feat: blocks UI part 2 (#6832)

This commit is contained in:
Anshuman Pandey
2025-11-21 16:29:57 +05:30
committed by GitHub
51 changed files with 1552 additions and 1051 deletions

View File

@@ -1127,6 +1127,7 @@ checksums:
environments/surveys/edit/add_photo_or_video: 7fd213e807ad060e415d1d4195397473
environments/surveys/edit/add_pin: 1bc282dd7eaea51301655d3e8dd3a9fb
environments/surveys/edit/add_question_below: 58e64eb2e013f1175ea0dcf79149109f
environments/surveys/edit/add_question_to_block: 8589b1042aa93531a836549d6036492c
environments/surveys/edit/add_row: a613cef4caf1f0e05697c8de5164e2a3
environments/surveys/edit/add_variable: 23f97e23aba763cc58934df4fa13ffc1
environments/surveys/edit/address_fields: 9cabb97c3deaff4f6cb3afc3d5cfaf0a
@@ -1158,6 +1159,8 @@ checksums:
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a
environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
environments/surveys/edit/button_url: 6f39f649a165a11873c11ea6403dba90
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
@@ -1221,6 +1224,7 @@ checksums:
environments/surveys/edit/create_group: 4566e056e5217dc02a383105892fe18c
environments/surveys/edit/create_your_own_survey: e3ddd53e0cfa409ca8dccfb3d77933e7
environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe
environments/surveys/edit/cta_button_label: ec070ffba38eae24751bb3a4c1e14c81
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
@@ -1239,9 +1243,11 @@ checksums:
environments/surveys/edit/does_not_include_one_of: 91090d2e0667faf654f6a81d9857440f
environments/surveys/edit/does_not_start_with: 9395869b54cdfb353a51a7e0864f4fd7
environments/surveys/edit/duplicate_block: d4ea4afb5fc5b18a81cbe0302fa05997
environments/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
environments/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
@@ -1317,11 +1323,12 @@ checksums:
environments/surveys/edit/hidden_field_used_in_recall: 70dee46bae18209e8861b654ff9a04ae
environments/surveys/edit/hidden_field_used_in_recall_ending_card: a985d03d18e33d83521961c9c981d0ee
environments/surveys/edit/hidden_field_used_in_recall_welcome: 22fef7001d5e60edbf877e7b435c1991
environments/surveys/edit/hide_advanced_settings: ffa251d7762030b72c12e92f3c69a9b4
environments/surveys/edit/hide_back_button: 9f355fb4a8e80485b9de521a952ffeb9
environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
environments/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hide_the_logo_in_this_specific_survey: 29d4c6c714886e57bc29ad292d0f5a00
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
@@ -1354,7 +1361,7 @@ checksums:
environments/surveys/edit/is_skipped: 9fb90b6578f603cca37d4e6c912bb401
environments/surveys/edit/is_submitted: 13e774a97ad5f5609555e6f99514e70f
environments/surveys/edit/italic: 555c60fb1d12ae305136202afa6deb3d
environments/surveys/edit/jump_to_question: 742aabed8845190825418aa429f01b2d
environments/surveys/edit/jump_to_block: 2fc00bd725c44f98861051c57bb2c392
environments/surveys/edit/keep_current_order: a7c944ad6b3515f2c4f83a2c81f8fc26
environments/surveys/edit/keep_showing_while_conditions_match: 2574802d87bd6da151c9145aacce7281
environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515
@@ -1368,16 +1375,18 @@ checksums:
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
environments/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
environments/surveys/edit/next_block: 53eaa5b1c9333455ab1e99bedd222ba2
environments/surveys/edit/next_button_label: e23522dd38f3eabeeccd3f48f32b73a8
environments/surveys/edit/next_question: 2e0f1ea264fb4bfcb8378b2b0cf7c18f
environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
environments/surveys/edit/no_images_found_for: 90f10f4611ed7b115a49595409b66ebe
environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
@@ -1483,11 +1492,12 @@ checksums:
environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929
environments/surveys/edit/settings_saved_successfully: eb109269bc59dd67ae09fd9eb53652d2
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
environments/surveys/edit/show_multiple_times: 5e6e0244c20feca78723c79aa1ddcf62
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
@@ -1513,6 +1523,7 @@ checksums:
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 219b15081cbafaa391e266bd2cc4c9d4

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "Foto oder Video hinzufügen",
"add_pin": "PIN hinzufügen",
"add_question_below": "Frage unten hinzufügen",
"add_question_to_block": "Frage zum Block hinzufügen",
"add_row": "Zeile hinzufügen",
"add_variable": "Variable hinzufügen",
"address_fields": "Adressfelder",
@@ -1243,6 +1244,8 @@
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
"button_external": "Externen Link aktivieren",
"button_external_description": "Fügen Sie eine Schaltfläche hinzu, die eine externe URL in einem neuen Tab öffnet",
"button_label": "Beschriftung",
"button_url": "URL",
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
@@ -1306,6 +1309,7 @@
"create_group": "Gruppe erstellen",
"create_your_own_survey": "Erstelle deine eigene Umfrage",
"css_selector": "CSS-Selektor",
"cta_button_label": "\"CTA\"-Schaltflächen-Beschriftung",
"custom_hostname": "Benutzerdefinierter Hostname",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"duplicate_block": "Block duplizieren",
"duplicate_question": "Frage duplizieren",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"element_not_found": "Frage nicht gefunden",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
"enable_spam_protection": "Spamschutz",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "Verstecktes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
"hidden_field_used_in_recall_ending_card": "Verstecktes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen.",
"hidden_field_used_in_recall_welcome": "Verstecktes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
"hide_advanced_settings": "Erweiterte Einstellungen ausblenden",
"hide_back_button": "'Zurück'-Button ausblenden",
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
"hide_block_settings": "Block-Einstellungen ausblenden",
"hide_logo": "Logo verstecken",
"hide_progress_bar": "Fortschrittsbalken ausblenden",
"hide_question_settings": "Frageeinstellungen ausblenden",
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
@@ -1439,7 +1446,7 @@
"is_skipped": "Wird übersprungen",
"is_submitted": "Wird eingereicht",
"italic": "Kursiv",
"jump_to_question": "Zur Frage springen",
"jump_to_block": "Zum Block springen",
"keep_current_order": "Bestehende Anordnung beibehalten",
"keep_showing_while_conditions_match": "Zeige weiter, solange die Bedingungen übereinstimmen",
"key": "Schlüssel",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
"long_answer": "Lange Antwort",
"long_answer_toggle_description": "Ermöglichen Sie den Befragten, längere Antworten über mehrere Zeilen zu schreiben.",
"lower_label": "Unteres Label",
"manage_languages": "Sprachen verwalten",
"matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen",
"max_file_size": "Max. Dateigröße",
"max_file_size_limit_is": "Max. Dateigröße ist",
"move_question_to_block": "Frage in Block verschieben",
"multiply": "Multiplizieren *",
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
"next_button_label": "Weiter",
"next_question": "Nächste Frage",
"next_block": "Nächster Block",
"next_button_label": "Beschriftung der Schaltfläche \"Weiter\"",
"no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder. Füge das erste unten hinzu.",
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "Stelle die globale Platzierung in den Look & Feel-Einstellungen ein.",
"settings_saved_successfully": "Einstellungen erfolgreich gespeichert",
"seven_points": "7 Punkte",
"show_advanced_settings": "Erweiterte Einstellungen anzeigen",
"show_block_settings": "Block-Einstellungen anzeigen",
"show_button": "Button anzeigen",
"show_language_switch": "Sprachwechsel anzeigen",
"show_multiple_times": "Mehrfach anzeigen",
"show_only_once": "Nur einmal anzeigen",
"show_question_settings": "Frageeinstellungen anzeigen",
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
@@ -1600,6 +1610,7 @@
"survey_placement": "Platzierung der Umfrage",
"survey_trigger": "Auslöser der Umfrage",
"switch_multi_lanugage_on_to_get_started": "Schalte Mehrsprachigkeit ein, um loszulegen 👉",
"target_block_not_found": "Zielblock nicht gefunden",
"targeted": "Gezielt",
"ten_points": "10 Punkte",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Die Umfrage wird mehrmals angezeigt, bis Du antwortest",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "Add photo or video",
"add_pin": "Add PIN",
"add_question_below": "Add question below",
"add_question_to_block": "Add question to block",
"add_row": "Add row",
"add_variable": "Add variable",
"address_fields": "Address Fields",
@@ -1243,6 +1244,8 @@
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
"button_external": "Enable External Link",
"button_external_description": "Add a button that opens an external URL in a new tab",
"button_label": "Button Label",
"button_url": "Button URL",
"cal_username": "Cal.com username or username/event",
@@ -1306,6 +1309,7 @@
"create_group": "Create group",
"create_your_own_survey": "Create your own survey",
"css_selector": "CSS Selector",
"cta_button_label": "\"CTA\" button label",
"custom_hostname": "Custom hostname",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"duplicate_block": "Duplicate block",
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"element_not_found": "Question not found",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
"enable_spam_protection": "Spam protection",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "Hidden field \"{hiddenField}\" is being recalled in question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Hidden field \"{hiddenField}\" is being recalled in Ending Card",
"hidden_field_used_in_recall_welcome": "Hidden field \"{hiddenField}\" is being recalled in Welcome card.",
"hide_advanced_settings": "Hide advanced settings",
"hide_back_button": "Hide 'Back' button",
"hide_back_button_description": "Do not display the back button in the survey",
"hide_block_settings": "Hide Block settings",
"hide_logo": "Hide logo",
"hide_progress_bar": "Hide progress bar",
"hide_question_settings": "Hide Question settings",
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
@@ -1439,7 +1446,7 @@
"is_skipped": "Is skipped",
"is_submitted": "Is submitted",
"italic": "Italic",
"jump_to_question": "Jump to question",
"jump_to_block": "Jump to block",
"keep_current_order": "Keep current order",
"keep_showing_while_conditions_match": "Keep showing while conditions match",
"key": "Key",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
"manage_languages": "Manage Languages",
"matrix_all_fields": "All fields",
"matrix_rows": "Rows",
"max_file_size": "Max file size",
"max_file_size_limit_is": "Max file size limit is",
"move_question_to_block": "Move question to block",
"multiply": "Multiply *",
"needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance",
"next_block": "Next block",
"next_button_label": "\"Next\" button label",
"next_question": "Next question",
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.",
"no_images_found_for": "No images found for ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "Set the global placement in the Look & Feel settings.",
"settings_saved_successfully": "Settings saved successfully.",
"seven_points": "7 points",
"show_advanced_settings": "Show Advanced settings",
"show_block_settings": "Show Block settings",
"show_button": "Show Button",
"show_language_switch": "Show language switch",
"show_multiple_times": "Show multiple times",
"show_only_once": "Show only once",
"show_question_settings": "Show Question settings",
"show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
@@ -1600,6 +1610,7 @@
"survey_placement": "Survey Placement",
"survey_trigger": "Survey Trigger",
"switch_multi_lanugage_on_to_get_started": "Switch multi-lanugage on to get started \uD83D\uDC49",
"target_block_not_found": "Target block not found",
"targeted": "Targeted",
"ten_points": "10 points",
"the_survey_will_be_shown_multiple_times_until_they_respond": "The survey will be shown multiple times until they respond",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "Ajouter une photo ou une vidéo",
"add_pin": "Ajouter un code PIN",
"add_question_below": "Ajouter une question ci-dessous",
"add_question_to_block": "Ajouter une question au bloc",
"add_row": "Ajouter une ligne",
"add_variable": "Ajouter une variable",
"address_fields": "Champs d'adresse",
@@ -1243,6 +1244,8 @@
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
"button_external": "Activer le lien externe",
"button_external_description": "Ajouter un bouton qui ouvre une URL externe dans un nouvel onglet",
"button_label": "Label du bouton",
"button_url": "URL du bouton",
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
@@ -1306,6 +1309,7 @@
"create_group": "Créer un groupe",
"create_your_own_survey": "Créez votre propre enquête",
"css_selector": "Sélecteur CSS",
"cta_button_label": "Libellé du bouton «CTA»",
"custom_hostname": "Nom d'hôte personnalisé",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "n'inclut pas un de",
"does_not_start_with": "Ne commence pas par",
"duplicate_block": "Dupliquer le bloc",
"duplicate_question": "Dupliquer la question",
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"element_not_found": "Question non trouvée",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
"enable_spam_protection": "Protection contre le spam",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
"hide_advanced_settings": "Cacher les paramètres avancés",
"hide_back_button": "Masquer le bouton 'Retour'",
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
"hide_block_settings": "Masquer les paramètres du bloc",
"hide_logo": "Cacher le logo",
"hide_progress_bar": "Cacher la barre de progression",
"hide_question_settings": "Masquer les paramètres de la question",
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
"hostname": "Nom d'hôte",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
@@ -1439,7 +1446,7 @@
"is_skipped": "Est ignoré",
"is_submitted": "Est soumis",
"italic": "Italique",
"jump_to_question": "Passer à la question",
"jump_to_block": "Aller au bloc",
"keep_current_order": "Conserver la commande actuelle",
"keep_showing_while_conditions_match": "Continuer à afficher tant que les conditions correspondent",
"key": "Clé",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "Changer causera des erreurs logiques",
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
"long_answer": "Longue réponse",
"long_answer_toggle_description": "Permettre aux répondants d'écrire des réponses plus longues et sur plusieurs lignes.",
"lower_label": "Étiquette inférieure",
"manage_languages": "Gérer les langues",
"matrix_all_fields": "Tous les champs",
"matrix_rows": "Lignes",
"max_file_size": "Taille maximale du fichier",
"max_file_size_limit_is": "La taille maximale du fichier est",
"move_question_to_block": "Déplacer la question vers le bloc",
"multiply": "Multiplier *",
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
"next_button_label": "Label du bouton \"Suivant\"",
"next_question": "Question suivante",
"next_block": "Bloc suivant",
"next_button_label": "Libellé du bouton «Suivant »",
"no_hidden_fields_yet_add_first_one_below": "Aucun champ caché pour le moment. Ajoutez le premier ci-dessous.",
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "Définissez le placement global dans les paramètres d'apparence.",
"settings_saved_successfully": "Paramètres enregistrés avec succès",
"seven_points": "7 points",
"show_advanced_settings": "Afficher les paramètres avancés",
"show_block_settings": "Afficher les paramètres du bloc",
"show_button": "Afficher le bouton",
"show_language_switch": "Afficher le changement de langue",
"show_multiple_times": "Afficher plusieurs fois",
"show_only_once": "Afficher une seule fois",
"show_question_settings": "Afficher les paramètres de la question",
"show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
@@ -1600,6 +1610,7 @@
"survey_placement": "Placement de l'enquête",
"survey_trigger": "Déclencheur d'enquête",
"switch_multi_lanugage_on_to_get_started": "Activez le multilingue pour commencer 👉",
"target_block_not_found": "Bloc cible non trouvé",
"targeted": "Ciblé",
"ten_points": "10 points",
"the_survey_will_be_shown_multiple_times_until_they_respond": "L'enquête sera affichée plusieurs fois jusqu'à ce qu'ils répondent.",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "写真または動画を追加",
"add_pin": "PINを追加",
"add_question_below": "以下に質問を追加",
"add_question_to_block": "ブロックに質問を追加",
"add_row": "行を追加",
"add_variable": "変数を追加",
"address_fields": "住所フィールド",
@@ -1243,6 +1244,8 @@
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
"button_external": "外部リンクを有効にする",
"button_external_description": "新しいタブで外部URLを開くボタンを追加する",
"button_label": "ボタンのラベル",
"button_url": "ボタンURL",
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
@@ -1306,6 +1309,7 @@
"create_group": "グループを作成",
"create_your_own_survey": "独自のフォームを作成",
"css_selector": "CSSセレクター",
"cta_button_label": "\"CTA\"ボタンのラベル",
"custom_hostname": "カスタムホスト名",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "のいずれも含まない",
"does_not_start_with": "で始まらない",
"duplicate_block": "ブロックを複製",
"duplicate_question": "質問を複製",
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"element_not_found": "質問が見つかりません",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
"enable_recaptcha_to_protect_your_survey_from_spam": "スパム対策はreCAPTCHA v3を使用してスパム回答をフィルタリングします。",
"enable_spam_protection": "スパム対策",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
"hide_advanced_settings": "詳細設定を非表示",
"hide_back_button": "「戻る」ボタンを非表示",
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
"hide_block_settings": "ブロック設定を非表示",
"hide_logo": "ロゴを非表示",
"hide_progress_bar": "プログレスバーを非表示",
"hide_question_settings": "質問設定を非表示",
"hide_the_logo_in_this_specific_survey": "この特定のフォームでロゴを非表示にする",
"hostname": "ホスト名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "{surveyTypeDerived} フォームのカードをどれくらいユニークにしますか",
@@ -1439,7 +1446,7 @@
"is_skipped": "スキップ済み",
"is_submitted": "送信済み",
"italic": "イタリック",
"jump_to_question": "質問にジャンプ",
"jump_to_block": "ブロックへジャンプ",
"keep_current_order": "現在の順序を維持",
"keep_showing_while_conditions_match": "条件が一致する間、表示し続ける",
"key": "キー",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "変更するとロジックエラーが発生します",
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
"long_answer": "長文回答",
"long_answer_toggle_description": "回答者が長文の複数行の回答を書けるようにします。",
"lower_label": "下限ラベル",
"manage_languages": "言語を管理",
"matrix_all_fields": "すべてのフィールド",
"matrix_rows": "行",
"max_file_size": "最大ファイルサイズ",
"max_file_size_limit_is": "最大ファイルサイズの上限は",
"move_question_to_block": "質問をブロックに移動",
"multiply": "乗算 *",
"needed_for_self_hosted_cal_com_instance": "セルフホストのCal.comインスタンスに必要",
"next_block": "次のブロック",
"next_button_label": "「次へ」ボタンのラベル",
"next_question": "次の質問",
"no_hidden_fields_yet_add_first_one_below": "まだ非表示フィールドがありません。以下で最初のものを追加してください。",
"no_images_found_for": "''{query}'' の画像が見つかりません",
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "「デザイン」設定でグローバルな配置を設定します。",
"settings_saved_successfully": "設定を正常に保存しました。",
"seven_points": "7点",
"show_advanced_settings": "詳細設定を表示",
"show_block_settings": "ブロック設定を表示",
"show_button": "ボタンを表示",
"show_language_switch": "言語切り替えを表示",
"show_multiple_times": "複数回表示",
"show_only_once": "一度だけ表示",
"show_question_settings": "質問設定を表示",
"show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
@@ -1600,6 +1610,7 @@
"survey_placement": "フォームの配置",
"survey_trigger": "フォームのトリガー",
"switch_multi_lanugage_on_to_get_started": "始めるには多言語をオンにしてください 👉",
"target_block_not_found": "対象ブロックが見つかりません",
"targeted": "ターゲット",
"ten_points": "10点",
"the_survey_will_be_shown_multiple_times_until_they_respond": "回答するまで複数回フォームが表示されます",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "Adicionar foto ou video",
"add_pin": "Adicionar PIN",
"add_question_below": "Adicione a pergunta abaixo",
"add_question_to_block": "Adicionar pergunta ao bloco",
"add_row": "Adicionar linha",
"add_variable": "Adicionar variável",
"address_fields": "Campos de Endereço",
@@ -1243,6 +1244,8 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
"button_external": "Habilitar link externo",
"button_external_description": "Adicionar um botão que abre uma URL externa em uma nova aba",
"button_label": "Rótulo do Botão",
"button_url": "URL do Botão",
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
@@ -1306,6 +1309,7 @@
"create_group": "Criar grupo",
"create_your_own_survey": "Crie sua própria pesquisa",
"css_selector": "Seletor CSS",
"cta_button_label": "Rótulo do botão \"CTA\"",
"custom_hostname": "Hostname personalizado",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"duplicate_block": "Duplicar bloco",
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"element_not_found": "Pergunta não encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
"hide_advanced_settings": "Ocultar configurações avançadas",
"hide_back_button": "Ocultar botão 'Voltar'",
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
"hide_block_settings": "Ocultar configurações do bloco",
"hide_logo": "Esconder logo",
"hide_progress_bar": "Esconder barra de progresso",
"hide_question_settings": "Ocultar configurações da pergunta",
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
"hostname": "nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
@@ -1439,7 +1446,7 @@
"is_skipped": "é pulado",
"is_submitted": "é submetido",
"italic": "Itálico",
"jump_to_question": "Pular para a pergunta",
"jump_to_block": "Pular para o bloco",
"keep_current_order": "Manter pedido atual",
"keep_showing_while_conditions_match": "Continue mostrando enquanto as condições corresponderem",
"key": "chave",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "Mudar vai causar erros de lógica",
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
"long_answer": "resposta longa",
"long_answer_toggle_description": "Permitir que os respondentes escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
"manage_languages": "Gerenciar Idiomas",
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo do arquivo",
"max_file_size_limit_is": "Tamanho máximo do arquivo é",
"move_question_to_block": "Mover pergunta para o bloco",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
"next_block": "Próximo bloco",
"next_button_label": "Próximo",
"next_question": "próxima pergunta",
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "Defina o posicionamento global nas configurações de Aparência.",
"settings_saved_successfully": "Configurações salvas com sucesso",
"seven_points": "7 pontos",
"show_advanced_settings": "Mostrar configurações avançadas",
"show_block_settings": "Mostrar configurações do bloco",
"show_button": "Mostrar Botão",
"show_language_switch": "Mostrar troca de idioma",
"show_multiple_times": "Mostrar várias vezes",
"show_only_once": "Mostrar só uma vez",
"show_question_settings": "Mostrar configurações da pergunta",
"show_survey_maximum_of": "Mostrar no máximo",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
@@ -1600,6 +1610,7 @@
"survey_placement": "Posicionamento da Pesquisa",
"survey_trigger": "Gatilho de Pesquisa",
"switch_multi_lanugage_on_to_get_started": "Ative o modo multilíngue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
"targeted": "direcionado",
"ten_points": "10 pontos",
"the_survey_will_be_shown_multiple_times_until_they_respond": "A pesquisa vai ser mostrada várias vezes até eles responderem",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "Adicionar foto ou vídeo",
"add_pin": "Adicionar PIN",
"add_question_below": "Adicionar pergunta abaixo",
"add_question_to_block": "Adicionar pergunta ao bloco",
"add_row": "Adicionar linha",
"add_variable": "Adicionar variável",
"address_fields": "Campos de Endereço",
@@ -1243,6 +1244,8 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
"button_external": "Ativar link externo",
"button_external_description": "Adicionar um botão que abre um URL externo num novo separador",
"button_label": "Rótulo do botão",
"button_url": "URL do botão",
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
@@ -1306,6 +1309,7 @@
"create_group": "Criar grupo",
"create_your_own_survey": "Crie o seu próprio inquérito",
"css_selector": "Seletor CSS",
"cta_button_label": "Etiqueta do botão \"CTA\"",
"custom_hostname": "Nome do host personalizado",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"duplicate_block": "Duplicar bloco",
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"element_not_found": "Pergunta não encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
"enable_spam_protection": "Proteção contra spam",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
"hide_advanced_settings": "Ocultar definições avançadas",
"hide_back_button": "Ocultar botão 'Retroceder'",
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
"hide_block_settings": "Ocultar definições do bloco",
"hide_logo": "Esconder logótipo",
"hide_progress_bar": "Ocultar barra de progresso",
"hide_question_settings": "Ocultar definições da pergunta",
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
"hostname": "Nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
@@ -1439,7 +1446,7 @@
"is_skipped": "É ignorado",
"is_submitted": "Está submetido",
"italic": "Itálico",
"jump_to_question": "Saltar para a pergunta",
"jump_to_block": "Saltar para o bloco",
"keep_current_order": "Manter ordem atual",
"keep_showing_while_conditions_match": "Continuar a mostrar enquanto as condições corresponderem",
"key": "Chave",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "A alteração causará erros de lógica",
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
"long_answer": "Resposta longa",
"long_answer_toggle_description": "Permitir que os inquiridos escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
"manage_languages": "Gerir Idiomas",
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo do ficheiro",
"max_file_size_limit_is": "O limite do tamanho máximo do ficheiro é",
"move_question_to_block": "Mover pergunta para o bloco",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
"next_block": "Bloco seguinte",
"next_button_label": "Rótulo do botão \"Seguinte\"",
"next_question": "Próxima pergunta",
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "Definir a colocação global nas definições de Aparência.",
"settings_saved_successfully": "Definições guardadas com sucesso",
"seven_points": "7 pontos",
"show_advanced_settings": "Mostrar definições avançadas",
"show_block_settings": "Mostrar definições do bloco",
"show_button": "Mostrar Botão",
"show_language_switch": "Mostrar alternador de idioma",
"show_multiple_times": "Mostrar várias vezes",
"show_only_once": "Mostrar apenas uma vez",
"show_question_settings": "Mostrar definições da pergunta",
"show_survey_maximum_of": "Mostrar inquérito máximo de",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
@@ -1600,6 +1610,7 @@
"survey_placement": "Colocação do Inquérito",
"survey_trigger": "Desencadeador de Inquérito",
"switch_multi_lanugage_on_to_get_started": "Ative o modo multilingue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
"targeted": "Alvo",
"ten_points": "10 pontos",
"the_survey_will_be_shown_multiple_times_until_they_respond": "O inquérito será mostrado várias vezes até que respondam",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "Adaugă fotografie sau video",
"add_pin": "Adaugă PIN",
"add_question_below": "Adaugă întrebare mai jos",
"add_question_to_block": "Adaugă întrebare în bloc",
"add_row": "Adăugați rând",
"add_variable": "Adaugă variabilă",
"address_fields": "Câmpuri Adresă",
@@ -1243,6 +1244,8 @@
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
"button_external": "Activează link extern",
"button_external_description": "Adaugă un buton care deschide un URL extern într-o filă nouă",
"button_label": "Etichetă buton",
"button_url": "URL Buton",
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
@@ -1306,6 +1309,7 @@
"create_group": "Creează grup",
"create_your_own_survey": "Creează-ți propriul chestionar",
"css_selector": "Selector CSS",
"cta_button_label": "Eticheta butonului \"CTA\"",
"custom_hostname": "Gazdă personalizată",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu",
"duplicate_block": "Duplicați blocul",
"duplicate_question": "Duplică întrebarea",
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"element_not_found": "Întrebarea nu a fost găsită",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.",
"enable_spam_protection": "Protecția împotriva spamului",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
"hide_advanced_settings": "Ascunde setări avansate",
"hide_back_button": "Ascunde butonul 'Înapoi'",
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
"hide_block_settings": "Ascunde setările blocului",
"hide_logo": "Ascunde logo",
"hide_progress_bar": "Ascunde bara de progres",
"hide_question_settings": "Ascunde setările întrebării",
"hide_the_logo_in_this_specific_survey": "Ascunde logo-ul în acest chestionar specific",
"hostname": "Nume gazdă",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
@@ -1439,7 +1446,7 @@
"is_skipped": "Este sărit",
"is_submitted": "Este trimis",
"italic": "Cursiv",
"jump_to_question": "Sări la întrebare",
"jump_to_block": "Sari la bloc",
"keep_current_order": "Păstrați ordinea actuală",
"keep_showing_while_conditions_match": "Continuă să afișezi cât timp condițiile se potrivesc",
"key": "Cheie",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "Schimbarea va provoca erori de logică",
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
"long_answer": "Răspuns lung",
"long_answer_toggle_description": "Permite respondenților să scrie răspunsuri mai lungi, pe mai multe rânduri.",
"lower_label": "Etichetă inferioară",
"manage_languages": "Gestionați limbile",
"matrix_all_fields": "Toate câmpurile",
"matrix_rows": "Rânduri",
"max_file_size": "Dimensiune maximă fișier",
"max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este",
"move_question_to_block": "Mută întrebarea în bloc",
"multiply": "Multiplicare",
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
"next_block": "Blocul următor",
"next_button_label": "Etichetă buton \"Următorul\"",
"next_question": "Întrebarea următoare",
"no_hidden_fields_yet_add_first_one_below": "Nu există încă câmpuri ascunse. Adăugați primul mai jos.",
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "Setați amplasarea globală în setările Aspect & Stil.",
"settings_saved_successfully": "Setările au fost salvate cu succes.",
"seven_points": "7 puncte",
"show_advanced_settings": "Afișați setările avansate",
"show_block_settings": "Afișează setările blocului",
"show_button": "Afișează butonul",
"show_language_switch": "Afișează comutatorul de limbă",
"show_multiple_times": "Afișează de mai multe ori",
"show_only_once": "Afișează doar o dată",
"show_question_settings": "Afișează setările întrebării",
"show_survey_maximum_of": "Afișează sondajul de maxim",
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
@@ -1600,6 +1610,7 @@
"survey_placement": "Amplasarea sondajului",
"survey_trigger": "Declanșator sondaj",
"switch_multi_lanugage_on_to_get_started": "Comutați pe modul multilingv pentru a începe 👉",
"target_block_not_found": "Blocul țintă nu a fost găsit",
"targeted": "Ţintite",
"ten_points": "10 puncte",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Sondajul va fi afișat de mai multe ori până când vor răspunde",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "添加 照片 或 视频",
"add_pin": "添加 PIN",
"add_question_below": "在下面 添加 问题",
"add_question_to_block": "添加问题到区块",
"add_row": "添加 行",
"add_variable": "添加 变量",
"address_fields": "地址字段",
@@ -1243,6 +1244,8 @@
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
"button_external": "启用外部链接",
"button_external_description": "添加一个按钮在新标签页中打开外部URL",
"button_label": "按钮标签",
"button_url": "按钮 URL",
"cal_username": "Cal.com 用户名 或 用户名/事件",
@@ -1306,6 +1309,7 @@
"create_group": "创建 群组",
"create_your_own_survey": "创建 你 的 调查",
"css_selector": "CSS 选择器",
"cta_button_label": "“CTA”按钮标签",
"custom_hostname": "自 定 义 主 机 名",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "不包括一 个",
"does_not_start_with": "不 以 开头",
"duplicate_block": "复制区块",
"duplicate_question": "复制问题",
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"element_not_found": "未找到问题",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾 邮件 保护 使用 reCAPTCHA v3 来 过滤 掉 垃圾 响应 。",
"enable_spam_protection": "垃圾 邮件 保护",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
"hide_advanced_settings": "隐藏 高级设置",
"hide_back_button": "隐藏 \"返回\" 按钮",
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
"hide_block_settings": "隐藏区块设置",
"hide_logo": "隐藏 徽标",
"hide_progress_bar": "隐藏 进度 条",
"hide_question_settings": "隐藏问题设置",
"hide_the_logo_in_this_specific_survey": "隐藏此特定调查中的 logo",
"hostname": "主 机 名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
@@ -1439,7 +1446,7 @@
"is_skipped": "已跳过",
"is_submitted": "已提交",
"italic": "斜体",
"jump_to_question": "跳 转 到 问题",
"jump_to_block": "跳转到区块",
"keep_current_order": "保持 当前 顺序",
"keep_showing_while_conditions_match": "条件 符合 时 保持 显示",
"key": "键",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "更改 将 导致 逻辑 错误",
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
"long_answer": "长答案",
"long_answer_toggle_description": "允许受访者填写较长的多行答案。",
"lower_label": "下限标签",
"manage_languages": "管理 语言",
"matrix_all_fields": "所有字段",
"matrix_rows": "行",
"max_file_size": "最大 文件 大小",
"max_file_size_limit_is": "最大 文件 大小 限制 是",
"move_question_to_block": "将问题移动到区块",
"multiply": "乘 *",
"needed_for_self_hosted_cal_com_instance": "需要用于 自建 Cal.com 实例",
"next_block": "下一块",
"next_button_label": "\"下一步\" 按钮标签",
"next_question": "下一个问题",
"no_hidden_fields_yet_add_first_one_below": "还没有隐藏字段。 在下面添加第一个。",
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加第一个以开始。",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "在外观 设置中 设置 全局 放置。",
"settings_saved_successfully": "设置 保存 成功",
"seven_points": "7 分",
"show_advanced_settings": "显示 高级设置",
"show_block_settings": "显示区块设置",
"show_button": "显示 按钮",
"show_language_switch": "显示 语言 切换",
"show_multiple_times": "显示 多次",
"show_only_once": "仅 显示 一次",
"show_question_settings": "显示问题设置",
"show_survey_maximum_of": "显示 调查 最大 一次",
"show_survey_to_users": "显示 问卷 给 % 的 用户",
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
@@ -1600,6 +1610,7 @@
"survey_placement": "调查 放置",
"survey_trigger": "调查 触发",
"switch_multi_lanugage_on_to_get_started": "打开多语言以开始 👉",
"target_block_not_found": "未找到目标区块",
"targeted": "定位",
"ten_points": "10 分",
"the_survey_will_be_shown_multiple_times_until_they_respond": "调查 将 显示 多次 直到 他们 回复",

View File

@@ -1212,6 +1212,7 @@
"add_photo_or_video": "新增照片或影片",
"add_pin": "新增 PIN 碼",
"add_question_below": "在下方新增問題",
"add_question_to_block": "新增問題到區塊",
"add_row": "新增列",
"add_variable": "新增變數",
"address_fields": "地址欄位",
@@ -1243,6 +1244,8 @@
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
"button_external": "啟用外部連結",
"button_external_description": "新增一個按鈕,在新分頁中開啟外部網址",
"button_label": "按鈕標籤",
"button_url": "按鈕網址",
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
@@ -1306,6 +1309,7 @@
"create_group": "建立群組",
"create_your_own_survey": "建立您自己的問卷",
"css_selector": "CSS 選取器",
"cta_button_label": "「CTA」按鈕標籤",
"custom_hostname": "自訂主機名稱",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
@@ -1324,9 +1328,11 @@
"does_not_include_one_of": "不包含其中之一",
"does_not_start_with": "不以...開頭",
"duplicate_block": "複製區塊",
"duplicate_question": "複製問題",
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"element_not_found": "找不到問題",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
"enable_spam_protection": "垃圾郵件保護",
@@ -1402,11 +1408,12 @@
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
"hide_advanced_settings": "隱藏進階設定",
"hide_back_button": "隱藏「Back」按鈕",
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
"hide_block_settings": "隱藏區塊設定",
"hide_logo": "隱藏標誌",
"hide_progress_bar": "隱藏進度列",
"hide_question_settings": "隱藏問題設定",
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
"hostname": "主機名稱",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
@@ -1439,7 +1446,7 @@
"is_skipped": "已跳過",
"is_submitted": "已提交",
"italic": "斜體",
"jump_to_question": "跳至問題",
"jump_to_block": "跳至區塊",
"keep_current_order": "保留目前順序",
"keep_showing_while_conditions_match": "在條件符合時持續顯示",
"key": "金鑰",
@@ -1453,16 +1460,18 @@
"logic_error_warning": "變更將導致邏輯錯誤",
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
"long_answer": "長回答",
"long_answer_toggle_description": "允許受訪者撰寫較長的多行回答。",
"lower_label": "下標籤",
"manage_languages": "管理語言",
"matrix_all_fields": "所有欄位",
"matrix_rows": "列",
"max_file_size": "最大檔案大小",
"max_file_size_limit_is": "最大檔案大小限制為",
"move_question_to_block": "將問題移至區塊",
"multiply": "乘 *",
"needed_for_self_hosted_cal_com_instance": "自行託管 Cal.com 執行個體時需要",
"next_block": "下一個區塊",
"next_button_label": "「下一步」按鈕標籤",
"next_question": "下一個問題",
"no_hidden_fields_yet_add_first_one_below": "尚無隱藏欄位。在下方新增第一個隱藏欄位。",
"no_images_found_for": "找不到「'{'query'}'」的圖片",
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
@@ -1570,11 +1579,12 @@
"set_the_global_placement_in_the_look_feel_settings": "在「外觀與風格」設定中設定整體位置。",
"settings_saved_successfully": "設定已成功儲存",
"seven_points": "7 分",
"show_advanced_settings": "顯示進階設定",
"show_block_settings": "顯示區塊設定",
"show_button": "顯示按鈕",
"show_language_switch": "顯示語言切換",
"show_multiple_times": "多次顯示",
"show_only_once": "僅顯示一次",
"show_question_settings": "顯示問題設定",
"show_survey_maximum_of": "最多顯示問卷",
"show_survey_to_users": "將問卷顯示給 % 的使用者",
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
@@ -1600,6 +1610,7 @@
"survey_placement": "問卷位置",
"survey_trigger": "問卷觸發器",
"switch_multi_lanugage_on_to_get_started": "開啟多語言以開始使用 👉",
"target_block_not_found": "找不到目標區塊",
"targeted": "目標",
"ten_points": "10 分",
"the_survey_will_be_shown_multiple_times_until_they_respond": "將多次顯示問卷,直到他們回應",

View File

@@ -20,7 +20,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { getDefaultOperatorForElement } from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
@@ -95,7 +95,7 @@ export const QuotaModal = ({
{
id: createId(),
leftOperand: { type: "question", value: firstQuestion?.id },
operator: firstQuestion ? getDefaultOperatorForQuestion(firstQuestion, t) : "equals",
operator: firstQuestion ? getDefaultOperatorForElement(firstQuestion, t) : "equals",
},
],
},

View File

@@ -24,6 +24,7 @@ import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import {
determineImageUploaderVisibility,
@@ -309,6 +310,60 @@ export const QuestionFormInput = ({
return false;
};
const getIsRequiredToggleDisabled = (): boolean => {
if (!question) return false;
if (question.type === TSurveyElementTypeEnum.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 === TSurveyElementTypeEnum.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 useRichTextEditor = id === "headline" || id === "subheader" || id === "html";
// For rich text editor fields, we need either updateQuestion or updateSurvey
@@ -320,8 +375,23 @@ export const QuestionFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3">
<div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && question && updateQuestion && (
<div className="flex items-center space-x-2">
<Label htmlFor="required-toggle" className="text-sm">
{t("environments.surveys.edit.required")}
</Label>
<Switch
id="required-toggle"
checked={question.required}
disabled={getIsRequiredToggleDisabled()}
onCheckedChange={(checked) => {
updateQuestion(questionIdx, { required: checked });
}}
/>
</div>
)}
</div>
)}
<div className="flex flex-col gap-4" ref={animationParent}>
@@ -375,7 +445,9 @@ export const QuestionFormInput = ({
</div>
{id === "headline" && !isWelcomeCard && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
<TooltipRenderer
tooltipContent={t("environments.surveys.edit.add_photo_or_video")}
delayDuration={100}>
<Button
variant="secondary"
size="icon"
@@ -433,8 +505,23 @@ export const QuestionFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3">
<div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && question && updateQuestion && (
<div className="flex items-center space-x-2">
<Label htmlFor="required-toggle" className="text-sm">
{t("environments.surveys.edit.required")}
</Label>
<Switch
id="required-toggle"
checked={question.required}
disabled={getIsRequiredToggleDisabled()}
onCheckedChange={(checked) => {
updateQuestion(questionIdx, { required: checked });
}}
/>
</div>
)}
</div>
)}
<MultiLangWrapper

View File

@@ -29,6 +29,7 @@ interface AddQuestionToBlockButtonProps {
localSurvey: TSurvey;
block: TSurveyBlock;
setLocalSurvey: (survey: TSurvey) => void;
setActiveQuestionId: (questionId: string) => void;
project: Project;
isCxMode: boolean;
}
@@ -37,6 +38,7 @@ export const AddQuestionToBlockButton = ({
localSurvey,
block,
setLocalSurvey,
setActiveQuestionId,
project,
isCxMode,
}: AddQuestionToBlockButtonProps) => {
@@ -70,7 +72,7 @@ export const AddQuestionToBlockButton = ({
setLocalSurvey(result.data);
setOpen(false);
toast.success("Question added to block");
setActiveQuestionId(questionWithLabels.id);
};
return (
@@ -79,7 +81,9 @@ export const AddQuestionToBlockButton = ({
<Button variant="secondary">
<PlusIcon className="h-4 w-4" />
<div>
<p className="text-sm font-medium text-slate-900">Add question to block</p>
<p className="text-sm font-medium text-slate-900">
{t("environments.surveys.edit.add_question_to_block")}
</p>
</div>
</Button>
</DropdownMenuTrigger>

View File

@@ -1,7 +1,6 @@
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ConditionalLogic } from "@/modules/survey/editor/components/conditional-logic";
import { OptionIds } from "@/modules/survey/editor/components/option-ids";
import { UpdateQuestionId } from "@/modules/survey/editor/components/update-question-id";
@@ -20,8 +19,6 @@ export const AdvancedSettings = ({
questionIdx,
localSurvey,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
selectedLanguageCode,
}: AdvancedSettingsProps) => {
const showOptionIds =
@@ -32,16 +29,6 @@ export const AdvancedSettings = ({
return (
<div className="flex flex-col gap-4">
{/* TODO: Re-enable ConditionalLogic in post-MVP */}
{/* <ConditionalLogic
question={question}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
localSurvey={localSurvey}
questionIdx={questionIdx}
/> */}
<UpdateQuestionId
question={question}
questionIdx={questionIdx}

View File

@@ -16,11 +16,11 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { recallToHeadline } from "@/lib/utils/recall";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { AddQuestionToBlockButton } from "@/modules/survey/editor/components/add-question-to-block-button";
import { AddressQuestionForm } from "@/modules/survey/editor/components/address-question-form";
import { AdvancedSettings } from "@/modules/survey/editor/components/advanced-settings";
import { BlockMenu } from "@/modules/survey/editor/components/block-menu";
import { BlockSettings } from "@/modules/survey/editor/components/block-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";
@@ -36,10 +36,9 @@ import { PictureSelectionForm } from "@/modules/survey/editor/components/picture
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface BlockCardProps {
localSurvey: TSurvey;
@@ -77,6 +76,7 @@ interface BlockCardProps {
deleteBlock: (blockId: string) => void;
moveBlock: (blockId: string, direction: "up" | "down") => void;
addElementToBlock: (element: TSurveyElement, blockId: string, afterElementIdx: number) => void;
moveElementToBlock?: (elementId: string, targetBlockId: string) => void;
totalBlocks: number;
}
@@ -112,6 +112,7 @@ export const BlockCard = ({
deleteBlock,
moveBlock,
addElementToBlock,
moveElementToBlock,
totalBlocks,
}: BlockCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -120,35 +121,35 @@ export const BlockCard = ({
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 hasInvalidElement = block.elements.some((element) => invalidQuestions?.includes(element.id));
// Check if button labels have incomplete translations for any enabled language
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
const hasInvalidButtonLabel =
block.buttonLabel !== undefined &&
!isLabelValidForAllLanguages(block.buttonLabel, localSurvey.languages ?? []);
// Check if back button label is invalid
// Back button label should exist for all blocks except the first one
const hasInvalidBackButtonLabel =
blockIdx > 0 &&
block.backButtonLabel !== undefined &&
!isLabelValidForAllLanguages(block.backButtonLabel, localSurvey.languages ?? []);
// Block should be highlighted if it has invalid elements OR invalid button labels
const isBlockInvalid = hasInvalidElement || hasInvalidButtonLabel || hasInvalidBackButtonLabel;
const [isBlockCollapsed, setIsBlockCollapsed] = useState(false);
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 [elementsParent] = useAutoAnimate();
const getElementHeadline = (
element: TSurveyElement,
@@ -177,11 +178,7 @@ export const BlockCard = ({
);
};
const renderElementForm = (
element: TSurveyElement,
questionIdx: number,
blockButtonLabel?: TI18nString
) => {
const renderElementForm = (element: TSurveyElement, questionIdx: number) => {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return (
@@ -236,14 +233,12 @@ export const BlockCard = ({
question={element}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={invalidQuestions ? invalidQuestions.includes(element.id) : false}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
buttonLabel={blockButtonLabel}
/>
);
case TSurveyElementTypeEnum.CTA:
@@ -427,6 +422,16 @@ export const BlockCard = ({
zIndex: isDragging ? 10 : 1,
};
const blockQuestionCount = block.elements.length;
const blockQuestionCountText = blockQuestionCount === 1 ? "question" : "questions";
let blockSidebarColorClass = "";
if (isBlockInvalid) {
blockSidebarColorClass = "bg-red-400";
} else {
blockSidebarColorClass = isBlockOpen ? "bg-slate-700" : "bg-slate-400";
}
return (
<div
className={cn(
@@ -440,11 +445,14 @@ export const BlockCard = ({
{...listeners}
{...attributes}
className={cn(
isBlockOpen ? "bg-slate-700" : "bg-slate-400",
// isBlockInvalid ? "bg-red-400" : isBlockOpen ? "bg-slate-700" : "bg-slate-400",
blockSidebarColorClass,
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
"flex flex-col items-center justify-between"
"flex flex-col items-center justify-between gap-2"
)}>
<div className="mt-3 flex w-full items-center justify-center text-xs font-medium">{blockIdx + 1}</div>
<div className="mt-3 flex w-full items-center justify-center rounded-full bg-white p-1 text-xs font-medium text-slate-900">
{blockIdx + 1}
</div>
<button
className="opacity-0 hover:cursor-move group-hover:opacity-100"
@@ -452,376 +460,209 @@ export const BlockCard = ({
<GripIcon className="h-4 w-4" />
</button>
</div>
<div className="w-[95%] flex-1 rounded-r-lg border border-slate-200">
{/* Block header - shown when block has multiple elements */}
{hasMultipleElements && (
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2">
<div>
<h4 className="text-sm font-medium text-slate-700">{blockName}</h4>
<p className="text-xs text-slate-500">{block.elements.length} questions</p>
<div className="flex-1 rounded-r-lg border border-slate-200">
<Collapsible.Root
open={!isBlockCollapsed}
onOpenChange={() => setIsBlockCollapsed(!isBlockCollapsed)}
className={cn(isBlockCollapsed ? "h-full" : "")}>
<Collapsible.CollapsibleTrigger
asChild
className="block h-full w-full cursor-pointer hover:bg-slate-100">
<div className="flex h-full items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<div>
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
<p className="text-xs text-slate-500">
{blockQuestionCount} {blockQuestionCountText}
</p>
</div>
</div>
<div>
<BlockMenu
isFirstBlock={blockIdx === 0}
isLastBlock={blockIdx === totalBlocks - 1}
isOnlyBlock={totalBlocks === 1}
onDuplicate={() => duplicateBlock(block.id)}
onDelete={() => deleteBlock(block.id)}
onMoveUp={() => moveBlock(block.id, "up")}
onMoveDown={() => moveBlock(block.id, "down")}
/>
</div>
</div>
<BlockMenu
blockIndex={blockIdx}
isFirstBlock={blockIdx === 0}
isLastBlock={blockIdx === totalBlocks - 1}
onDuplicate={() => duplicateBlock(block.id)}
onDelete={() => deleteBlock(block.id)}
onMoveUp={() => moveBlock(block.id, "up")}
onMoveDown={() => moveBlock(block.id, "down")}
/>
</div>
)}
</Collapsible.CollapsibleTrigger>
{/* 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;
<Collapsible.CollapsibleContent>
{/* Render each element in the block */}
<div ref={elementsParent}>
{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 isOpen = activeQuestionId === element.id;
const getIsRequiredToggleDisabled = (): boolean => {
if (element.type === TSurveyElementTypeEnum.Address) {
const allFieldsAreOptional = [
element.addressLine1,
element.addressLine2,
element.city,
element.state,
element.zip,
element.country,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
return true;
}
return [
element.addressLine1,
element.addressLine2,
element.city,
element.state,
element.zip,
element.country,
]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
if (element.type === TSurveyElementTypeEnum.ContactInfo) {
const allFieldsAreOptional = [
element.firstName,
element.lastName,
element.email,
element.phone,
element.company,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
return true;
}
return [element.firstName, element.lastName, element.email, element.phone, element.company]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
return false;
};
const handleRequiredToggle = () => {
// Fix for NPS and Rating element having missing translations when buttonLabel is not removed
if (!element.required && (element.type === "nps" || element.type === "rating")) {
// Remove buttonLabel from the block when making NPS/Rating required
updateBlockButtonLabel(blockIdx, "buttonLabel", undefined);
updateQuestion(questionIdx, { required: true });
} else {
updateQuestion(questionIdx, { required: !element.required });
}
};
return (
<div key={element.id} className={cn(elementIndex > 0 && "border-t border-slate-200")}>
<Collapsible.Root
open={open}
onOpenChange={() => {
if (activeQuestionId !== element.id) {
setActiveQuestionId(element.id);
} else {
setActiveQuestionId(null);
}
}}
className="w-full">
<Collapsible.CollapsibleTrigger
asChild
className={cn(
open ? "bg-slate-50" : "",
"flex w-full cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50"
)}
aria-label="Toggle question details">
<div>
<div className="flex grow">
<div className="flex grow items-center gap-3" dir="auto">
<div className="flex items-center text-slate-600">
{QUESTIONS_ICON_MAP[element.type]}
</div>
<div className="flex grow flex-col justify-center">
{hasMultipleElements && (
<p className="mb-1 text-xs font-medium text-slate-500">
Question {elementIndex + 1}
</p>
)}
<h3 className="text-sm font-semibold">
{getElementHeadline(element, selectedLanguageCode)}
</h3>
{!open && (
<p className="mt-1 truncate text-xs text-slate-500">
{element?.required
? t("environments.surveys.edit.required")
: t("environments.surveys.edit.optional")}
</p>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<EditorCardMenu
survey={localSurvey}
cardIdx={questionIdx}
lastCard={lastQuestion && elementIndex === lastElementIndex}
blockId={block.id}
elementIdx={elementIndex}
duplicateCard={duplicateQuestion}
deleteCard={deleteQuestion}
moveCard={moveQuestion}
card={{
...element,
logic: block.logic,
buttonLabel: block.buttonLabel,
backButtonLabel: block.backButtonLabel,
}}
project={project}
updateCard={updateQuestion}
addCard={addQuestion}
addCardToBlock={addElementToBlock}
cardType="question"
isCxMode={isCxMode}
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-4"}`}>
{shouldShowCautionAlert(element.type) && (
<Alert variant="warning" size="small" className="w-fill" role="alert">
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
<AlertButton onClick={() => onAlertTrigger()}>{t("common.learn_more")}</AlertButton>
</Alert>
)}
{renderElementForm(element, questionIdx, blockButtonLabel)}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
return (
<div key={element.id} className={cn(elementIndex > 0 && "border-t border-slate-200")}>
<Collapsible.Root
open={isOpen}
onOpenChange={() => {
if (activeQuestionId !== element.id) {
setActiveQuestionId(element.id);
} else {
setActiveQuestionId(null);
}
}}
className="w-full">
<Collapsible.CollapsibleTrigger
className="flex items-center text-sm text-slate-700"
aria-label="Toggle advanced settings">
{openAdvanced ? (
<ChevronDownIcon className="mr-1 h-4 w-3" />
) : (
<ChevronRightIcon className="mr-2 h-4 w-3" />
asChild
className={cn(
isOpen ? "bg-slate-50" : "",
"flex w-full cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50"
)}
{openAdvanced
? t("environments.surveys.edit.hide_advanced_settings")
: t("environments.surveys.edit.show_advanced_settings")}
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="flex flex-col gap-4" ref={parent}>
{element.type !== TSurveyElementTypeEnum.NPS &&
element.type !== TSurveyElementTypeEnum.Rating &&
element.type !== TSurveyElementTypeEnum.CTA ? (
<div className="mt-2 flex space-x-2">
{questionIdx !== 0 && (
<QuestionFormInput
id="backButtonLabel"
value={blockBackButtonLabel}
label={t("environments.surveys.edit.back_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={t("common.back")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
onBlur={(e) => {
if (!blockBackButtonLabel) return;
let translatedBackButtonLabel = {
...blockBackButtonLabel,
[selectedLanguageCode]: e.target.value,
};
updateBlockButtonLabel(
blockIdx,
"backButtonLabel",
translatedBackButtonLabel
);
updateEmptyButtonLabels(
"backButtonLabel",
translatedBackButtonLabel,
blockIdx
);
}}
isStorageConfigured={isStorageConfigured}
/>
)}
<div className="w-full">
<QuestionFormInput
id="buttonLabel"
value={blockButtonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
onBlur={(e) => {
if (!blockButtonLabel) return;
let translatedNextButtonLabel = {
...blockButtonLabel,
[selectedLanguageCode]: e.target.value,
};
updateBlockButtonLabel(blockIdx, "buttonLabel", translatedNextButtonLabel);
// Don't propagate to last block
const lastBlockIndex = localSurvey.blocks.length - 1;
if (blockIdx !== lastBlockIndex) {
updateEmptyButtonLabels(
"buttonLabel",
translatedNextButtonLabel,
lastBlockIndex
);
}
}}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
aria-label="Toggle question details">
<div>
<div className="flex grow">
<div className="flex grow items-center gap-3" dir="auto">
<div className="flex items-center text-slate-600">
{QUESTIONS_ICON_MAP[element.type]}
</div>
<div className="flex grow flex-col justify-center">
{hasMultipleElements && (
<p className="mb-1 text-xs font-medium text-slate-500">
Question {elementIndex + 1}
</p>
)}
<h3 className="text-sm font-semibold">
{getElementHeadline(element, selectedLanguageCode)}
</h3>
{!isOpen && (
<p className="mt-1 truncate text-xs text-slate-500">
{element?.required
? t("environments.surveys.edit.required")
: t("environments.surveys.edit.optional")}
</p>
)}
</div>
</div>
</div>
) : null}
{(element.type === TSurveyElementTypeEnum.Rating ||
element.type === TSurveyElementTypeEnum.NPS) &&
questionIdx !== 0 && (
<div className="mt-4">
<QuestionFormInput
id="backButtonLabel"
value={blockBackButtonLabel}
label={`"Back" Button Label`}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={"Back"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
onBlur={(e) => {
if (!blockBackButtonLabel) return;
const translatedBackButtonLabel = {
...blockBackButtonLabel,
[selectedLanguageCode]: e.target.value,
};
updateBlockButtonLabel(
blockIdx,
"backButtonLabel",
translatedBackButtonLabel
);
updateEmptyButtonLabels(
"backButtonLabel",
translatedBackButtonLabel,
blockIdx
);
}}
isStorageConfigured={isStorageConfigured}
/>
</div>
)}
<AdvancedSettings
// TODO -- We should remove this when we can confirm that everything works fine with the survey editor, not changing this right now in this file because it would require changing the question type to the respective element type in all the question forms.
question={element}
questionIdx={questionIdx}
localSurvey={localSurvey}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
selectedLanguageCode={selectedLanguageCode}
/>
<div className="flex items-center space-x-2">
<EditorCardMenu
survey={localSurvey}
cardIdx={questionIdx}
lastCard={lastQuestion && elementIndex === lastElementIndex}
blockId={block.id}
elementIdx={elementIndex}
duplicateCard={duplicateQuestion}
deleteCard={deleteQuestion}
moveCard={moveQuestion}
card={{
...element,
logic: block.logic,
buttonLabel: block.buttonLabel,
backButtonLabel: block.backButtonLabel,
}}
project={project}
updateCard={updateQuestion}
addCard={addQuestion}
addCardToBlock={addElementToBlock}
moveElementToBlock={moveElementToBlock}
cardType="question"
isCxMode={isCxMode}
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${isOpen && "pb-4"}`}>
{shouldShowCautionAlert(element.type) && (
<Alert variant="warning" size="small" className="w-fill" role="alert">
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
<AlertButton onClick={() => onAlertTrigger()}>
{t("common.learn_more")}
</AlertButton>
</Alert>
)}
{renderElementForm(element, questionIdx)}
<div className="mt-4">
<Collapsible.Root
open={openAdvanced}
onOpenChange={setOpenAdvanced}
className="mt-5">
<Collapsible.CollapsibleTrigger
className="flex items-center text-sm text-slate-700"
aria-label="Toggle advanced settings">
{openAdvanced ? (
<ChevronDownIcon className="mr-1 h-4 w-3" />
) : (
<ChevronRightIcon className="mr-2 h-4 w-3" />
)}
{openAdvanced
? t("environments.surveys.edit.hide_question_settings")
: t("environments.surveys.edit.show_question_settings")}
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="flex flex-col gap-4" ref={parent}>
{element.type !== TSurveyElementTypeEnum.NPS &&
element.type !== TSurveyElementTypeEnum.Rating &&
element.type !== TSurveyElementTypeEnum.CTA ? (
<div className="mt-2 flex space-x-2"></div>
) : null}
<AdvancedSettings
// TODO -- We should remove this when we can confirm that everything works fine with the survey editor, not changing this right now in this file because it would require changing the question type to the respective element type in all the question forms.
question={element}
questionIdx={questionIdx}
localSurvey={localSurvey}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
selectedLanguageCode={selectedLanguageCode}
/>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
</Collapsible.CollapsibleContent>
{open && (
<div className="mx-4 flex justify-end space-x-6 border-t border-slate-200">
{element.type === "openText" && (
<div className="my-4 flex items-center justify-end space-x-2">
<Label htmlFor="longAnswer">{t("environments.surveys.edit.long_answer")}</Label>
<Switch
id="longAnswer"
disabled={element.inputType !== "text"}
checked={element.longAnswer !== false}
onClick={(e) => {
e.stopPropagation();
updateQuestion(questionIdx, {
longAnswer:
typeof element.longAnswer === "undefined" ? false : !element.longAnswer,
});
}}
/>
</div>
)}
{
<div className="my-4 flex items-center justify-end space-x-2">
<Label htmlFor="required-toggle">{t("environments.surveys.edit.required")}</Label>
<Switch
id="required-toggle"
checked={element.required}
disabled={getIsRequiredToggleDisabled()}
onClick={(e) => {
e.stopPropagation();
handleRequiredToggle();
}}
/>
</div>
}
</div>
)}
</Collapsible.Root>
);
})}
</div>
);
})}
<hr className="mb-4 border-dashed border-slate-200" />
{/* Add Question to Block button */}
{/* Add Question to Block button */}
<div className="p-4 pt-0">
<AddQuestionToBlockButton
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
block={block}
project={project}
isCxMode={isCxMode}
/>
</div>
<div className="p-4">
<AddQuestionToBlockButton
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
block={block}
project={project}
isCxMode={isCxMode}
/>
</div>
<hr className="border-dashed border-slate-200" />
{/* Block Settings */}
<div className="p-4">
<BlockSettings
localSurvey={localSurvey}
block={block}
blockIndex={blockIdx}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
updateBlockButtonLabel={updateBlockButtonLabel}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
locale={locale}
isStorageConfigured={isStorageConfigured}
isLastBlock={blockIdx === totalBlocks - 1}
/>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
</div>
);

View File

@@ -6,9 +6,9 @@ import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface BlockMenuProps {
blockIndex: number;
isFirstBlock: boolean;
isLastBlock: boolean;
isOnlyBlock: boolean;
onDuplicate: () => void;
onDelete: () => void;
onMoveUp: () => void;
@@ -16,9 +16,9 @@ interface BlockMenuProps {
}
export const BlockMenu = ({
blockIndex,
isFirstBlock,
isLastBlock,
isOnlyBlock,
onDuplicate,
onDelete,
onMoveUp,
@@ -77,9 +77,12 @@ export const BlockMenu = ({
<Button
variant="ghost"
size="icon"
disabled={isOnlyBlock}
onClick={(e) => {
e.stopPropagation();
onDelete();
if (!isOnlyBlock) {
e.stopPropagation();
onDelete();
}
}}
className="h-8 w-8">
<TrashIcon className="h-4 w-4" />

View File

@@ -0,0 +1,181 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { ConditionalLogic } from "@/modules/survey/editor/components/conditional-logic";
interface BlockSettingsProps {
localSurvey: TSurvey;
block: TSurveyBlock;
blockIndex: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
updateBlockButtonLabel: (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
labelValue: TI18nString | undefined
) => void;
updateBlockLogic: (blockIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (blockIdx: number, logicFallback: string | undefined) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
isLastBlock: boolean;
}
export const BlockSettings = ({
localSurvey,
block,
blockIndex,
selectedLanguageCode,
setSelectedLanguageCode,
updateBlockButtonLabel,
updateBlockLogic,
updateBlockLogicFallback,
locale,
isStorageConfigured,
isLastBlock,
}: BlockSettingsProps) => {
const { t } = useTranslation();
// Use the first element in the block as a representative for logic
const firstElement = block.elements[0];
const blockLogic = block.logic ?? [];
// Auto-open if block has logic configured
const [open, setOpen] = useState(blockLogic.length > 0);
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);
}
});
};
return (
<Collapsible.Root open={open} onOpenChange={setOpen} className="w-full rounded-lg">
<Collapsible.CollapsibleTrigger
className="flex items-center text-sm text-slate-700"
aria-label="Toggle advanced settings">
{open ? <ChevronDownIcon className="mr-1 h-4 w-3" /> : <ChevronRightIcon className="mr-2 h-4 w-3" />}
{open
? t("environments.surveys.edit.hide_block_settings")
: t("environments.surveys.edit.show_block_settings")}
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent>
<div className="mt-2 space-y-4">
<div className="flex space-x-2">
{blockIndex !== 0 && (
<QuestionFormInput
id="backButtonLabel"
value={block.backButtonLabel}
label={t("environments.surveys.edit.back_button_label")}
localSurvey={localSurvey}
questionIdx={blockIndex}
isInvalid={false}
updateQuestion={(_, updatedAttributes) => {
if ("backButtonLabel" in updatedAttributes) {
const backButtonLabel = updatedAttributes.backButtonLabel as TI18nString;
updateBlockButtonLabel(blockIndex, "backButtonLabel", {
...block.backButtonLabel,
[selectedLanguageCode]: backButtonLabel[selectedLanguageCode],
});
}
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
placeholder={t("common.back")}
locale={locale}
isStorageConfigured={isStorageConfigured}
onBlur={(e) => {
const languageSymbols = extractLanguageCodes(localSurvey.languages ?? []);
const existingLabel = block.backButtonLabel || {};
const translatedBackButtonLabel = addMultiLanguageLabels(
{
...existingLabel,
[selectedLanguageCode]: e.target.value,
},
languageSymbols
);
updateBlockButtonLabel(blockIndex, "backButtonLabel", translatedBackButtonLabel);
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, blockIndex);
}}
/>
)}
<QuestionFormInput
id="buttonLabel"
value={block.buttonLabel}
label={t("environments.surveys.edit.button_label")}
localSurvey={localSurvey}
questionIdx={blockIndex}
isInvalid={false}
updateQuestion={(_, updatedAttributes) => {
if ("buttonLabel" in updatedAttributes) {
const languageSymbols = extractLanguageCodes(localSurvey.languages ?? []);
const buttonLabel = updatedAttributes.buttonLabel as TI18nString;
const existingLabel = block.buttonLabel || {};
const updatedButtonLabel = addMultiLanguageLabels(
{
...existingLabel,
[selectedLanguageCode]: buttonLabel[selectedLanguageCode],
},
languageSymbols
);
updateBlockButtonLabel(blockIndex, "buttonLabel", updatedButtonLabel);
}
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
placeholder={t("common.next")}
locale={locale}
isStorageConfigured={isStorageConfigured}
onBlur={(e) => {
const languageSymbols = extractLanguageCodes(localSurvey.languages ?? []);
const existingLabel = block.buttonLabel || {};
const translatedNextButtonLabel = addMultiLanguageLabels(
{
...existingLabel,
[selectedLanguageCode]: e.target.value,
},
languageSymbols
);
updateBlockButtonLabel(blockIndex, "buttonLabel", translatedNextButtonLabel);
// Don't propagate to last block
const lastBlockIndex = localSurvey.blocks.length - 1;
if (blockIndex !== lastBlockIndex && !isLastBlock) {
updateEmptyButtonLabels("buttonLabel", translatedNextButtonLabel, lastBlockIndex);
}
}}
/>
</div>
{/* Conditional Logic */}
{firstElement && (
<ConditionalLogic
localSurvey={localSurvey}
block={block}
blockIdx={blockIndex}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
/>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
};

View File

@@ -40,6 +40,7 @@ interface BlocksDroppableProps {
deleteBlock: (blockId: string) => void;
moveBlock: (blockId: string, direction: "up" | "down") => void;
addElementToBlock: (element: TSurveyElement, blockId: string, afterElementIdx: number) => void;
moveElementToBlock?: (elementId: string, targetBlockId: string) => void;
}
export const BlocksDroppable = ({
@@ -70,6 +71,7 @@ export const BlocksDroppable = ({
deleteBlock,
moveBlock,
addElementToBlock,
moveElementToBlock,
}: BlocksDroppableProps) => {
const [parent] = useAutoAnimate();
@@ -115,6 +117,7 @@ export const BlocksDroppable = ({
deleteBlock={deleteBlock}
moveBlock={moveBlock}
addElementToBlock={addElementToBlock}
moveElementToBlock={moveElementToBlock}
totalBlocks={localSurvey.blocks.length}
/>
);

View File

@@ -13,14 +13,13 @@ import {
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurvey } from "@formbricks/types/surveys/types";
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
import {
getDefaultOperatorForQuestion,
getDefaultOperatorForElement,
replaceEndingCardHeadlineRecall,
} from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -34,18 +33,16 @@ import { Label } from "@/modules/ui/components/label";
interface ConditionalLogicProps {
localSurvey: TSurvey;
questionIdx: number;
question: TSurveyElement;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
blockIdx: number;
block: TSurveyBlock;
updateBlockLogic: (blockIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (blockIdx: number, logicFallback: string | undefined) => void;
}
export function ConditionalLogic({
localSurvey,
question,
questionIdx,
updateQuestion,
blockIdx,
block,
updateBlockLogic,
updateBlockLogicFallback,
}: ConditionalLogicProps) {
@@ -57,17 +54,16 @@ export function ConditionalLogic({
return modifiedSurvey;
}, [localSurvey]);
// Find the parent block for this question/element to get its logic
const parentBlock = useMemo(
() => localSurvey.blocks.find((block) => block.elements.some((element) => element.id === question.id)),
[localSurvey.blocks, question.id]
);
const blockLogic = useMemo(() => block.logic ?? [], [block.logic]);
const blockLogicFallback = block.logicFallback;
const blockLogic = useMemo(() => parentBlock?.logic ?? [], [parentBlock?.logic]);
const blockLogicFallback = parentBlock?.logicFallback;
// Use the first element in the block as the reference element for default operators
const firstElement = block.elements[0];
const addLogic = () => {
const operator = getDefaultOperatorForQuestion(question, t);
if (!firstElement) return;
const operator = getDefaultOperatorForElement(firstElement, t);
const initialCondition: TSurveyBlockLogic = {
id: createId(),
@@ -78,7 +74,7 @@ export function ConditionalLogic({
{
id: createId(),
leftOperand: {
value: question.id,
value: firstElement.id,
type: "question",
},
operator,
@@ -94,7 +90,7 @@ export function ConditionalLogic({
],
};
updateBlockLogic(questionIdx, [...blockLogic, initialCondition]);
updateBlockLogic(blockIdx, [...blockLogic, initialCondition]);
};
const handleRemoveLogic = (logicItemIdx: number) => {
@@ -102,9 +98,9 @@ export function ConditionalLogic({
const isLast = logicCopy.length === 1;
logicCopy.splice(logicItemIdx, 1);
updateBlockLogic(questionIdx, logicCopy);
updateBlockLogic(blockIdx, logicCopy);
if (isLast) {
updateBlockLogicFallback(questionIdx, undefined);
updateBlockLogicFallback(blockIdx, undefined);
}
};
@@ -113,7 +109,7 @@ export function ConditionalLogic({
const [movedItem] = logicCopy.splice(from, 1);
logicCopy.splice(to, 0, movedItem);
updateBlockLogic(questionIdx, logicCopy);
updateBlockLogic(blockIdx, logicCopy);
};
const duplicateLogic = (logicItemIdx: number) => {
@@ -122,15 +118,16 @@ export function ConditionalLogic({
const newLogicItem = duplicateLogicItem(logicItem);
logicCopy.splice(logicItemIdx + 1, 0, newLogicItem);
updateBlockLogic(questionIdx, logicCopy);
updateBlockLogic(blockIdx, logicCopy);
};
const [parent] = useAutoAnimate();
useEffect(() => {
if (blockLogic.length === 0 && blockLogicFallback) {
updateBlockLogicFallback(questionIdx, undefined);
updateBlockLogicFallback(blockIdx, undefined);
}
}, [blockLogic, questionIdx, blockLogicFallback, updateBlockLogicFallback]);
}, [blockLogic, blockIdx, blockLogicFallback, updateBlockLogicFallback]);
return (
<div className="mt-4" ref={parent}>
@@ -148,11 +145,10 @@ export function ConditionalLogic({
<LogicEditor
localSurvey={transformedSurvey}
logicItem={logicItem}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
question={question}
questionIdx={questionIdx}
block={block}
blockIdx={blockIdx}
logicIdx={logicItemIdx}
isLast={logicItemIdx === blockLogic.length - 1}
/>

View File

@@ -6,6 +6,7 @@ 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 { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -74,34 +75,43 @@ export const CTAQuestionForm = ({
</div>
<div className="mt-3 flex-1">
<div className="mt-2 flex flex-col gap-2">
<QuestionFormInput
id="ctaButtonLabel"
value={question.ctaButtonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
<div>
<Label htmlFor="buttonLabel">{t("environments.surveys.edit.button_url")}</Label>
<Input
id="buttonUrl"
name="buttonUrl"
value={question.buttonUrl}
placeholder="https://website.com"
onChange={(e) => updateQuestion(questionIdx, { buttonUrl: e.target.value })}
<AdvancedOptionToggle
isChecked={question.buttonExternal}
onToggle={() => updateQuestion(questionIdx, { buttonExternal: !question.buttonExternal })}
htmlId="buttonExternal"
title={t("environments.surveys.edit.button_external")}
description={t("environments.surveys.edit.button_external_description")}
childBorder
customContainerClass="p-0 mt-4">
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-1">
<QuestionFormInput
id="ctaButtonLabel"
value={question.ctaButtonLabel}
label={t("environments.surveys.edit.cta_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
<div>
<Label htmlFor="buttonLabel">{t("environments.surveys.edit.button_url")}</Label>
<Input
id="buttonUrl"
name="buttonUrl"
value={question.buttonUrl}
placeholder="https://website.com"
onChange={(e) => updateQuestion(questionIdx, { buttonUrl: e.target.value })}
/>
</div>
</div>
</div>
</AdvancedOptionToggle>
</div>
</form>
);

View File

@@ -2,7 +2,7 @@
import { createId } from "@paralleldrive/cuid2";
import { Project } from "@prisma/client";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
@@ -48,6 +48,7 @@ interface EditorCardMenuProps {
updateCard: (cardIdx: number, updatedAttributes: any) => void;
addCard: (question: any, index?: number) => void;
addCardToBlock?: (element: TSurveyElement, blockId: string, afterElementIdx: number) => void;
moveElementToBlock?: (elementId: string, targetBlockId: string) => void;
cardType: "question" | "ending";
project?: Project;
isCxMode?: boolean;
@@ -67,6 +68,7 @@ export const EditorCardMenu = ({
updateCard,
addCard,
addCardToBlock,
moveElementToBlock,
cardType,
isCxMode = false,
}: EditorCardMenuProps) => {
@@ -192,7 +194,9 @@ export const EditorCardMenu = ({
<ArrowDownIcon />
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.duplicate")} triggerClass="disabled:border-none">
<TooltipRenderer
tooltipContent={t("environments.surveys.edit.duplicate_question")}
triggerClass="disabled:border-none">
<Button
variant="ghost"
size="icon"
@@ -293,6 +297,34 @@ export const EditorCardMenu = ({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{cardType === "question" && moveElementToBlock && survey.blocks.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer" onClick={(e) => e.preventDefault()}>
{t("environments.surveys.edit.move_question_to_block")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="ml-2">
{survey.blocks.map((block) => {
// Don't show current block in the list
if (block.id === blockId) return null;
const blockName = block.name;
return (
<DropdownMenuItem
key={block.id}
className="min-h-8"
onClick={(e) => {
e.stopPropagation();
moveElementToBlock(card.id, block.id);
}}
icon={<ArrowRightIcon className="h-4 w-4" />}>
<span className="ml-2">{blockName}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
onClick={(e) => {
if (cardIdx !== 0) {

View File

@@ -189,7 +189,7 @@ export const FileUploadQuestionForm = ({
</Button>
)}
</div>
<div className="mb-8 mt-6 space-y-6">
<div className="mt-6 space-y-6">
<AdvancedOptionToggle
isChecked={question.allowMultipleFiles}
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}

View File

@@ -4,11 +4,11 @@ import { createId } from "@paralleldrive/cuid2";
import { CopyIcon, CornerDownRightIcon, EllipsisVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
TSurveyBlock,
TSurveyBlockLogic,
TSurveyBlockLogicAction,
TSurveyBlockLogicActionObjective,
} from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import {
TActionNumberVariableCalculateOperator,
TActionTextVariableCalculateOperator,
@@ -33,32 +33,29 @@ import {
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
import { cn } from "@/modules/ui/lib/utils";
interface LogicEditorActions {
interface LogicEditorActionsProps {
localSurvey: TSurvey;
logicItem: TSurveyBlockLogic;
logicIdx: number;
question: TSurveyElement;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
questionIdx: number;
block: TSurveyBlock;
updateBlockLogic: (blockIdx: number, logic: TSurveyBlockLogic[]) => void;
blockIdx: number;
isLast?: boolean;
}
export function LogicEditorActions({
localSurvey,
logicItem,
logicIdx,
question,
block,
updateBlockLogic,
questionIdx,
}: LogicEditorActions) {
blockIdx,
isLast,
}: LogicEditorActionsProps) {
const actions = logicItem.actions;
const { t } = useTranslation();
// Find the parent block for this question/element to get its logic
const parentBlock = localSurvey.blocks?.find((block) =>
block.elements.some((element) => element.id === question.id)
);
const blockLogic = parentBlock?.logic ?? [];
const blockLogic = block.logic ?? [];
const handleActionsChange = (
operation: "remove" | "addBelow" | "duplicate" | "update",
@@ -89,7 +86,7 @@ export function LogicEditorActions({
break;
}
updateBlockLogic(questionIdx, logicCopy);
updateBlockLogic(blockIdx, logicCopy);
};
const handleObjectiveChange = (actionIdx: number, objective: TSurveyBlockLogicActionObjective) => {
@@ -116,7 +113,7 @@ export function LogicEditorActions({
{t("environments.surveys.edit.then")}
</div>
<div className="flex grow flex-col gap-y-2 border-b border-slate-200 last:pb-3">
<div className={cn("flex grow flex-col gap-y-2 last:pb-3", isLast && "border-b border-slate-200")}>
{actions?.map((action, idx) => (
<div className="flex items-center gap-x-2" key={action.id}>
<div className="flex w-10 shrink-0 items-center justify-end">
@@ -152,7 +149,7 @@ export function LogicEditorActions({
id={`action-${idx}-target`}
key={`target-${action.id}`}
showSearch={false}
options={getActionTargetOptions(action, localSurvey, questionIdx, t)}
options={getActionTargetOptions(action, localSurvey, blockIdx, t)}
value={action.target}
onChangeValue={(val: string) => {
handleValuesChange(idx, {
@@ -219,7 +216,7 @@ export function LogicEditorActions({
placeholder: "Value",
type: localSurvey.variables.find((v) => v.id === action.variableId)?.type || "text",
}}
groupedOptions={getActionValueOptions(action.variableId, localSurvey, questionIdx, t)}
groupedOptions={getActionValueOptions(action.variableId, localSurvey, blockIdx, t)}
onChangeValue={(val, option, fromInput) => {
const fieldType = option?.meta?.type as TActionVariableValueType;

View File

@@ -1,21 +1,19 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TConditionGroup } from "@formbricks/types/surveys/logic";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { getDefaultOperatorForElement } from "@/modules/survey/editor/lib/utils";
import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
interface LogicEditorConditionsProps {
conditions: TConditionGroup;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyQuestion>) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
question: TSurveyElement;
updateBlockLogic: (blockIdx: number, logic: TSurveyBlockLogic[]) => void;
block: TSurveyBlock;
localSurvey: TSurvey;
questionIdx: number;
blockIdx: number;
logicIdx: number;
depth?: number;
}
@@ -23,26 +21,23 @@ interface LogicEditorConditionsProps {
export function LogicEditorConditions({
conditions,
logicIdx,
question,
block,
localSurvey,
questionIdx,
blockIdx,
updateBlockLogic,
depth = 0,
}: LogicEditorConditionsProps) {
const { t } = useTranslation();
// Find the parent block for this question/element to get its logic
const parentBlock = localSurvey.blocks?.find((block) =>
block.elements.some((element) => element.id === question.id)
);
const blockLogic = parentBlock?.logic ?? [];
const blockLogic = block.logic ?? [];
const firstElement = block.elements[0];
const { config, callbacks } = createSharedConditionsFactory(
{
survey: localSurvey,
t,
questionIdx,
getDefaultOperator: () => getDefaultOperatorForQuestion(question, t),
blockIdx,
getDefaultOperator: () => (firstElement ? getDefaultOperatorForElement(firstElement, t) : "equals"),
includeCreateGroup: true,
},
{
@@ -56,7 +51,7 @@ export function LogicEditorConditions({
logicCopy.splice(logicIdx, 1);
}
updateBlockLogic(questionIdx, logicCopy);
updateBlockLogic(blockIdx, logicCopy);
},
}
);

View File

@@ -1,17 +1,14 @@
"use client";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { recallToHeadline } from "@/lib/utils/recall";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import {
Select,
SelectContent,
@@ -23,11 +20,10 @@ import {
interface LogicEditorProps {
localSurvey: TSurvey;
logicItem: TSurveyBlockLogic;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
question: TSurveyElement;
questionIdx: number;
updateBlockLogic: (blockIdx: number, logic: TSurveyBlockLogic[]) => void;
updateBlockLogicFallback: (blockIdx: number, logicFallback: string | undefined) => void;
block: TSurveyBlock;
blockIdx: number;
logicIdx: number;
isLast: boolean;
}
@@ -35,58 +31,32 @@ interface LogicEditorProps {
export function LogicEditor({
localSurvey,
logicItem,
updateQuestion,
updateBlockLogic,
updateBlockLogicFallback,
question,
questionIdx,
block,
blockIdx,
logicIdx,
isLast,
}: LogicEditorProps) {
const { t } = useTranslation();
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Find the parent block for this question/element to get its logicFallback
const parentBlock = localSurvey.blocks?.find((block) =>
block.elements.some((element) => element.id === question.id)
);
const blockLogicFallback = parentBlock?.logicFallback;
const blockLogicFallback = block.logicFallback;
const fallbackOptions = useMemo(() => {
let options: {
icon?: ReactElement;
label: string;
value: string;
}[] = [];
// Derive questions from blocks
const allQuestions = getElementsFromBlocks(localSurvey.blocks);
const blocks = localSurvey.blocks;
// Track which blocks we've already added to avoid duplicates when a block has multiple elements
const addedBlockIds = new Set<string>();
// Add blocks AFTER the current block
for (let i = blockIdx + 1; i < blocks.length; i++) {
const currentBlock = blocks[i];
// Iterate over the questions AFTER the current question
for (let i = questionIdx + 1; i < allQuestions.length; i++) {
const ques = allQuestions[i];
const block = blocks.find((b) => b.elements.some((e) => e.id === ques.id));
if (!block) continue;
// Skip if we've already added this block
if (addedBlockIds.has(block.id)) continue;
addedBlockIds.add(block.id);
// Use the first element's headline as the block label
const firstElement = block.elements[0];
options.push({
icon: QUESTIONS_ICON_MAP[firstElement.type],
label: getTextContent(
recallToHeadline(firstElement.headline, localSurvey, false, "default").default ?? ""
),
value: block.id,
label: currentBlock.name,
value: currentBlock.id,
});
}
@@ -104,27 +74,26 @@ export function LogicEditor({
});
return options;
}, [localSurvey, questionIdx, QUESTIONS_ICON_MAP, t]);
}, [localSurvey, blockIdx, t]);
return (
<div className="flex w-full min-w-full grow flex-col gap-4 overflow-x-auto pb-2 text-sm">
<LogicEditorConditions
conditions={logicItem.conditions}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
question={question}
questionIdx={questionIdx}
block={block}
blockIdx={blockIdx}
localSurvey={localSurvey}
logicIdx={logicIdx}
/>
<LogicEditorActions
logicItem={logicItem}
logicIdx={logicIdx}
question={question}
updateQuestion={updateQuestion}
block={block}
updateBlockLogic={updateBlockLogic}
localSurvey={localSurvey}
questionIdx={questionIdx}
blockIdx={blockIdx}
isLast={isLast}
/>
{isLast ? (
@@ -139,22 +108,19 @@ export function LogicEditor({
autoComplete="true"
defaultValue={blockLogicFallback || "defaultSelection"}
onValueChange={(val) => {
updateBlockLogicFallback(questionIdx, val === "defaultSelection" ? undefined : val);
updateBlockLogicFallback(blockIdx, val === "defaultSelection" ? undefined : val);
}}>
<SelectTrigger className="w-auto bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="fallback_default_selection" value={"defaultSelection"}>
{t("environments.surveys.edit.next_question")}
{t("environments.surveys.edit.next_block")}
</SelectItem>
{fallbackOptions.map((option) => (
<SelectItem key={`fallback_${option.value}`} value={option.value}>
<div className="flex items-center gap-2">
{option.icon}
{option.label}
</div>
{option.label}
</SelectItem>
))}
</SelectContent>

View File

@@ -4,7 +4,6 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import { useTranslation } from "react-i18next";
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";
@@ -18,21 +17,18 @@ interface NPSQuestionFormProps {
question: TSurveyNPSElement;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyNPSElement>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
buttonLabel?: TI18nString;
}
export const NPSQuestionForm = ({
question,
questionIdx,
updateQuestion,
lastQuestion,
isInvalid,
localSurvey,
selectedLanguageCode,
@@ -40,7 +36,6 @@ export const NPSQuestionForm = ({
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
buttonLabel,
}: NPSQuestionFormProps): JSX.Element => {
const { t } = useTranslation();
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -136,26 +131,6 @@ export const NPSQuestionForm = ({
</div>
</div>
{!question.required && (
<div className="mt-3">
<QuestionFormInput
id="buttonLabel"
value={buttonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
</div>
)}
<AdvancedOptionToggle
isChecked={question.isColorCodingEnabled}
onToggle={() => updateQuestion(questionIdx, { isColorCodingEnabled: !question.isColorCodingEnabled })}

View File

@@ -167,7 +167,7 @@ export const OpenQuestionForm = ({
/>
</div>
</div>
<div className="mt-3">
<div className="mt-6 space-y-6">
{showCharLimits && (
<AdvancedOptionToggle
isChecked={isCharLimitEnabled}
@@ -181,7 +181,7 @@ export const OpenQuestionForm = ({
},
});
}}
htmlId="charLimit"
htmlId={`charLimit-${question.id}`}
description={t("environments.surveys.edit.character_limit_toggle_description")}
childBorder
title={t("environments.surveys.edit.character_limit_toggle_title")}
@@ -230,6 +230,21 @@ export const OpenQuestionForm = ({
</div>
</AdvancedOptionToggle>
)}
<div className="mt-4">
<AdvancedOptionToggle
isChecked={question.longAnswer !== false}
onToggle={(checked: boolean) => {
updateQuestion(questionIdx, {
longAnswer: checked,
});
}}
htmlId={`longAnswer-${question.id}`}
title={t("environments.surveys.edit.long_answer")}
description={t("environments.surveys.edit.long_answer_toggle_description")}
disabled={question.inputType !== "text"}
customContainerClass="p-0"
/>
</div>
</div>
</form>
);

View File

@@ -351,17 +351,13 @@ export const QuestionsView = ({
};
// Update block logic (block-level property)
const updateBlockLogic = (questionIdx: number, logic: TSurveyBlockLogic[]) => {
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(localSurvey, question.id);
if (blockIndex === -1) return;
const updateBlockLogic = (blockIdx: number, logic: TSurveyBlockLogic[]) => {
if (blockIdx < 0 || blockIdx >= localSurvey.blocks.length) return;
setLocalSurvey((prevSurvey) => {
const blocks = [...(prevSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
blocks[blockIdx] = {
...blocks[blockIdx],
logic,
};
return { ...prevSurvey, blocks };
@@ -369,17 +365,13 @@ export const QuestionsView = ({
};
// Update block logic fallback (block-level property)
const updateBlockLogicFallback = (questionIdx: number, logicFallback: string | undefined) => {
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(localSurvey, question.id);
if (blockIndex === -1) return;
const updateBlockLogicFallback = (blockIdx: number, logicFallback: string | undefined) => {
if (blockIdx < 0 || blockIdx >= localSurvey.blocks.length) return;
setLocalSurvey((prevSurvey) => {
const blocks = [...(prevSurvey.blocks ?? [])];
blocks[blockIndex] = {
...blocks[blockIndex],
blocks[blockIdx] = {
...blocks[blockIdx],
logicFallback,
};
return { ...prevSurvey, blocks };
@@ -580,6 +572,53 @@ export const QuestionsView = ({
internalQuestionIdMap[updatedQuestion.id] = createId();
};
const moveElementToBlock = (elementId: string, targetBlockId: string) => {
const updatedSurvey = structuredClone(localSurvey);
// Find the source block and element
let sourceBlock: TSurveyBlock | undefined;
let elementToMove: TSurveyElement | undefined;
let elementIndexInBlock = -1;
for (const block of updatedSurvey.blocks) {
const idx = block.elements.findIndex((el) => el.id === elementId);
if (idx !== -1) {
sourceBlock = block;
elementToMove = block.elements[idx];
elementIndexInBlock = idx;
break;
}
}
if (!sourceBlock || !elementToMove) {
toast.error(t("environments.surveys.edit.element_not_found"));
return;
}
// Remove element from source block
sourceBlock.elements.splice(elementIndexInBlock, 1);
// If source block is now empty, delete it
if (sourceBlock.elements.length === 0) {
const blockIdx = updatedSurvey.blocks.findIndex((b) => b.id === sourceBlock.id);
if (blockIdx !== -1) {
updatedSurvey.blocks.splice(blockIdx, 1);
}
}
// Add element to target block at the end
const targetBlock = updatedSurvey.blocks.find((b) => b.id === targetBlockId);
if (!targetBlock) {
toast.error(t("environments.surveys.edit.target_block_not_found"));
return;
}
targetBlock.elements.push(elementToMove);
setLocalSurvey(updatedSurvey);
setActiveQuestionId(elementId);
};
const addEndingCard = (index: number) => {
const updatedSurvey = structuredClone(localSurvey);
const newEndingCard = getDefaultEndingCard(localSurvey.languages, t);
@@ -721,27 +760,25 @@ export const QuestionsView = ({
})
);
const onQuestionCardDragEnd = (event: DragEndEvent) => {
const onBlockCardDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
// Find source and destination block indices
const sourceQuestion = questions.find((q) => q.id === active.id);
const destQuestion = questions.find((q) => q.id === over?.id);
if (!over) return;
if (!sourceQuestion || !destQuestion) return;
// Check if we're dragging a block (not a question/element)
const sourceBlockIndex = localSurvey.blocks.findIndex((b) => b.id === active.id);
const destBlockIndex = localSurvey.blocks.findIndex((b) => b.id === over.id);
const { blockIndex: sourceBlockIndex } = findElementLocation(localSurvey, sourceQuestion.id);
const { blockIndex: destBlockIndex } = findElementLocation(localSurvey, destQuestion.id);
if (sourceBlockIndex !== -1 && destBlockIndex !== -1) {
// We're dragging blocks
if (sourceBlockIndex === destBlockIndex) return; // No move needed
if (sourceBlockIndex === -1 || destBlockIndex === -1) return;
if (sourceBlockIndex === destBlockIndex) return; // No move needed
const blocks = [...localSurvey.blocks];
const [movedBlock] = blocks.splice(sourceBlockIndex, 1);
blocks.splice(destBlockIndex, 0, movedBlock);
// Reorder blocks
const blocks = [...(localSurvey.blocks ?? [])];
const [movedBlock] = blocks.splice(sourceBlockIndex, 1);
blocks.splice(destBlockIndex, 0, movedBlock);
setLocalSurvey({ ...localSurvey, blocks });
setLocalSurvey({ ...localSurvey, blocks });
}
};
const onEndingCardDragEnd = (event: DragEndEvent) => {
@@ -777,9 +814,9 @@ export const QuestionsView = ({
)}
<DndContext
id="questions"
id="blocks"
sensors={sensors}
onDragEnd={onQuestionCardDragEnd}
onDragEnd={onBlockCardDragEnd}
collisionDetection={closestCorners}>
<BlocksDroppable
localSurvey={localSurvey}
@@ -809,6 +846,7 @@ export const QuestionsView = ({
deleteBlock={deleteBlockById}
moveBlock={moveBlockById}
addElementToBlock={_addElementToBlock}
moveElementToBlock={moveElementToBlock}
/>
</DndContext>

View File

@@ -3,7 +3,6 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
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";
@@ -26,7 +25,6 @@ interface RatingQuestionFormProps {
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
buttonLabel?: TI18nString;
}
export const RatingQuestionForm = ({
@@ -40,7 +38,6 @@ export const RatingQuestionForm = ({
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
buttonLabel,
}: RatingQuestionFormProps) => {
const { t } = useTranslation();
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -181,27 +178,6 @@ export const RatingQuestionForm = ({
</div>
</div>
<div className="mt-3">
{!question.required && (
<div className="flex-1">
<QuestionFormInput
id="buttonLabel"
value={buttonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
placeholder={"skip"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
</div>
)}
</div>
{question.scale !== "star" && (
<AdvancedOptionToggle
isChecked={question.isColorCodingEnabled}

View File

@@ -280,8 +280,21 @@ describe("deleteBlock", () => {
}
});
test("should return error when block not found", () => {
test("should return error when trying to delete the last block", () => {
const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]);
const result = deleteBlock(survey, "block-1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe("Cannot delete the last block in the survey");
}
});
test("should return error when block not found", () => {
const survey = createMockSurvey([
createMockBlock("block-1", "Block 1"),
createMockBlock("block-2", "Block 2"),
]);
const result = deleteBlock(survey, "nonexistent");
expect(result.ok).toBe(false);

View File

@@ -130,6 +130,11 @@ export const updateBlock = (
* @returns Result with updated survey or Error
*/
export const deleteBlock = (survey: TSurvey, blockId: string): Result<TSurvey, Error> => {
// Prevent deleting the last block
if (survey.blocks?.length === 1) {
return err(new Error("Cannot delete the last block in the survey"));
}
const filteredBlocks = survey.blocks?.filter((b) => b.id !== blockId) || [];
if (filteredBlocks.length === survey.blocks?.length) {

View File

@@ -1,4 +1,5 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuotaLogic } from "@formbricks/types/quota";
import {
@@ -87,7 +88,12 @@ vi.mock("@/modules/survey/editor/lib/utils", () => ({
{ value: "notEquals", label: "not equals" },
{ value: "isEmpty", label: "is empty" },
]),
getDefaultOperatorForQuestion: vi.fn().mockReturnValue("equals"),
getDefaultOperatorForElement: vi.fn().mockReturnValue("equals"),
getElementOperatorOptions: vi.fn().mockReturnValue([
{ value: "equals", label: "equals" },
{ value: "notEquals", label: "not equals" },
{ value: "isEmpty", label: "is empty" },
]),
}));
vi.mock("@paralleldrive/cuid2", () => ({
@@ -168,7 +174,7 @@ describe("shared-conditions-factory", () => {
const defaultParams: SharedConditionsFactoryParams = {
survey: mockSurvey,
t: mockT,
t: mockT as unknown as TFunction,
getDefaultOperator: mockGetDefaultOperator,
};
@@ -243,15 +249,15 @@ describe("shared-conditions-factory", () => {
result.config.getLeftOperandOptions();
const { getConditionValueOptions } = await import("@/modules/survey/editor/lib/utils");
expect(getConditionValueOptions).toHaveBeenCalledWith(mockSurvey, mockT);
expect(getConditionValueOptions).toHaveBeenCalledWith(mockSurvey, mockT, undefined);
});
test("should call getConditionValueOptions with questionIdx", async () => {
const paramsWithQuestionIdx = {
const paramsWithBlockIdx = {
...defaultParams,
questionIdx: 0,
blockIdx: 0,
};
const result = createSharedConditionsFactory(paramsWithQuestionIdx, defaultCallbacks);
const result = createSharedConditionsFactory(paramsWithBlockIdx, defaultCallbacks);
result.config.getLeftOperandOptions();
@@ -270,15 +276,15 @@ describe("shared-conditions-factory", () => {
result.config.getValueProps(mockCondition);
const { getMatchValueProps } = await import("@/modules/survey/editor/lib/utils");
expect(getMatchValueProps).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT);
expect(getMatchValueProps).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT, undefined);
});
test("should call getMatchValueProps with questionIdx", async () => {
const paramsWithQuestionIdx = {
const paramsWithBlockIdx = {
...defaultParams,
questionIdx: 0,
blockIdx: 0,
};
const result = createSharedConditionsFactory(paramsWithQuestionIdx, defaultCallbacks);
const result = createSharedConditionsFactory(paramsWithBlockIdx, defaultCallbacks);
const mockCondition: TSingleCondition = {
id: "condition1",
leftOperand: { value: "question1", type: "question" },
@@ -300,6 +306,30 @@ describe("shared-conditions-factory", () => {
expect(mockGetDefaultOperator).toHaveBeenCalled();
});
test("should get operator options for condition", async () => {
const { getConditionOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
const mockGetConditionOperatorOptions = vi.mocked(getConditionOperatorOptions);
mockGetConditionOperatorOptions.mockReturnValue([
{ value: "equals", label: "equals" },
{ value: "doesNotEqual", label: "does not equal" },
]);
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const mockCondition: TSingleCondition = {
id: "condition1",
leftOperand: { value: "question1", type: "question" },
operator: "equals",
};
const operators = result.config.getOperatorOptions(mockCondition);
expect(mockGetConditionOperatorOptions).toHaveBeenCalledWith(mockCondition, mockSurvey, mockT);
expect(operators).toEqual([
{ value: "equals", label: "equals" },
{ value: "doesNotEqual", label: "does not equal" },
]);
});
test("should format left operand value", async () => {
const { getFormatLeftOperandValue } = await import("@/modules/survey/editor/lib/utils");
const mockGetFormatLeftOperandValue = vi.mocked(getFormatLeftOperandValue);
@@ -360,6 +390,139 @@ describe("shared-conditions-factory", () => {
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
});
test("onUpdateCondition should correct invalid operator for element type", async () => {
const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
// Mock to return limited operators (e.g., only isEmpty and isNotEmpty)
mockGetElementOperatorOptions.mockReturnValue([
{ value: "isEmpty", label: "is empty" },
{ value: "isNotEmpty", label: "is not empty" },
]);
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const resourceId = "condition1";
const updates = {
leftOperand: {
value: "question1",
type: "question" as const,
},
operator: "equals" as TSurveyLogicConditionsOperator, // Invalid operator for this element
};
result.callbacks.onUpdateCondition(resourceId, updates);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
// Get the updater function that was called
const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
const mockConditions: TConditionGroup = {
id: "root",
connector: "and",
conditions: [
{
id: "condition1",
leftOperand: { value: "oldQuestion", type: "question" },
operator: "equals",
},
],
};
updater(structuredClone(mockConditions));
// Verify the operator was validated
expect(mockGetElementOperatorOptions).toHaveBeenCalled();
});
test("onUpdateCondition should handle update with valid operator", async () => {
const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
// Mock to return operators that include the one being set
mockGetElementOperatorOptions.mockReturnValue([
{ value: "equals", label: "equals" },
{ value: "doesNotEqual", label: "does not equal" },
]);
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const resourceId = "condition1";
const updates = {
leftOperand: {
value: "question1",
type: "question" as const,
},
operator: "equals" as TSurveyLogicConditionsOperator, // Valid operator
};
result.callbacks.onUpdateCondition(resourceId, updates);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
});
test("onUpdateCondition should handle update without leftOperand", () => {
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const resourceId = "condition1";
const updates = {
operator: "equals" as TSurveyLogicConditionsOperator,
};
result.callbacks.onUpdateCondition(resourceId, updates);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
});
test("onUpdateCondition should handle update without operator", () => {
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const resourceId = "condition1";
const updates = {
leftOperand: {
value: "question1",
type: "question" as const,
},
};
result.callbacks.onUpdateCondition(resourceId, updates);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
});
test("onUpdateCondition should handle non-question leftOperand type", () => {
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const resourceId = "condition1";
const updates = {
leftOperand: {
value: "variable1",
type: "variable" as const,
},
operator: "equals" as TSurveyLogicConditionsOperator,
};
result.callbacks.onUpdateCondition(resourceId, updates);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
});
test("onUpdateCondition should handle element not found", async () => {
const { getElementOperatorOptions } = await import("@/modules/survey/editor/lib/utils");
const mockGetElementOperatorOptions = vi.mocked(getElementOperatorOptions);
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const resourceId = "condition1";
const updates = {
leftOperand: {
value: "non-existent-question",
type: "question" as const,
},
operator: "equals" as TSurveyLogicConditionsOperator,
};
result.callbacks.onUpdateCondition(resourceId, updates);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
// Should not call getElementOperatorOptions if element not found
expect(mockGetElementOperatorOptions).not.toHaveBeenCalled();
});
test("onToggleGroupConnector should toggle group connector", () => {
const result = createSharedConditionsFactory(defaultParams, defaultCallbacks);
const groupId = "group1";
@@ -367,6 +530,21 @@ describe("shared-conditions-factory", () => {
result.callbacks.onToggleGroupConnector(groupId);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
// Execute the updater function to ensure it runs properly
const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
const mockConditions: TConditionGroup = {
id: "root",
connector: "and",
conditions: [
{
id: "group1",
connector: "and",
conditions: [],
},
],
};
updater(mockConditions);
});
test("onCreateGroup should create group when includeCreateGroup is true", () => {
@@ -380,6 +558,21 @@ describe("shared-conditions-factory", () => {
result.callbacks.onCreateGroup!(resourceId);
expect(mockConditionsChange).toHaveBeenCalledWith(expect.any(Function));
// Execute the updater function to ensure it runs properly
const updater = mockConditionsChange.mock.calls[0][0] as (c: TConditionGroup) => TConditionGroup;
const mockConditions: TConditionGroup = {
id: "root",
connector: "and",
conditions: [
{
id: "condition1",
leftOperand: { value: "question1", type: "question" },
operator: "equals",
},
],
};
updater(mockConditions);
});
});

View File

@@ -19,10 +19,10 @@ import {
import {
getConditionOperatorOptions,
getConditionValueOptions,
getDefaultOperatorForQuestion,
getDefaultOperatorForElement,
getElementOperatorOptions,
getFormatLeftOperandValue,
getMatchValueProps,
getQuestionOperatorOptions,
} from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import {
@@ -35,7 +35,7 @@ import {
export interface SharedConditionsFactoryParams {
survey: TSurvey;
t: TFunction;
questionIdx?: number;
blockIdx?: number;
getDefaultOperator: () => TSurveyLogicConditionsOperator;
includeCreateGroup?: boolean;
}
@@ -53,26 +53,26 @@ export function createSharedConditionsFactory(
config: TConditionsEditorConfig<TSingleCondition>;
callbacks: TConditionsEditorCallbacks<TSingleCondition>;
} {
const { survey, t, questionIdx, getDefaultOperator, includeCreateGroup = false } = params;
const { survey, t, blockIdx, getDefaultOperator, includeCreateGroup = false } = params;
const { onConditionsChange } = updateCallbacks;
// Derive questions from blocks
const questions = getElementsFromBlocks(survey.blocks);
// Derive elements from blocks
const elements = getElementsFromBlocks(survey.blocks);
// Handles special update logic for matrix questions, setting appropriate operators and metadata
const handleMatrixQuestionUpdate = (resourceId: string, updates: Partial<TSingleCondition>): boolean => {
// Handles special update logic for matrix elements, setting appropriate operators and metadata
const handleMatrixElementUpdate = (resourceId: string, updates: Partial<TSingleCondition>): boolean => {
if (updates.leftOperand && updates.leftOperand.type === "question") {
const [questionId, rowId] = updates.leftOperand.value.split(".");
const questionEntity = questions.find((q) => q.id === questionId);
const [elementId, rowId] = updates.leftOperand.value.split(".");
const element = elements.find((q) => q.id === elementId);
if (questionEntity && questionEntity.type === TSurveyElementTypeEnum.Matrix) {
if (element && element.type === TSurveyElementTypeEnum.Matrix) {
if (updates.leftOperand.value.includes(".")) {
// Matrix question with rowId is selected
// Matrix element with rowId is selected
onConditionsChange((conditions) => {
const conditionsCopy = structuredClone(conditions);
updateCondition(conditionsCopy, resourceId, {
leftOperand: {
value: questionId,
value: elementId,
type: "question",
meta: {
row: rowId,
@@ -91,15 +91,9 @@ export function createSharedConditionsFactory(
};
const config: TConditionsEditorConfig<TSingleCondition> = {
getLeftOperandOptions: () =>
questionIdx !== undefined
? getConditionValueOptions(survey, t, questionIdx)
: getConditionValueOptions(survey, t),
getLeftOperandOptions: () => getConditionValueOptions(survey, t, blockIdx),
getOperatorOptions: (condition) => getConditionOperatorOptions(condition, survey, t),
getValueProps: (condition) =>
questionIdx !== undefined
? getMatchValueProps(condition, survey, t, questionIdx)
: getMatchValueProps(condition, survey, t),
getValueProps: (condition) => getMatchValueProps(condition, survey, t, blockIdx),
getDefaultOperator,
formatLeftOperandValue: (condition) => getFormatLeftOperandValue(condition, survey),
};
@@ -107,12 +101,9 @@ export function createSharedConditionsFactory(
const callbacks: TConditionsEditorCallbacks<TSingleCondition> = {
// Creates and adds a new empty condition below the specified condition
onAddConditionBelow: (resourceId: string) => {
// When adding a condition in the context of a specific question, default to that question
const defaultLeftOperandValue = questionIdx !== undefined ? questions[questionIdx].id : questions[0].id;
const defaultOperator =
questionIdx !== undefined
? getDefaultOperatorForQuestion(questions[questionIdx], t)
: getDefaultOperatorForQuestion(questions[0], t);
// When adding a condition in the context of a specific block, default to the first element
const defaultLeftOperandValue = elements.length > 0 ? elements[0].id : "";
const defaultOperator = elements.length > 0 ? getDefaultOperatorForElement(elements[0], t) : "equals";
const newCondition: TSingleCondition = {
id: createId(),
leftOperand: { value: defaultLeftOperandValue, type: "question" },
@@ -144,20 +135,20 @@ export function createSharedConditionsFactory(
});
},
// Updates a condition with new values, handling matrix questions specially
// Updates a condition with new values, handling matrix elements specially
onUpdateCondition: (resourceId: string, updates: Partial<TSingleCondition>) => {
// Try matrix question handling first
if (handleMatrixQuestionUpdate(resourceId, updates)) {
// Try matrix element handling first
if (handleMatrixElementUpdate(resourceId, updates)) {
return;
}
// Check if the operator is correct for the question
// Check if the operator is correct for the element
if (updates.leftOperand?.type === "question" && updates.operator) {
const questionId = updates.leftOperand.value.split(".")[0];
const question = questions.find((q) => q.id === questionId);
const elementId = updates.leftOperand.value.split(".")[0];
const element = elements.find((q) => q.id === elementId);
if (question) {
const operatorOptions = getQuestionOperatorOptions(question, t);
if (element) {
const operatorOptions = getElementOperatorOptions(element, t);
const isValidOperator = operatorOptions.some((o) => o.value === updates.operator);
if (!isValidOperator) {

View File

@@ -26,7 +26,7 @@ import { isConditionGroup } from "@/lib/surveyLogic/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { getQuestionTypes, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine";
@@ -106,74 +106,92 @@ const getQuestionIconMapping = (t: TFunction) =>
{}
);
const getElementHeadline = (
localSurvey: TSurvey,
element: TSurveyElement,
languageCode: string,
t: TFunction
): string => {
const headlineData = recallToHeadline(element.headline, localSurvey, false, languageCode);
const headlineText = headlineData[languageCode];
if (headlineText) {
const textContent = getTextContent(headlineText);
if (textContent.length > 0) {
return textContent;
}
}
return getTSurveyQuestionTypeEnumName(element.type, t) ?? "";
};
export const getConditionValueOptions = (
localSurvey: TSurvey,
t: TFunction,
currQuestionIdx?: number // Optional in case of quotas
blockIdx?: number // Optional - if provided, includes elements from this block and all previous blocks
): TComboboxGroupedOption[] => {
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
const variables = localSurvey.variables ?? [];
// Derive questions from blocks
const questions = getElementsFromBlocks(localSurvey.blocks);
// If blockIdx is provided, get elements from current block and all previous blocks
// Otherwise, get all elements from all blocks
const allElements =
blockIdx === undefined
? getElementsFromBlocks(localSurvey.blocks)
: localSurvey.blocks.slice(0, blockIdx + 1).flatMap((block) => block.elements);
const groupedOptions: TComboboxGroupedOption[] = [];
const questionOptions: TComboboxOption[] = [];
const elementOptions: TComboboxOption[] = [];
questions
.filter((_, idx) => (typeof currQuestionIdx === "undefined" ? true : idx <= currQuestionIdx))
.forEach((question) => {
if (question.type === TSurveyElementTypeEnum.Matrix) {
// Rows submenu
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
const questionHeadline = getTextContent(processedHeadline.default ?? "");
const rows = question.rows.map((row, rowIdx) => {
const processedLabel = recallToHeadline(row.label, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[question.type],
label: `${getTextContent(processedLabel.default ?? "")} (${questionHeadline})`,
value: `${question.id}.${rowIdx}`,
allElements.forEach((element) => {
if (element.type === TSurveyElementTypeEnum.Matrix) {
const elementHeadline = getElementHeadline(localSurvey, element, "default", t);
// Rows submenu
const rows = element.rows.map((row, rowIdx) => {
const processedLabel = recallToHeadline(row.label, localSurvey, false, "default");
return {
icon: getQuestionIconMapping(t)[element.type],
label: `${getTextContent(processedLabel.default ?? "")} (${elementHeadline})`,
value: `${element.id}.${rowIdx}`,
meta: {
type: "question",
rowIdx: rowIdx.toString(),
},
};
});
elementOptions.push({
icon: getQuestionIconMapping(t)[element.type],
label: elementHeadline,
value: element.id,
meta: {
type: "question",
},
children: [
{
label: t("environments.surveys.edit.matrix_rows", "Rows"),
value: `${element.id}-rows`,
children: rows,
},
{
label: t("environments.surveys.edit.matrix_all_fields", "All fields"),
value: element.id,
meta: {
type: "question",
rowIdx: rowIdx.toString(),
},
};
});
questionOptions.push({
icon: getQuestionIconMapping(t)[question.type],
label: questionHeadline,
value: question.id,
meta: {
type: "question",
},
children: [
{
label: t("environments.surveys.edit.matrix_rows", "Rows"),
value: `${question.id}-rows`,
children: rows,
},
{
label: t("environments.surveys.edit.matrix_all_fields", "All fields"),
value: question.id,
meta: {
type: "question",
},
},
],
});
} else {
questionOptions.push({
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
),
value: question.id,
meta: {
type: "question",
},
});
}
});
],
});
} else {
elementOptions.push({
icon: getQuestionIconMapping(t)[element.type],
label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "question",
},
});
}
});
const variableOptions = variables.map((variable) => {
return {
@@ -197,11 +215,11 @@ export const getConditionValueOptions = (
};
});
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}
@@ -237,43 +255,43 @@ export const replaceEndingCardHeadlineRecall = (survey: TSurvey, language: strin
export const getActionObjectiveOptions = (t: TFunction): TComboboxOption[] => [
{ label: t("environments.surveys.edit.calculate"), value: "calculate" },
{ label: t("environments.surveys.edit.require_answer"), value: "requireAnswer" },
{ label: t("environments.surveys.edit.jump_to_question"), value: "jumpToBlock" },
{ label: t("environments.surveys.edit.jump_to_block"), value: "jumpToBlock" },
];
export const hasJumpToBlockAction = (actions: TSurveyBlockLogicAction[]): boolean => {
return actions.some((action) => action.objective === "jumpToBlock");
};
export const getQuestionOperatorOptions = (
question: TSurveyElement,
export const getElementOperatorOptions = (
element: TSurveyElement,
t: TFunction,
condition?: TSingleCondition
): TComboboxOption[] => {
let options: TLogicRuleOption;
if (question.type === "openText") {
const inputType = question.inputType === "number" ? "number" : "text";
if (element.type === "openText") {
const inputType = element.inputType === "number" ? "number" : "text";
options = getLogicRules(t).question[`openText.${inputType}`].options;
} else if (question.type === TSurveyElementTypeEnum.Matrix && condition) {
} else if (element.type === TSurveyElementTypeEnum.Matrix && condition) {
const isMatrixRow =
condition.leftOperand.type === "question" && condition.leftOperand?.meta?.row !== undefined;
options = getLogicRules(t).question[`matrix${isMatrixRow ? ".row" : ""}`].options;
} else {
options = getLogicRules(t).question[question.type].options;
options = getLogicRules(t).question[element.type].options;
}
if (question.required) {
if (element.required) {
options = options.filter((option) => option.value !== "isSkipped") as TLogicRuleOption;
}
return options;
};
export const getDefaultOperatorForQuestion = (
question: TSurveyElement,
export const getDefaultOperatorForElement = (
element: TSurveyElement,
t: TFunction
): TSurveyLogicConditionsOperator => {
const options = getQuestionOperatorOptions(question, t);
const options = getElementOperatorOptions(element, t);
return options[0].value.toString() as TSurveyLogicConditionsOperator;
};
@@ -305,8 +323,8 @@ export const getConditionOperatorOptions = (
return getLogicRules(t).hiddenField.options;
} else if (condition.leftOperand.type === "question") {
// Derive questions from blocks
const questions = getElementsFromBlocks(localSurvey.blocks);
const question = questions.find((question) => {
const elements = getElementsFromBlocks(localSurvey.blocks);
const element = elements.find((question) => {
let leftOperandQuestionId = condition.leftOperand.value;
if (question.type === TSurveyElementTypeEnum.Matrix) {
leftOperandQuestionId = condition.leftOperand.value.split(".")[0];
@@ -314,9 +332,9 @@ export const getConditionOperatorOptions = (
return question.id === leftOperandQuestionId;
});
if (!question) return [];
if (!element) return [];
return getQuestionOperatorOptions(question, t, condition);
return getElementOperatorOptions(element, t, condition);
}
return [];
};
@@ -325,7 +343,7 @@ export const getMatchValueProps = (
condition: TSingleCondition,
localSurvey: TSurvey,
t: TFunction,
questionIdx?: number
blockIdx?: number // Optional - if provided, includes elements from this block and all previous blocks
): {
show?: boolean;
showInput?: boolean;
@@ -350,19 +368,23 @@ export const getMatchValueProps = (
return { show: false, options: [] };
}
// Derive questions from blocks
const allQuestions = getElementsFromBlocks(localSurvey.blocks);
let questions = allQuestions.filter((_, idx) =>
typeof questionIdx === "undefined" ? true : idx <= questionIdx
);
// If blockIdx is provided, get elements from current block and all previous blocks
// Otherwise, get all elements from all blocks
let elements =
blockIdx === undefined
? getElementsFromBlocks(localSurvey.blocks)
: localSurvey.blocks
.slice(0, blockIdx + 1) // Include blocks from 0 to blockIdx (inclusive)
.flatMap((block) => block.elements);
let variables = localSurvey.variables ?? [];
let hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
const selectedQuestion = questions.find((question) => question.id === condition.leftOperand.value);
const selectedElement = elements.find((element) => element.id === condition.leftOperand.value);
const selectedVariable = variables.find((variable) => variable.id === condition.leftOperand.value);
if (condition.leftOperand.type === "question") {
questions = questions.filter((question) => question.id !== condition.leftOperand.value);
elements = elements.filter((element) => element.id !== condition.leftOperand.value);
} else if (condition.leftOperand.type === "variable") {
variables = variables.filter((variable) => variable.id !== condition.leftOperand.value);
} else if (condition.leftOperand.type === "hiddenField") {
@@ -370,16 +392,16 @@ export const getMatchValueProps = (
}
if (condition.leftOperand.type === "question") {
if (selectedQuestion?.type === TSurveyElementTypeEnum.OpenText) {
const allowedQuestionTypes = [TSurveyElementTypeEnum.OpenText];
if (selectedElement?.type === TSurveyElementTypeEnum.OpenText) {
const allowedElementTypes = [TSurveyElementTypeEnum.OpenText];
if (selectedQuestion.inputType === "number") {
allowedQuestionTypes.push(TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS);
if (selectedElement.inputType === "number") {
allowedElementTypes.push(TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS);
}
if (["equals", "doesNotEqual"].includes(condition.operator)) {
if (selectedQuestion.inputType !== "number") {
allowedQuestionTypes.push(
if (selectedElement.inputType !== "number") {
allowedElementTypes.push(
TSurveyElementTypeEnum.Date,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti
@@ -387,15 +409,15 @@ export const getMatchValueProps = (
}
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
const allowedElements = elements.filter((element) => allowedElementTypes.includes(element.type));
const questionOptions = allowedQuestions.map((question) => {
const elementOptions = allowedElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
icon: getQuestionIconMapping(t)[element.type],
label: getTextContent(
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
recallToHeadline(element.headline, localSurvey, false, "default").default ?? ""
),
value: question.id,
value: element.id,
meta: {
type: "question",
},
@@ -404,7 +426,7 @@ export const getMatchValueProps = (
const variableOptions = variables
.filter((variable) =>
selectedQuestion.inputType !== "number" ? variable.type === "text" : variable.type === "number"
selectedElement.inputType === "number" ? variable.type === "number" : variable.type === "text"
)
.map((variable) => {
return {
@@ -430,11 +452,11 @@ export const getMatchValueProps = (
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}
@@ -456,12 +478,12 @@ export const getMatchValueProps = (
return {
show: true,
showInput: true,
inputType: selectedQuestion.inputType === "number" ? "number" : "text",
inputType: selectedElement.inputType === "number" ? "number" : "text",
options: groupedOptions,
};
} else if (
selectedQuestion?.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
selectedQuestion?.type === TSurveyElementTypeEnum.MultipleChoiceMulti
selectedElement?.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
selectedElement?.type === TSurveyElementTypeEnum.MultipleChoiceMulti
) {
const operatorsToFilterNone = [
"includesOneOf",
@@ -470,10 +492,10 @@ export const getMatchValueProps = (
"doesNotIncludeAllOf",
];
const shouldFilterNone =
selectedQuestion.type === TSurveyElementTypeEnum.MultipleChoiceMulti &&
selectedElement.type === TSurveyElementTypeEnum.MultipleChoiceMulti &&
operatorsToFilterNone.includes(condition.operator);
const choices = selectedQuestion.choices
const choices = selectedElement.choices
.filter((choice) => !shouldFilterNone || choice.id !== "none")
.map((choice) => {
return {
@@ -490,8 +512,8 @@ export const getMatchValueProps = (
showInput: false,
options: [{ label: t("common.choices"), value: "choices", options: choices }],
};
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.PictureSelection) {
const choices = selectedQuestion.choices.map((choice, idx) => {
} else if (selectedElement?.type === TSurveyElementTypeEnum.PictureSelection) {
const choices = selectedElement.choices.map((choice, idx) => {
return {
imgSrc: choice.imageUrl,
label: `${t("common.picture")} ${idx + 1}`,
@@ -507,8 +529,8 @@ export const getMatchValueProps = (
showInput: false,
options: [{ label: t("common.choices"), value: "choices", options: choices }],
};
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Rating) {
const choices = Array.from({ length: selectedQuestion.range }, (_, idx) => {
} else if (selectedElement?.type === TSurveyElementTypeEnum.Rating) {
const choices = Array.from({ length: selectedElement.range }, (_, idx) => {
return {
label: `${idx + 1}`,
value: idx + 1,
@@ -554,7 +576,7 @@ export const getMatchValueProps = (
showInput: false,
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.NPS) {
} else if (selectedElement?.type === TSurveyElementTypeEnum.NPS) {
const choices = Array.from({ length: 11 }, (_, idx) => {
return {
label: `${idx}`,
@@ -601,18 +623,18 @@ export const getMatchValueProps = (
showInput: false,
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Date) {
const openTextQuestions = questions.filter((question) =>
[TSurveyElementTypeEnum.OpenText, TSurveyElementTypeEnum.Date].includes(question.type)
} else if (selectedElement?.type === TSurveyElementTypeEnum.Date) {
const openTextElements = elements.filter((element) =>
[TSurveyElementTypeEnum.OpenText, TSurveyElementTypeEnum.Date].includes(element.type)
);
const questionOptions = openTextQuestions.map((question) => {
const elementOptions = openTextElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
icon: getQuestionIconMapping(t)[element.type],
label: getTextContent(
recallToHeadline(question.headline, localSurvey, false, "default").default ?? ""
recallToHeadline(element.headline, localSurvey, false, "default").default ?? ""
),
value: question.id,
value: element.id,
meta: {
type: "question",
},
@@ -645,11 +667,11 @@ export const getMatchValueProps = (
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}
@@ -675,8 +697,8 @@ export const getMatchValueProps = (
inputType: "date",
options: groupedOptions,
};
} else if (selectedQuestion?.type === TSurveyElementTypeEnum.Matrix) {
const choices = selectedQuestion.columns.map((column, colIdx) => {
} else if (selectedElement?.type === TSurveyElementTypeEnum.Matrix) {
const choices = selectedElement.columns.map((column, colIdx) => {
return {
label: getLocalizedValue(column.label, "default"),
value: colIdx.toString(),
@@ -694,22 +716,22 @@ export const getMatchValueProps = (
}
} else if (condition.leftOperand.type === "variable") {
if (selectedVariable?.type === "text") {
const allowedQuestionTypes = [
const allowedElementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.MultipleChoiceSingle,
];
if (["equals", "doesNotEqual"].includes(condition.operator)) {
allowedQuestionTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
allowedElementTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
const allowedElements = elements.filter((element) => allowedElementTypes.includes(element.type));
const questionOptions = allowedQuestions.map((question) => {
const elementOptions = allowedElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
icon: getQuestionIconMapping(t)[element.type],
label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "question",
},
@@ -742,11 +764,11 @@ export const getMatchValueProps = (
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}
@@ -773,17 +795,17 @@ export const getMatchValueProps = (
options: groupedOptions,
};
} else if (selectedVariable?.type === "number") {
const allowedQuestions = questions.filter(
(question) =>
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(question.type) ||
(question.type === TSurveyElementTypeEnum.OpenText && question.inputType === "number")
const allowedElements = elements.filter(
(element) =>
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(element.type) ||
(element.type === TSurveyElementTypeEnum.OpenText && element.inputType === "number")
);
const questionOptions = allowedQuestions.map((question) => {
const elementOptions = allowedElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
icon: getQuestionIconMapping(t)[element.type],
label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "question",
},
@@ -816,11 +838,11 @@ export const getMatchValueProps = (
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}
@@ -848,22 +870,22 @@ export const getMatchValueProps = (
};
}
} else if (condition.leftOperand.type === "hiddenField") {
const allowedQuestionTypes = [
const allowedElementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.MultipleChoiceSingle,
];
if (["equals", "doesNotEqual"].includes(condition.operator)) {
allowedQuestionTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
allowedElementTypes.push(TSurveyElementTypeEnum.MultipleChoiceMulti, TSurveyElementTypeEnum.Date);
}
const allowedQuestions = questions.filter((question) => allowedQuestionTypes.includes(question.type));
const allowedElements = elements.filter((element) => allowedElementTypes.includes(element.type));
const questionOptions = allowedQuestions.map((question) => {
const elementOptions = allowedElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
icon: getQuestionIconMapping(t)[element.type],
label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "question",
},
@@ -896,11 +918,11 @@ export const getMatchValueProps = (
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}
@@ -934,50 +956,45 @@ export const getMatchValueProps = (
export const getActionTargetOptions = (
action: TSurveyBlockLogicAction,
localSurvey: TSurvey,
currQuestionIdx: number,
blockIdx: number,
t: TFunction
): TComboboxOption[] => {
// Derive questions from blocks
const allQuestions = localSurvey.blocks?.flatMap((b) => b.elements) ?? [];
let questions = allQuestions.filter((_, idx) => idx > currQuestionIdx);
// Derive elements from blocks
const allElements = localSurvey.blocks?.flatMap((b) => b.elements) ?? [];
// Calculate which elements come after the current block
let elementsUpToAndIncludingCurrentBlock = 0;
for (let i = 0; i <= blockIdx; i++) {
elementsUpToAndIncludingCurrentBlock += localSurvey.blocks[i].elements.length;
}
// For requireAnswer, show elements after the current block (not including current block)
if (action.objective === "requireAnswer") {
questions = questions.filter((question) => !question.required);
// Return question IDs (elements) for requireAnswer
return questions.map((question) => {
const processedHeadline = recallToHeadline(question.headline, localSurvey, false, "default");
const elementsAfterCurrentBlock = allElements.filter(
(_, idx) => idx >= elementsUpToAndIncludingCurrentBlock
);
const nonRequiredElements = elementsAfterCurrentBlock.filter((element) => !element.required);
// Return element IDs for requireAnswer
return nonRequiredElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(processedHeadline.default ?? ""),
value: question.id, // Element ID
icon: getQuestionIconMapping(t)[element.type],
label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
};
});
}
// For jumpToBlock, we need block IDs
// Track which blocks we've already added to avoid duplicates when a block has multiple elements
const blocks = localSurvey.blocks ?? [];
const addedBlockIds = new Set<string>();
const questionOptions: TComboboxOption[] = [];
const blockOptions: TComboboxOption[] = [];
for (const question of questions) {
// Find which block this question belongs to
const block = blocks.find((b) => b.elements.some((e) => e.id === question.id));
// Add blocks after the current block
for (let i = blockIdx + 1; i < blocks.length; i++) {
const block = blocks[i];
if (!block) continue;
// Skip if we've already added this block
if (addedBlockIds.has(block.id)) continue;
// Mark this block as added
addedBlockIds.add(block.id);
// Use the first element's headline as the block label
const firstElement = block.elements[0];
const processedHeadline = recallToHeadline(firstElement.headline, localSurvey, false, "default");
questionOptions.push({
icon: getQuestionIconMapping(t)[firstElement.type],
label: getTextContent(processedHeadline.default ?? ""),
blockOptions.push({
label: block.name,
value: block.id,
});
}
@@ -1004,7 +1021,7 @@ export const getActionTargetOptions = (
}
});
return [...questionOptions, ...endingCardOptions];
return [...blockOptions, ...endingCardOptions];
};
export const getActionVariableOptions = (localSurvey: TSurvey): TComboboxOption[] => {
@@ -1067,13 +1084,15 @@ export const getActionOperatorOptions = (
export const getActionValueOptions = (
variableId: string,
localSurvey: TSurvey,
questionIdx: number,
blockIdx: number,
t: TFunction
): TComboboxGroupedOption[] => {
const questions = getElementsFromBlocks(localSurvey.blocks);
// Get elements from current block and all previous blocks
const allElements = localSurvey.blocks
.slice(0, blockIdx + 1) // Include blocks from 0 to blockIdx (inclusive)
.flatMap((block) => block.elements);
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
let variables = localSurvey.variables ?? [];
const filteredQuestions = questions.filter((_, idx) => idx <= questionIdx);
const hiddenFieldsOptions = hiddenFields.map((field) => {
return {
@@ -1093,21 +1112,21 @@ export const getActionValueOptions = (
if (!selectedVariable) return [];
if (selectedVariable.type === "text") {
const allowedQuestions = filteredQuestions.filter((question) =>
const allowedElements = allElements.filter((element) =>
[
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Date,
].includes(question.type)
].includes(element.type)
);
const questionOptions = allowedQuestions.map((question) => {
const elementOptions = allowedElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
icon: getQuestionIconMapping(t)[element.type],
label: getElementHeadline(localSurvey, element, "default", t),
value: element.id,
meta: {
type: "question",
},
@@ -1129,11 +1148,11 @@ export const getActionValueOptions = (
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}
@@ -1155,17 +1174,17 @@ export const getActionValueOptions = (
return groupedOptions;
} else if (selectedVariable.type === "number") {
const allowedQuestions = filteredQuestions.filter(
(question) =>
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(question.type) ||
(question.type === TSurveyElementTypeEnum.OpenText && question.inputType === "number")
const allowedElements = allElements.filter(
(element) =>
[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS].includes(element.type) ||
(element.type === TSurveyElementTypeEnum.OpenText && element.inputType === "number")
);
const questionOptions = allowedQuestions.map((question) => {
const elementOptions = allowedElements.map((element) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
icon: getQuestionIconMapping(t)[element.type],
label: getTextContent(getLocalizedValue(element.headline, "default")),
value: element.id,
meta: {
type: "question",
},
@@ -1187,11 +1206,11 @@ export const getActionValueOptions = (
const groupedOptions: TComboboxGroupedOption[] = [];
if (questionOptions.length > 0) {
if (elementOptions.length > 0) {
groupedOptions.push({
label: t("common.questions"),
value: "questions",
options: questionOptions,
options: elementOptions,
});
}

View File

@@ -171,6 +171,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [
subheader: createI18nString("", []),
ctaButtonLabel: createI18nString(t("templates.book_interview"), []),
buttonUrl: "",
buttonExternal: true,
},
},
{

View File

@@ -32,7 +32,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
interface TooltipRendererProps {
tooltipContent: ReactNode;
@@ -40,12 +40,13 @@ interface TooltipRendererProps {
className?: string;
triggerClass?: string;
shouldRender?: boolean;
delayDuration?: number;
}
export const TooltipRenderer = (props: TooltipRendererProps) => {
const { children, shouldRender = true, tooltipContent, className, triggerClass } = props;
const { children, shouldRender = true, tooltipContent, className, triggerClass, delayDuration = 0 } = props;
if (shouldRender) {
return (
<TooltipProvider delayDuration={0}>
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild>
<span className={triggerClass}>{children}</span>

View File

@@ -27,7 +27,6 @@ interface BlockConditionalProps {
setTtc: (ttc: TResponseTtc) => void;
surveyId: string;
autoFocusEnabled: boolean;
currentBlockId: string;
isBackButtonHidden: boolean;
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
@@ -208,8 +207,6 @@ export function BlockConditional({
onChange={(responseData) => handleElementChange(element.id, responseData)}
onBack={() => {}}
onFileUpload={onFileUpload}
isFirstElement={false}
isLastElement={false}
languageCode={languageCode}
prefilledElementValue={prefilledResponseData?.[element.id]}
skipPrefilled={skipPrefilled}

View File

@@ -30,8 +30,6 @@ interface ElementConditionalProps {
onChange: (responseData: TResponseData) => void;
onBack: () => void;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
isFirstElement: boolean;
isLastElement: boolean;
languageCode: string;
prefilledElementValue?: TResponseDataValue;
skipPrefilled?: boolean;

View File

@@ -777,7 +777,6 @@ export function Survey({
isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id}
languageCode={selectedLanguage}
autoFocusEnabled={autoFocusEnabled}
currentBlockId={blockId}
isBackButtonHidden={localSurvey.isBackButtonHidden}
onOpenExternalURL={onOpenExternalURL}
dir={dir}

View File

@@ -11,7 +11,7 @@ export const LinkIcon = ({ className }: LinkIconProps) => {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}>

View File

@@ -36,44 +36,58 @@ export function AddressQuestion({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]);
const isCurrent = question.id === currentQuestionId;
const fields = [
{
id: "addressLine1",
...question.addressLine1,
label: question.addressLine1.placeholder[languageCode],
},
{
id: "addressLine2",
...question.addressLine2,
label: question.addressLine2.placeholder[languageCode],
},
{
id: "city",
...question.city,
label: question.city.placeholder[languageCode],
},
{
id: "state",
...question.state,
label: question.state.placeholder[languageCode],
},
{
id: "zip",
...question.zip,
label: question.zip.placeholder[languageCode],
},
{
id: "country",
...question.country,
label: question.country.placeholder[languageCode],
},
];
const fields = useMemo(
() => [
{
id: "addressLine1",
...question.addressLine1,
label: question.addressLine1.placeholder[languageCode],
},
{
id: "addressLine2",
...question.addressLine2,
label: question.addressLine2.placeholder[languageCode],
},
{
id: "city",
...question.city,
label: question.city.placeholder[languageCode],
},
{
id: "state",
...question.state,
label: question.state.placeholder[languageCode],
},
{
id: "zip",
...question.zip,
label: question.zip.placeholder[languageCode],
},
{
id: "country",
...question.country,
label: question.country.placeholder[languageCode],
},
],
[
question.addressLine1,
question.addressLine2,
question.city,
question.state,
question.zip,
question.country,
languageCode,
]
);
const handleChange = (fieldId: string, fieldValue: string) => {
const newValue = fields.map((field) => {
@@ -102,6 +116,25 @@ export function AddressQuestion({
[question.id, autoFocusEnabled, currentQuestionId]
);
const isFieldRequired = useCallback(
(field: (typeof fields)[number]) => {
if (field.required) {
return true;
}
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
}
return false;
},
[fields, question.required]
);
return (
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<div>
@@ -118,32 +151,17 @@ export function AddressQuestion({
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
return true;
}
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
}
return false;
};
const isRequired = isFieldRequired(field);
return (
field.show && (
<div className="fb-space-y-1">
<Label htmlForId={field.id} text={isFieldRequired() ? `${field.label}*` : field.label} />
<div className="fb-space-y-1" key={field.id}>
<Label htmlForId={field.id} text={isRequired ? `${field.label}*` : field.label} />
<Input
id={field.id}
key={field.id}
required={isFieldRequired()}
required={isRequired}
value={safeValue[index] || ""}
type={field.id === "email" ? "email" : "text"}
type="text"
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}

View File

@@ -35,10 +35,11 @@ export function CalQuestion({
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const onSuccessfulBooking = useCallback(() => {
setErrorMessage("");
onChange({ [question.id]: "booked" });
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
}, [onChange, question.id, setTtc, startTime, ttc]);
}, [onChange, question.id, setTtc, startTime, ttc, setErrorMessage]);
return (
<form

View File

@@ -96,7 +96,7 @@ export function ConsentQuestion({
aria-labelledby={`${question.id}-label`}
required={question.required}
/>
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium fb-flex-1" dir="auto">
<span className="fb-ml-3 fb-mr-3 fb-font-medium fb-flex-1" dir="auto">
{getLocalizedValue(question.label, languageCode)}
</span>
</label>

View File

@@ -34,6 +34,20 @@ export function CTAQuestion({
const isCurrent = question.id === currentQuestionId;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const handleExternalButtonClick = () => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onChange({ [question.id]: "clicked" });
if (question.buttonUrl) {
if (onOpenExternalURL) {
onOpenExternalURL(question.buttonUrl);
} else {
window.open(question.buttonUrl, "_blank")?.focus();
}
}
};
return (
<div key={question.id}>
<div>
@@ -47,29 +61,21 @@ export function CTAQuestion({
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
<button
dir="auto"
type="button"
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onChange({ [question.id]: "clicked" });
if (onOpenExternalURL) {
onOpenExternalURL(question.buttonUrl);
} else {
window.open(question.buttonUrl, "_blank")?.focus();
}
}}
className="fb-text-heading focus:fb-ring-focus fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(question.ctaButtonLabel, languageCode)}
<LinkIcon className="fb-ml-2 fb-h-4 fb-w-4" />
</button>
{question.buttonExternal && question.buttonUrl && (
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
<button
dir="auto"
type="button"
tabIndex={isCurrent ? 0 : -1}
onClick={handleExternalButtonClick}
className="fb-text-heading focus:fb-ring-focus fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(question.ctaButtonLabel, languageCode)}
<LinkIcon className="fb-ml-2 fb-h-4 fb-w-4" />
</button>
</div>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -127,10 +127,14 @@ export function DateQuestion({
key={question.id}
onSubmit={(e) => {
e.preventDefault();
// Validate required field
if (question.required && !value) {
setErrorMessage(t("errors.please_select_a_date"));
return;
}
setErrorMessage("");
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
}}
@@ -189,6 +193,15 @@ export function DateQuestion({
isOpen={datePickerOpen}
onChange={(value) => {
const date = value as Date;
if (!date) {
if (question.required) {
setErrorMessage(t("errors.please_select_a_date"));
}
return;
}
setErrorMessage("");
setSelectedDate(date);
// Get the timezone offset in minutes and convert it to milliseconds

View File

@@ -237,9 +237,8 @@ export function MultipleChoiceMultiQuestion({
baseLabelClassName
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
// Accessibility: if spacebar was pressed pass this down to the checkbox
if (e.key === " ") {
if (otherSelected) return;
e.preventDefault();
document.getElementById(otherOption.id)?.click();
}
@@ -248,7 +247,7 @@ export function MultipleChoiceMultiQuestion({
<input
type="checkbox"
dir={dir}
tabIndex={-1}
tabIndex={isCurrent ? 0 : -1}
id={otherOption.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
@@ -279,7 +278,7 @@ export function MultipleChoiceMultiQuestion({
<input
ref={otherSpecify}
dir={otherOptionInputDir}
id={`${otherOption.id}-label`}
id={`${otherOption.id}-specify`}
maxLength={250}
name={question.id}
tabIndex={isCurrent ? 0 : -1}

View File

@@ -180,9 +180,7 @@ export function MultipleChoiceSingleQuestion({
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(otherOption.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
@@ -224,11 +222,11 @@ export function MultipleChoiceSingleQuestion({
{otherSelected ? (
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
id={`${otherOption.id}-input`}
dir={otherOptionInputDir}
name={question.id}
pattern=".*\S+.*"
value={value}
value={value ?? ""}
onChange={(e) => {
onChange({ [question.id]: e.currentTarget.value });
}}

View File

@@ -169,7 +169,7 @@ export function OpenTextQuestion({
)}
{question.inputType === "text" && question.charLimit?.max !== undefined && (
<span
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 font-semibold" : "text-neutral-400"}`}>
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 fb-font-semibold" : "fb-text-neutral-400"}`}>
{currentLength}/{question.charLimit?.max}
</span>
)}

View File

@@ -45,9 +45,11 @@ export function StackedCardsContainer({
const blockIdxTemp = useMemo(() => {
if (currentBlockId === "start") return survey.welcomeCard.enabled ? -1 : 0;
if (!survey.blocks.map((block) => block.id).includes(currentBlockId)) {
return survey.blocks.length;
}
return survey.blocks.findIndex((block) => block.id === currentBlockId);
}, [currentBlockId, survey]);
@@ -132,7 +134,7 @@ export function StackedCardsContainer({
// Reset block progress, when card arrangement changes
useEffect(() => {
if (shouldResetBlockId) {
setBlockId(survey.welcomeCard.enabled ? "start" : survey.blocks[0]?.id);
setBlockId(survey.welcomeCard.enabled ? "start" : survey.blocks[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only update when cardArrangement changes
}, [cardArrangement]);

View File

@@ -158,8 +158,30 @@ export type TSurveyNPSElement = z.infer<typeof ZSurveyNPSElement>;
// CTA Element
export const ZSurveyCTAElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.CTA),
buttonUrl: ZUrl,
ctaButtonLabel: ZI18nString,
buttonExternal: z.boolean().optional().default(false),
buttonUrl: z.string().optional(),
ctaButtonLabel: ZI18nString.optional(),
}).superRefine((data, ctx) => {
// When buttonExternal is true, buttonUrl is required and must be valid
if (data.buttonExternal) {
if (!data.buttonUrl || data.buttonUrl.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Button URL is required when external button is enabled",
path: ["buttonUrl"],
});
} else {
// Validate URL format only when buttonExternal is true and URL is provided
const urlValidation = ZUrl.safeParse(data.buttonUrl);
if (!urlValidation.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Please enter a valid URL",
path: ["buttonUrl"],
});
}
}
}
});
export type TSurveyCTAElement = z.infer<typeof ZSurveyCTAElement>;
@@ -189,7 +211,7 @@ export const ZSurveyPictureSelectionElement = ZSurveyElementBase.extend({
allowMulti: z.boolean().optional().default(false),
choices: z
.array(ZSurveyPictureChoice)
.min(2, { message: "Picture Selection element must have atleast 2 choices" }),
.min(2, { message: "Picture Selection element must have a minimum of 2 choices" }),
});
export type TSurveyPictureSelectionElement = z.infer<typeof ZSurveyPictureSelectionElement>;

View File

@@ -1199,7 +1199,10 @@ export const ZSurvey = z
// Validate block button labels
const defaultLanguageCode = "default";
if (block.buttonLabel && block.buttonLabel[defaultLanguageCode].trim() !== "") {
if (
block.buttonLabel?.[defaultLanguageCode] &&
block.buttonLabel[defaultLanguageCode].trim() !== ""
) {
// Validate button label for all enabled languages
const enabledLanguages = languages.filter((lang) => lang.enabled);
const languageCodes = enabledLanguages.map((lang) =>
@@ -1224,7 +1227,10 @@ export const ZSurvey = z
}
}
if (block.backButtonLabel && block.backButtonLabel[defaultLanguageCode].trim() !== "") {
if (
block.backButtonLabel?.[defaultLanguageCode] &&
block.backButtonLabel[defaultLanguageCode].trim() !== ""
) {
// Validate back button label for all enabled languages
const enabledLanguages = languages.filter((lang) => lang.enabled);
const languageCodes = enabledLanguages.map((lang) =>
@@ -1364,10 +1370,12 @@ export const ZSurvey = z
}
if (element.type === TSurveyElementTypeEnum.CTA) {
if (!element.required) {
// Only validate buttonExternal fields when buttonExternal is true
if (element.buttonExternal) {
// Validate ctaButtonLabel when buttonExternal is enabled
elementMultiLangIssue = validateElementLabels(
"ctaButtonLabel",
element.ctaButtonLabel,
element.ctaButtonLabel ?? {},
languages,
blockIndex,
elementIndex
@@ -1378,27 +1386,28 @@ export const ZSurvey = z
blockIndex,
"elements",
elementIndex,
"dismissButtonLabel",
"ctaButtonLabel",
];
ctx.addIssue(elementMultiLangIssue);
}
}
if (!element.buttonUrl || element.buttonUrl.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}: Button URL is required when external button is enabled`,
path: ["blocks", blockIndex, "elements", elementIndex, "buttonUrl"],
});
} else {
const parsedButtonUrl = getZSafeUrl.safeParse(element.buttonUrl);
if (!parsedButtonUrl.success) {
const errorMessage = parsedButtonUrl.error.issues[0].message;
// Validate buttonUrl when buttonExternal is enabled
if (!element.buttonUrl || element.buttonUrl.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}: ${errorMessage}`,
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}: Button URL is required when external button is enabled`,
path: ["blocks", blockIndex, "elements", elementIndex, "buttonUrl"],
});
} else {
const parsedButtonUrl = getZSafeUrl.safeParse(element.buttonUrl);
if (!parsedButtonUrl.success) {
const errorMessage = parsedButtonUrl.error.issues[0].message;
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}: ${errorMessage}`,
path: ["blocks", blockIndex, "elements", elementIndex, "buttonUrl"],
});
}
}
}
}