Compare commits

...

6 Commits

Author SHA1 Message Date
Dhruwang 67161155a9 fix build 2026-01-08 15:48:45 +05:30
Dhruwang 9b0cf5f532 refactor: update validation rule types for survey elements
- Replace TValidationRule with specific validation rule types for each survey element in their respective forms.
- Ensure type safety by introducing TValidationRulesFor* types for Address, Cal, Consent, Contact Info, CTA, Date, File Upload, Matrix, Multiple Choice, NPS, Open Text, Picture Selection, Ranking, and Rating elements.
- Update the ZSurveyElement definitions to include the new validation rules schemas.
2026-01-08 15:14:29 +05:30
Dhruwang a32241d7c8 fix sonar and code rabbit issues 2026-01-08 14:34:12 +05:30
Dhruwang a296ad189a fix: resolve linting issues in validation rules editor component 2026-01-08 14:08:13 +05:30
Dhruwang 942cb0f8d0 fix lint error 2026-01-08 13:53:06 +05:30
Dhruwang 3e3b8cc349 feat: add validation rules editor UI and refactor required logic
- Add ValidationRulesEditor component with drag-and-drop support
- Integrate validation rules editor into all element forms
- Refactor element components to derive 'required' state from validationRules
- Remove legacy 'required' toggle from element form inputs
- Update Headline component to use validationRules instead of required prop
- Remove unused translation keys (input_type, maximum, minimum, character_limit_toggle)
- Fix TypeScript serialization issue by using z.lazy for validation rules schema
- Simplify element type definitions by removing narrowed validation rule types
2026-01-08 13:47:14 +05:30
51 changed files with 2111 additions and 331 deletions
+15 -5
View File
@@ -211,7 +211,6 @@ checksums:
common/imprint: c4e5f2a1994d3cc5896b200709cc499c common/imprint: c4e5f2a1994d3cc5896b200709cc499c
common/in_progress: 3de9afebcb9d4ce8ac42e14995f79ffd common/in_progress: 3de9afebcb9d4ce8ac42e14995f79ffd
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76 common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/input_type: df4865b5d0a598a8d7f563dcec104df5
common/integration: 40d02f65c4356003e0e90ffb944907d2 common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36 common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7 common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
@@ -235,13 +234,11 @@ checksums:
common/look_and_feel: 9125503712626d495cedec7a79f1418c common/look_and_feel: 9125503712626d495cedec7a79f1418c
common/manage: a3d40c0267b81ae53c9598eaeb05087d common/manage: a3d40c0267b81ae53c9598eaeb05087d
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8 common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2 common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32 common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08 common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4 common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028 common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72 common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: 6d73b635018b4a5a89cce58e1d2497f5 common/mobile_overlay_surveys_look_good: 6d73b635018b4a5a89cce58e1d2497f5
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
@@ -1149,8 +1146,6 @@ checksums:
environments/surveys/edit/change_the_question_color_of_the_survey: ab6942138a8c5fc6c8c3b9f8dd95e980 environments/surveys/edit/change_the_question_color_of_the_survey: ab6942138a8c5fc6c8c3b9f8dd95e980
environments/surveys/edit/changes_saved: 90aab363c9e96eaa1295a997c48f97f6 environments/surveys/edit/changes_saved: 90aab363c9e96eaa1295a997c48f97f6
environments/surveys/edit/changing_survey_type_will_remove_existing_distribution_channels: 9ce817be04f13f2f0db981145ec48df4 environments/surveys/edit/changing_survey_type_will_remove_existing_distribution_channels: 9ce817be04f13f2f0db981145ec48df4
environments/surveys/edit/character_limit_toggle_description: d15a6895eaaf4d4c7212d9240c6bf45d
environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87
environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8 environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987 environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987
environments/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80 environments/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
@@ -1516,6 +1511,21 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17 environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421 environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664 environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/validation/characters: f62970e214bd04fd1959e2759ee1ec48
environments/surveys/edit/validation/email: a481cd9fba3e145252458ee1eaa9bd3b
environments/surveys/edit/validation/max_length: dad68e07f6ee06ed11ec6bda2e896c68
environments/surveys/edit/validation/max_selections: 6edf9e1149c3893da102d9464138da22
environments/surveys/edit/validation/max_value: 6109d595ba21497c59b1c91d7fd09a13
environments/surveys/edit/validation/min_length: ad5c57a937565826794fb865522962e8
environments/surveys/edit/validation/min_selections: 204dbf1f1b3aa34c8b981642b1694262
environments/surveys/edit/validation/min_value: b9542ab0e0ea0ee18e82931b160b1385
environments/surveys/edit/validation/options_selected: 088309b017c07c01494447dba82b2621
environments/surveys/edit/validation/pattern: c6f01d7bc9baa21a40ea38fa986bd5a0
environments/surveys/edit/validation/phone: bcd7bd37a475ab1f80ea4c5b4d4d0bb5
environments/surveys/edit/validation/required: b6c231d5d1a8dfe37615d1efd38ed8e0
environments/surveys/edit/validation/url: 4006a4d8dfac013758f0053f6aa67cdd
environments/surveys/edit/validation_rules: 0cd99f02684d633196c8b249e857d207
environments/surveys/edit/validation_rules_description: a0a7cee05e18efd462148698e3a93399
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7 environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Impressum", "imprint": "Impressum",
"in_progress": "Im Gange", "in_progress": "Im Gange",
"inactive_surveys": "Inaktive Umfragen", "inactive_surveys": "Inaktive Umfragen",
"input_type": "Eingabetyp",
"integration": "Integration", "integration": "Integration",
"integrations": "Integrationen", "integrations": "Integrationen",
"invalid_date": "Ungültiges Datum", "invalid_date": "Ungültiges Datum",
@@ -262,13 +261,11 @@
"look_and_feel": "Darstellung", "look_and_feel": "Darstellung",
"manage": "Verwalten", "manage": "Verwalten",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Maximal",
"member": "Mitglied", "member": "Mitglied",
"members": "Mitglieder", "members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams", "members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden", "membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten", "metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.", "mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!", "mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!", "mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.", "change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"changes_saved": "Änderungen gespeichert.", "changes_saved": "Änderungen gespeichert.",
"changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"", "changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
"checkbox_label": "Checkbox-Beschriftung", "checkbox_label": "Checkbox-Beschriftung",
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.", "choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block", "choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
@@ -1589,6 +1584,23 @@
"upper_label": "Oberes Label", "upper_label": "Oberes Label",
"url_filters": "URL-Filter", "url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt", "url_not_supported": "URL nicht unterstützt",
"validation": {
"characters": "Zeichen",
"email": "Ist gültige E-Mail",
"max_length": "Ist kürzer als",
"max_selections": "Höchstens",
"max_value": "Ist weniger als",
"min_length": "Ist länger als",
"min_selections": "Mindestens",
"min_value": "Ist größer als",
"options_selected": "Optionen ausgewählt",
"pattern": "Entspricht Regex-Muster",
"phone": "Ist gültige Telefonnummer",
"required": "Ist erforderlich",
"url": "Ist gültige URL"
},
"validation_rules": "Validierungsregeln",
"validation_rules_description": "Nur Antworten akzeptieren, die die folgenden Kriterien erfüllen",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.", "variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
+18 -6
View File
@@ -238,7 +238,6 @@
"imprint": "Imprint", "imprint": "Imprint",
"in_progress": "In Progress", "in_progress": "In Progress",
"inactive_surveys": "Inactive surveys", "inactive_surveys": "Inactive surveys",
"input_type": "Input type",
"integration": "integration", "integration": "integration",
"integrations": "Integrations", "integrations": "Integrations",
"invalid_date": "Invalid date", "invalid_date": "Invalid date",
@@ -262,13 +261,11 @@
"look_and_feel": "Look & Feel", "look_and_feel": "Look & Feel",
"manage": "Manage", "manage": "Manage",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Maximum",
"member": "Member", "member": "Member",
"members": "Members", "members": "Members",
"members_and_teams": "Members & Teams", "members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found", "membership_not_found": "Membership not found",
"metadata": "Metadata", "metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.", "mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!", "mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!", "mobile_overlay_title": "Oops, tiny screen detected!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Change the question color of the survey.", "change_the_question_color_of_the_survey": "Change the question color of the survey.",
"changes_saved": "Changes saved.", "changes_saved": "Changes saved.",
"changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.", "changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
"checkbox_label": "Checkbox Label", "checkbox_label": "Checkbox Label",
"choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.", "choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.",
"choose_the_first_question_on_your_block": "Choose the first question on your Block", "choose_the_first_question_on_your_block": "Choose the first question on your Block",
@@ -1589,6 +1584,23 @@
"upper_label": "Upper Label", "upper_label": "Upper Label",
"url_filters": "URL Filters", "url_filters": "URL Filters",
"url_not_supported": "URL not supported", "url_not_supported": "URL not supported",
"validation": {
"characters": "characters",
"email": "Is valid email",
"max_length": "Is shorter than",
"max_selections": "At most",
"max_value": "Is less than",
"min_length": "Is longer than",
"min_selections": "At least",
"min_value": "Is greater than",
"options_selected": "options selected",
"pattern": "Matches regex pattern",
"phone": "Is valid phone",
"required": "Is required",
"url": "Is valid URL"
},
"validation_rules": "Validation rules",
"validation_rules_description": "Only accept responses that meet the following criteria",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.", "variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
@@ -3034,4 +3046,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.", "usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)" "usability_score_name": "System Usability Score (SUS)"
} }
} }
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Aviso legal", "imprint": "Aviso legal",
"in_progress": "En progreso", "in_progress": "En progreso",
"inactive_surveys": "Encuestas inactivas", "inactive_surveys": "Encuestas inactivas",
"input_type": "Tipo de entrada",
"integration": "integración", "integration": "integración",
"integrations": "Integraciones", "integrations": "Integraciones",
"invalid_date": "Fecha no válida", "invalid_date": "Fecha no válida",
@@ -262,13 +261,11 @@
"look_and_feel": "Apariencia", "look_and_feel": "Apariencia",
"manage": "Gestionar", "manage": "Gestionar",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Máximo",
"member": "Miembro", "member": "Miembro",
"members": "Miembros", "members": "Miembros",
"members_and_teams": "Miembros y equipos", "members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada", "membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos", "metadata": "Metadatos",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.", "mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!", "mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!", "mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Cambiar el color de las preguntas de la encuesta.", "change_the_question_color_of_the_survey": "Cambiar el color de las preguntas de la encuesta.",
"changes_saved": "Cambios guardados.", "changes_saved": "Cambios guardados.",
"changing_survey_type_will_remove_existing_distribution_channels": "Cambiar el tipo de encuesta afectará a cómo se puede compartir. Si los encuestados ya tienen enlaces de acceso para el tipo actual, podrían perder el acceso después del cambio.", "changing_survey_type_will_remove_existing_distribution_channels": "Cambiar el tipo de encuesta afectará a cómo se puede compartir. Si los encuestados ya tienen enlaces de acceso para el tipo actual, podrían perder el acceso después del cambio.",
"character_limit_toggle_description": "Limitar lo corta o larga que puede ser una respuesta.",
"character_limit_toggle_title": "Añadir límites de caracteres",
"checkbox_label": "Etiqueta de casilla de verificación", "checkbox_label": "Etiqueta de casilla de verificación",
"choose_the_actions_which_trigger_the_survey": "Elige las acciones que activan la encuesta.", "choose_the_actions_which_trigger_the_survey": "Elige las acciones que activan la encuesta.",
"choose_the_first_question_on_your_block": "Elige la primera pregunta en tu bloque", "choose_the_first_question_on_your_block": "Elige la primera pregunta en tu bloque",
@@ -1589,6 +1584,23 @@
"upper_label": "Etiqueta superior", "upper_label": "Etiqueta superior",
"url_filters": "Filtros de URL", "url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible", "url_not_supported": "URL no compatible",
"validation": {
"characters": "caracteres",
"email": "Es un correo electrónico válido",
"max_length": "Es más corto que",
"max_selections": "Como máximo",
"max_value": "Es menor que",
"min_length": "Es más largo que",
"min_selections": "Al menos",
"min_value": "Es mayor que",
"options_selected": "opciones seleccionadas",
"pattern": "Coincide con el patrón regex",
"phone": "Es un teléfono válido",
"required": "Es obligatorio",
"url": "Es una URL válida"
},
"validation_rules": "Reglas de validación",
"validation_rules_description": "Solo aceptar respuestas que cumplan los siguientes criterios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala primero de la lógica.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala primero de la lógica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" se está utilizando en la cuota \"{quotaName}\"", "variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" se está utilizando en la cuota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "El nombre de la variable ya está en uso, por favor elige otro.", "variable_name_is_already_taken_please_choose_another": "El nombre de la variable ya está en uso, por favor elige otro.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Empreinte", "imprint": "Empreinte",
"in_progress": "En cours", "in_progress": "En cours",
"inactive_surveys": "Sondages inactifs", "inactive_surveys": "Sondages inactifs",
"input_type": "Type d'entrée",
"integration": "intégration", "integration": "intégration",
"integrations": "Intégrations", "integrations": "Intégrations",
"invalid_date": "Date invalide", "invalid_date": "Date invalide",
@@ -262,13 +261,11 @@
"look_and_feel": "Apparence", "look_and_feel": "Apparence",
"manage": "Gérer", "manage": "Gérer",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Max",
"member": "Membre", "member": "Membre",
"members": "Membres", "members": "Membres",
"members_and_teams": "Membres & Équipes", "members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé", "membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées", "metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.", "mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!", "mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!", "mobile_overlay_title": "Oups, écran minuscule détecté!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Vous pouvez modifier la couleur des questions d'une enquête.", "change_the_question_color_of_the_survey": "Vous pouvez modifier la couleur des questions d'une enquête.",
"changes_saved": "Modifications enregistrées.", "changes_saved": "Modifications enregistrées.",
"changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.", "changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.",
"character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères",
"checkbox_label": "Étiquette de case à cocher", "checkbox_label": "Étiquette de case à cocher",
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.", "choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc", "choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
@@ -1589,6 +1584,23 @@
"upper_label": "Étiquette supérieure", "upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL", "url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée", "url_not_supported": "URL non supportée",
"validation": {
"characters": "caractères",
"email": "Est un e-mail valide",
"max_length": "Est plus court que",
"max_selections": "Au maximum",
"max_value": "Est inférieur à",
"min_length": "Est plus long que",
"min_selections": "Au moins",
"min_value": "Est supérieur à",
"options_selected": "options sélectionnées",
"pattern": "Correspond au modèle d'expression régulière",
"phone": "Est un numéro de téléphone valide",
"required": "Est requis",
"url": "Est une URL valide"
},
"validation_rules": "Règles de validation",
"validation_rules_description": "Accepter uniquement les réponses qui répondent aux critères suivants",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"", "variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.", "variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "企業情報", "imprint": "企業情報",
"in_progress": "進行中", "in_progress": "進行中",
"inactive_surveys": "非アクティブなフォーム", "inactive_surveys": "非アクティブなフォーム",
"input_type": "入力タイプ",
"integration": "連携", "integration": "連携",
"integrations": "連携", "integrations": "連携",
"invalid_date": "無効な日付です", "invalid_date": "無効な日付です",
@@ -262,13 +261,11 @@
"look_and_feel": "デザイン", "look_and_feel": "デザイン",
"manage": "管理", "manage": "管理",
"marketing": "マーケティング", "marketing": "マーケティング",
"maximum": "最大",
"member": "メンバー", "member": "メンバー",
"members": "メンバー", "members": "メンバー",
"members_and_teams": "メンバー&チーム", "members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません", "membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ", "metadata": "メタデータ",
"minimum": "最小",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。", "mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!", "mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!", "mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "フォームの質問の色を変更します。", "change_the_question_color_of_the_survey": "フォームの質問の色を変更します。",
"changes_saved": "変更を保存しました。", "changes_saved": "変更を保存しました。",
"changing_survey_type_will_remove_existing_distribution_channels": "フォームの種類を変更すると、共有方法に影響します。回答者が現在のタイプのアクセスリンクをすでに持っている場合、切り替え後にアクセスを失う可能性があります。", "changing_survey_type_will_remove_existing_distribution_channels": "フォームの種類を変更すると、共有方法に影響します。回答者が現在のタイプのアクセスリンクをすでに持っている場合、切り替え後にアクセスを失う可能性があります。",
"character_limit_toggle_description": "回答の長さの上限・下限を設定します。",
"character_limit_toggle_title": "文字数制限を追加",
"checkbox_label": "チェックボックスのラベル", "checkbox_label": "チェックボックスのラベル",
"choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。", "choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。",
"choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください", "choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください",
@@ -1589,6 +1584,23 @@
"upper_label": "上限ラベル", "upper_label": "上限ラベル",
"url_filters": "URLフィルター", "url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません", "url_not_supported": "URLはサポートされていません",
"validation": {
"characters": "文字",
"email": "有効なメールアドレスである",
"max_length": "より短い",
"max_selections": "最大",
"max_value": "より小さい",
"min_length": "より長い",
"min_selections": "最小",
"min_value": "より大きい",
"options_selected": "個のオプションが選択されている",
"pattern": "正規表現パターンに一致する",
"phone": "有効な電話番号である",
"required": "必須である",
"url": "有効なURLである"
},
"validation_rules": "検証ルール",
"validation_rules_description": "次の条件を満たす回答のみを受け付ける",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています", "variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。", "variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Afdruk", "imprint": "Afdruk",
"in_progress": "In uitvoering", "in_progress": "In uitvoering",
"inactive_surveys": "Inactieve enquêtes", "inactive_surveys": "Inactieve enquêtes",
"input_type": "Invoertype",
"integration": "integratie", "integration": "integratie",
"integrations": "Integraties", "integrations": "Integraties",
"invalid_date": "Ongeldige datum", "invalid_date": "Ongeldige datum",
@@ -262,13 +261,11 @@
"look_and_feel": "Kijk & voel", "look_and_feel": "Kijk & voel",
"manage": "Beheren", "manage": "Beheren",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Maximaal",
"member": "Lid", "member": "Lid",
"members": "Leden", "members": "Leden",
"members_and_teams": "Leden & teams", "members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden", "membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens", "metadata": "Metagegevens",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.", "mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!", "mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!", "mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Verander de vraagkleur van de enquête.", "change_the_question_color_of_the_survey": "Verander de vraagkleur van de enquête.",
"changes_saved": "Wijzigingen opgeslagen.", "changes_saved": "Wijzigingen opgeslagen.",
"changing_survey_type_will_remove_existing_distribution_channels": "Het wijzigen van het enquêtetype heeft invloed op de manier waarop deze kan worden gedeeld. Als respondenten al toegangslinks hebben voor het huidige type, verliezen ze mogelijk de toegang na de overstap.", "changing_survey_type_will_remove_existing_distribution_channels": "Het wijzigen van het enquêtetype heeft invloed op de manier waarop deze kan worden gedeeld. Als respondenten al toegangslinks hebben voor het huidige type, verliezen ze mogelijk de toegang na de overstap.",
"character_limit_toggle_description": "Beperk hoe kort of lang een antwoord mag zijn.",
"character_limit_toggle_title": "Tekenlimieten toevoegen",
"checkbox_label": "Selectievakje-label", "checkbox_label": "Selectievakje-label",
"choose_the_actions_which_trigger_the_survey": "Kies de acties die de enquête activeren.", "choose_the_actions_which_trigger_the_survey": "Kies de acties die de enquête activeren.",
"choose_the_first_question_on_your_block": "Kies de eerste vraag in je blok", "choose_the_first_question_on_your_block": "Kies de eerste vraag in je blok",
@@ -1589,6 +1584,23 @@
"upper_label": "Bovenste etiket", "upper_label": "Bovenste etiket",
"url_filters": "URL-filters", "url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund", "url_not_supported": "URL niet ondersteund",
"validation": {
"characters": "tekens",
"email": "Is geldig e-mailadres",
"max_length": "Is korter dan",
"max_selections": "Maximaal",
"max_value": "Is minder dan",
"min_length": "Is langer dan",
"min_selections": "Minimaal",
"min_value": "Is groter dan",
"options_selected": "opties geselecteerd",
"pattern": "Komt overeen met regex-patroon",
"phone": "Is geldig telefoonnummer",
"required": "Is verplicht",
"url": "Is geldige URL"
},
"validation_rules": "Validatieregels",
"validation_rules_description": "Accepteer alleen antwoorden die voldoen aan de volgende criteria",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele \"{variableName}\" wordt gebruikt in het \"{quotaName}\" quotum", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele \"{variableName}\" wordt gebruikt in het \"{quotaName}\" quotum",
"variable_name_is_already_taken_please_choose_another": "Variabelenaam is al in gebruik, kies een andere.", "variable_name_is_already_taken_please_choose_another": "Variabelenaam is al in gebruik, kies een andere.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "impressão", "imprint": "impressão",
"in_progress": "Em andamento", "in_progress": "Em andamento",
"inactive_surveys": "Pesquisas inativas", "inactive_surveys": "Pesquisas inativas",
"input_type": "Tipo de entrada",
"integration": "integração", "integration": "integração",
"integrations": "Integrações", "integrations": "Integrações",
"invalid_date": "Data inválida", "invalid_date": "Data inválida",
@@ -262,13 +261,11 @@
"look_and_feel": "Aparência e Experiência", "look_and_feel": "Aparência e Experiência",
"manage": "gerenciar", "manage": "gerenciar",
"marketing": "marketing", "marketing": "marketing",
"maximum": "Máximo",
"member": "Membros", "member": "Membros",
"members": "Membros", "members": "Membros",
"members_and_teams": "Membros e equipes", "members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada", "membership_not_found": "Assinatura não encontrada",
"metadata": "metadados", "metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.", "mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!", "mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!", "mobile_overlay_title": "Eita, tela pequena detectada!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.", "change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"changes_saved": "Mudanças salvas.", "changes_saved": "Mudanças salvas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.", "changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção", "checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.", "choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco", "choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco",
@@ -1589,6 +1584,23 @@
"upper_label": "Etiqueta Superior", "upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL", "url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada", "url_not_supported": "URL não suportada",
"validation": {
"characters": "caracteres",
"email": "É um e-mail válido",
"max_length": "É menor que",
"max_selections": "No máximo",
"max_value": "É menor que",
"min_length": "É maior que",
"min_selections": "No mínimo",
"min_value": "É maior que",
"options_selected": "opções selecionadas",
"pattern": "Corresponde ao padrão regex",
"phone": "É um telefone válido",
"required": "É obrigatório",
"url": "É uma URL válida"
},
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que atendam aos seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.", "variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Impressão", "imprint": "Impressão",
"in_progress": "Em Progresso", "in_progress": "Em Progresso",
"inactive_surveys": "Inquéritos inativos", "inactive_surveys": "Inquéritos inativos",
"input_type": "Tipo de entrada",
"integration": "integração", "integration": "integração",
"integrations": "Integrações", "integrations": "Integrações",
"invalid_date": "Data inválida", "invalid_date": "Data inválida",
@@ -262,13 +261,11 @@
"look_and_feel": "Aparência e Sensação", "look_and_feel": "Aparência e Sensação",
"manage": "Gerir", "manage": "Gerir",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Máximo",
"member": "Membro", "member": "Membro",
"members": "Membros", "members": "Membros",
"members_and_teams": "Membros e equipas", "members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada", "membership_not_found": "Associação não encontrada",
"metadata": "Metadados", "metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.", "mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!", "mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!", "mobile_overlay_title": "Oops, ecrã pequeno detectado!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito", "change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"changes_saved": "Alterações guardadas.", "changes_saved": "Alterações guardadas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.", "changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção", "checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.", "choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco", "choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco",
@@ -1589,6 +1584,23 @@
"upper_label": "Etiqueta Superior", "upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL", "url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado", "url_not_supported": "URL não suportado",
"validation": {
"characters": "caracteres",
"email": "É um email válido",
"max_length": "É mais curto que",
"max_selections": "No máximo",
"max_value": "É menos que",
"min_length": "É mais longo que",
"min_selections": "Pelo menos",
"min_value": "É maior que",
"options_selected": "opções selecionadas",
"pattern": "Coincide com o padrão regex",
"phone": "É um telefone válido",
"required": "É obrigatório",
"url": "É um URL válido"
},
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que cumpram os seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.", "variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Amprentă", "imprint": "Amprentă",
"in_progress": "În progres", "in_progress": "În progres",
"inactive_surveys": "Sondaje inactive", "inactive_surveys": "Sondaje inactive",
"input_type": "Tipul de intrare",
"integration": "integrare", "integration": "integrare",
"integrations": "Integrări", "integrations": "Integrări",
"invalid_date": "Dată invalidă", "invalid_date": "Dată invalidă",
@@ -262,13 +261,11 @@
"look_and_feel": "Aspect și Comportament", "look_and_feel": "Aspect și Comportament",
"manage": "Gestionați", "manage": "Gestionați",
"marketing": "Marketing", "marketing": "Marketing",
"maximum": "Maximum",
"member": "Membru", "member": "Membru",
"members": "Membri", "members": "Membri",
"members_and_teams": "Membri și echipe", "members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită", "membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate", "metadata": "Metadate",
"minimum": "Minim",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.", "mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!", "mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
"mobile_overlay_title": "Ups, ecran mic detectat!", "mobile_overlay_title": "Ups, ecran mic detectat!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Schimbați culoarea întrebării chestionarului.", "change_the_question_color_of_the_survey": "Schimbați culoarea întrebării chestionarului.",
"changes_saved": "Modificările au fost salvate", "changes_saved": "Modificările au fost salvate",
"changing_survey_type_will_remove_existing_distribution_channels": "Schimbarea tipului chestionarului va afecta modul în care acesta poate fi distribuit. Dacă respondenții au deja linkuri de acces pentru tipul curent, aceștia ar putea pierde accesul după schimbare.", "changing_survey_type_will_remove_existing_distribution_channels": "Schimbarea tipului chestionarului va afecta modul în care acesta poate fi distribuit. Dacă respondenții au deja linkuri de acces pentru tipul curent, aceștia ar putea pierde accesul după schimbare.",
"character_limit_toggle_description": "Limitați cât de scurt sau lung poate fi un răspuns.",
"character_limit_toggle_title": "Adăugați limite de caractere",
"checkbox_label": "Etichetă casetă de selectare", "checkbox_label": "Etichetă casetă de selectare",
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.", "choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
"choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău", "choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău",
@@ -1589,6 +1584,23 @@
"upper_label": "Etichetă superioară", "upper_label": "Etichetă superioară",
"url_filters": "Filtre URL", "url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat", "url_not_supported": "URL nesuportat",
"validation": {
"characters": "caractere",
"email": "Este un email valid",
"max_length": "Este mai scurt de",
"max_selections": "Cel mult",
"max_value": "Este mai mic decât",
"min_length": "Este mai lung de",
"min_selections": "Cel puțin",
"min_value": "Este mai mare decât",
"options_selected": "opțiuni selectate",
"pattern": "Se potrivește cu un șablon regex",
"phone": "Este un număr de telefon valid",
"required": "Este obligatoriu",
"url": "Este un URL valid"
},
"validation_rules": "Reguli de validare",
"validation_rules_description": "Acceptă doar răspunsurile care îndeplinesc următoarele criterii",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.", "variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Выходные данные", "imprint": "Выходные данные",
"in_progress": "В процессе", "in_progress": "В процессе",
"inactive_surveys": "Неактивные опросы", "inactive_surveys": "Неактивные опросы",
"input_type": "Тип ввода",
"integration": "интеграция", "integration": "интеграция",
"integrations": "Интеграции", "integrations": "Интеграции",
"invalid_date": "Неверная дата", "invalid_date": "Неверная дата",
@@ -262,13 +261,11 @@
"look_and_feel": "Внешний вид", "look_and_feel": "Внешний вид",
"manage": "Управление", "manage": "Управление",
"marketing": "Маркетинг", "marketing": "Маркетинг",
"maximum": "Максимум",
"member": "Участник", "member": "Участник",
"members": "Участники", "members": "Участники",
"members_and_teams": "Участники и команды", "members_and_teams": "Участники и команды",
"membership_not_found": "Участие не найдено", "membership_not_found": "Участие не найдено",
"metadata": "Метаданные", "metadata": "Метаданные",
"minimum": "Минимум",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.", "mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!", "mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
"mobile_overlay_title": "Ой, обнаружен маленький экран!", "mobile_overlay_title": "Ой, обнаружен маленький экран!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Изменить цвет вопросов в опросе.", "change_the_question_color_of_the_survey": "Изменить цвет вопросов в опросе.",
"changes_saved": "Изменения сохранены.", "changes_saved": "Изменения сохранены.",
"changing_survey_type_will_remove_existing_distribution_channels": "Изменение типа опроса повлияет на способы его распространения. Если у респондентов уже есть ссылки для доступа к текущему типу, после смены они могут потерять доступ.", "changing_survey_type_will_remove_existing_distribution_channels": "Изменение типа опроса повлияет на способы его распространения. Если у респондентов уже есть ссылки для доступа к текущему типу, после смены они могут потерять доступ.",
"character_limit_toggle_description": "Ограничьте минимальную и максимальную длину ответа.",
"character_limit_toggle_title": "Добавить ограничения на количество символов",
"checkbox_label": "Метка флажка", "checkbox_label": "Метка флажка",
"choose_the_actions_which_trigger_the_survey": "Выберите действия, которые запускают опрос.", "choose_the_actions_which_trigger_the_survey": "Выберите действия, которые запускают опрос.",
"choose_the_first_question_on_your_block": "Выберите первый вопрос в вашем блоке", "choose_the_first_question_on_your_block": "Выберите первый вопрос в вашем блоке",
@@ -1589,6 +1584,23 @@
"upper_label": "Верхняя метка", "upper_label": "Верхняя метка",
"url_filters": "Фильтры URL", "url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается", "url_not_supported": "URL не поддерживается",
"validation": {
"characters": "символов",
"email": "Корректный email",
"max_length": "Короче чем",
"max_selections": "Не более",
"max_value": "Меньше чем",
"min_length": "Длиннее чем",
"min_selections": "Не менее",
"min_value": "Больше чем",
"options_selected": "выбрано вариантов",
"pattern": "Соответствует шаблону regex",
"phone": "Корректный телефон",
"required": "Обязательное поле",
"url": "Корректный URL"
},
"validation_rules": "Правила валидации",
"validation_rules_description": "Принимать только ответы, соответствующие следующим критериям",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}»", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}»",
"variable_name_is_already_taken_please_choose_another": "Это имя переменной уже занято, выберите другое.", "variable_name_is_already_taken_please_choose_another": "Это имя переменной уже занято, выберите другое.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "Impressum", "imprint": "Impressum",
"in_progress": "Pågående", "in_progress": "Pågående",
"inactive_surveys": "Inaktiva enkäter", "inactive_surveys": "Inaktiva enkäter",
"input_type": "Inmatningstyp",
"integration": "integration", "integration": "integration",
"integrations": "Integrationer", "integrations": "Integrationer",
"invalid_date": "Ogiltigt datum", "invalid_date": "Ogiltigt datum",
@@ -262,13 +261,11 @@
"look_and_feel": "Utseende", "look_and_feel": "Utseende",
"manage": "Hantera", "manage": "Hantera",
"marketing": "Marknadsföring", "marketing": "Marknadsföring",
"maximum": "Maximum",
"member": "Medlem", "member": "Medlem",
"members": "Medlemmar", "members": "Medlemmar",
"members_and_teams": "Medlemmar och team", "members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte", "membership_not_found": "Medlemskap hittades inte",
"metadata": "Metadata", "metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.", "mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
"mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!", "mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
"mobile_overlay_title": "Hoppsan, liten skärm upptäckt!", "mobile_overlay_title": "Hoppsan, liten skärm upptäckt!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "Ändra enkätens frågefärg.", "change_the_question_color_of_the_survey": "Ändra enkätens frågefärg.",
"changes_saved": "Ändringar sparade.", "changes_saved": "Ändringar sparade.",
"changing_survey_type_will_remove_existing_distribution_channels": "Att ändra enkättypen påverkar hur den kan delas. Om respondenter redan har åtkomstlänkar för den nuvarande typen kan de förlora åtkomst efter bytet.", "changing_survey_type_will_remove_existing_distribution_channels": "Att ändra enkättypen påverkar hur den kan delas. Om respondenter redan har åtkomstlänkar för den nuvarande typen kan de förlora åtkomst efter bytet.",
"character_limit_toggle_description": "Begränsa hur kort eller långt ett svar kan vara.",
"character_limit_toggle_title": "Lägg till teckengränser",
"checkbox_label": "Kryssruteetikett", "checkbox_label": "Kryssruteetikett",
"choose_the_actions_which_trigger_the_survey": "Välj de åtgärder som utlöser enkäten.", "choose_the_actions_which_trigger_the_survey": "Välj de åtgärder som utlöser enkäten.",
"choose_the_first_question_on_your_block": "Välj den första frågan i ditt block", "choose_the_first_question_on_your_block": "Välj den första frågan i ditt block",
@@ -1589,6 +1584,23 @@
"upper_label": "Övre etikett", "upper_label": "Övre etikett",
"url_filters": "URL-filter", "url_filters": "URL-filter",
"url_not_supported": "URL stöds inte", "url_not_supported": "URL stöds inte",
"validation": {
"characters": "tecken",
"email": "Är en giltig e-postadress",
"max_length": "Är kortare än",
"max_selections": "Högst",
"max_value": "Är mindre än",
"min_length": "Är längre än",
"min_selections": "Minst",
"min_value": "Är större än",
"options_selected": "valda alternativ",
"pattern": "Matchar regexmönster",
"phone": "Är ett giltigt telefonnummer",
"required": "Är obligatorisk",
"url": "Är en giltig URL"
},
"validation_rules": "Valideringsregler",
"validation_rules_description": "Acceptera endast svar som uppfyller följande kriterier",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabel \"{variableName}\" används i kvoten \"{quotaName}\"", "variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabel \"{variableName}\" används i kvoten \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Variabelnamnet är redan taget, vänligen välj ett annat.", "variable_name_is_already_taken_please_choose_another": "Variabelnamnet är redan taget, vänligen välj ett annat.",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "印记", "imprint": "印记",
"in_progress": "进行中", "in_progress": "进行中",
"inactive_surveys": "不 活跃 调查", "inactive_surveys": "不 活跃 调查",
"input_type": "输入类型",
"integration": "集成", "integration": "集成",
"integrations": "集成", "integrations": "集成",
"invalid_date": "无效 日期", "invalid_date": "无效 日期",
@@ -262,13 +261,11 @@
"look_and_feel": "外观 & 感觉", "look_and_feel": "外观 & 感觉",
"manage": "管理", "manage": "管理",
"marketing": "市场营销", "marketing": "市场营销",
"maximum": "最大值",
"member": "成员", "member": "成员",
"members": "成员", "members": "成员",
"members_and_teams": "成员和团队", "members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格", "membership_not_found": "未找到会员资格",
"metadata": "元数据", "metadata": "元数据",
"minimum": "最低",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。", "mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!", "mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
"mobile_overlay_title": "噢, 检测 到 小 屏幕!", "mobile_overlay_title": "噢, 检测 到 小 屏幕!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "更改调查的 问题颜色", "change_the_question_color_of_the_survey": "更改调查的 问题颜色",
"changes_saved": "更改 已 保存", "changes_saved": "更改 已 保存",
"changing_survey_type_will_remove_existing_distribution_channels": "更改 调查 类型 会影 响 分享 方式 。 如果 受访者 已经 拥有 当前 类型 的 访问 链接 , 在 更改 之后 ,他们 可能 会 失去 访问 权限 。", "changing_survey_type_will_remove_existing_distribution_channels": "更改 调查 类型 会影 响 分享 方式 。 如果 受访者 已经 拥有 当前 类型 的 访问 链接 , 在 更改 之后 ,他们 可能 会 失去 访问 权限 。",
"character_limit_toggle_description": "限制 答案的短或长程度。",
"character_limit_toggle_title": "添加 字符限制",
"checkbox_label": "复选框 标签", "checkbox_label": "复选框 标签",
"choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。", "choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。",
"choose_the_first_question_on_your_block": "选择区块中的第一个问题", "choose_the_first_question_on_your_block": "选择区块中的第一个问题",
@@ -1589,6 +1584,23 @@
"upper_label": "上限标签", "upper_label": "上限标签",
"url_filters": "URL 过滤器", "url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持", "url_not_supported": "URL 不支持",
"validation": {
"characters": "个字符",
"email": "是有效的邮箱地址",
"max_length": "短于",
"max_selections": "最多",
"max_value": "小于",
"min_length": "长于",
"min_selections": "至少",
"min_value": "大于",
"options_selected": "项已选择",
"pattern": "匹配正则表达式模式",
"phone": "是有效的手机号",
"required": "为必填项",
"url": "是有效的URL"
},
"validation_rules": "校验规则",
"validation_rules_description": "仅接受符合以下条件的回复",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用", "variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。", "variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
+17 -5
View File
@@ -238,7 +238,6 @@
"imprint": "版本訊息", "imprint": "版本訊息",
"in_progress": "進行中", "in_progress": "進行中",
"inactive_surveys": "停用中的問卷", "inactive_surveys": "停用中的問卷",
"input_type": "輸入類型",
"integration": "整合", "integration": "整合",
"integrations": "整合", "integrations": "整合",
"invalid_date": "無效日期", "invalid_date": "無效日期",
@@ -262,13 +261,11 @@
"look_and_feel": "外觀與風格", "look_and_feel": "外觀與風格",
"manage": "管理", "manage": "管理",
"marketing": "行銷", "marketing": "行銷",
"maximum": "最大值",
"member": "成員", "member": "成員",
"members": "成員", "members": "成員",
"members_and_teams": "成員與團隊", "members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格", "membership_not_found": "找不到成員資格",
"metadata": "元數據", "metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。", "mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!", "mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!", "mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
@@ -1220,8 +1217,6 @@
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。", "change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"changes_saved": "已儲存變更。", "changes_saved": "已儲存變更。",
"changing_survey_type_will_remove_existing_distribution_channels": "更改問卷類型會影響其共享方式。如果受訪者已擁有當前類型的存取連結,則在切換後可能會失去存取權限。", "changing_survey_type_will_remove_existing_distribution_channels": "更改問卷類型會影響其共享方式。如果受訪者已擁有當前類型的存取連結,則在切換後可能會失去存取權限。",
"character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制",
"checkbox_label": "核取方塊標籤", "checkbox_label": "核取方塊標籤",
"choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。", "choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。",
"choose_the_first_question_on_your_block": "選擇此區塊的第一個問題", "choose_the_first_question_on_your_block": "選擇此區塊的第一個問題",
@@ -1589,6 +1584,23 @@
"upper_label": "上標籤", "upper_label": "上標籤",
"url_filters": "網址篩選器", "url_filters": "網址篩選器",
"url_not_supported": "不支援網址", "url_not_supported": "不支援網址",
"validation": {
"characters": "個字元",
"email": "是有效的電子郵件",
"max_length": "少於",
"max_selections": "最多",
"max_value": "小於",
"min_length": "多於",
"min_selections": "至少",
"min_value": "大於",
"options_selected": "個選項已選",
"pattern": "符合正則表達式樣式",
"phone": "是有效的電話號碼",
"required": "為必填",
"url": "是有效的 URL"
},
"validation_rules": "驗證規則",
"validation_rules_description": "僅接受符合下列條件的回應",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中", "variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。", "variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
@@ -9,7 +9,6 @@ import { type TI18nString } from "@formbricks/types/i18n";
import { import {
TSurveyElement, TSurveyElement,
TSurveyElementChoice, TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements"; } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
@@ -24,7 +23,6 @@ import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input"; import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { import {
determineImageUploaderVisibility, determineImageUploaderVisibility,
@@ -315,70 +313,6 @@ export const ElementFormInput = ({
return false; return false;
}; };
const getIsRequiredToggleDisabled = (): boolean => {
if (!currentElement) return false;
// CTA elements should always have the required toggle disabled
if (currentElement.type === TSurveyElementTypeEnum.CTA) {
return true;
}
if (currentElement.type === TSurveyElementTypeEnum.Address) {
const allFieldsAreOptional = [
currentElement.addressLine1,
currentElement.addressLine2,
currentElement.city,
currentElement.state,
currentElement.zip,
currentElement.country,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
return true;
}
return [
currentElement.addressLine1,
currentElement.addressLine2,
currentElement.city,
currentElement.state,
currentElement.zip,
currentElement.country,
]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
if (currentElement.type === TSurveyElementTypeEnum.ContactInfo) {
const allFieldsAreOptional = [
currentElement.firstName,
currentElement.lastName,
currentElement.email,
currentElement.phone,
currentElement.company,
]
.filter((field) => field.show)
.every((field) => !field.required);
if (allFieldsAreOptional) {
return true;
}
return [
currentElement.firstName,
currentElement.lastName,
currentElement.email,
currentElement.phone,
currentElement.company,
]
.filter((field) => field.show)
.some((condition) => condition.required === true);
}
return false;
};
const useRichTextEditor = id === "headline" || id === "subheader" || id === "html"; const useRichTextEditor = id === "headline" || id === "subheader" || id === "html";
@@ -393,21 +327,6 @@ export const ElementFormInput = ({
{label && ( {label && (
<div className="mb-2 mt-3 flex items-center justify-between"> <div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<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={currentElement.required}
disabled={getIsRequiredToggleDisabled()}
onCheckedChange={(checked) => {
updateElement(elementIdx, { required: checked });
}}
/>
</div>
)}
</div> </div>
)} )}
<div className="flex flex-col gap-4" ref={animationParent}> <div className="flex flex-col gap-4" ref={animationParent}>
@@ -523,21 +442,6 @@ export const ElementFormInput = ({
{label && ( {label && (
<div className="mb-2 mt-3 flex items-center justify-between"> <div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<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={currentElement.required}
disabled={getIsRequiredToggleDisabled()}
onCheckedChange={(checked) => {
updateElement(elementIdx, { required: checked });
}}
/>
</div>
)}
</div> </div>
)} )}
<MultiLangWrapper <MultiLangWrapper
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX, useEffect } from "react"; import { type JSX, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyAddressElement } from "@formbricks/types/surveys/elements"; import { TSurveyAddressElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForAddress } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table"; import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table";
@@ -159,6 +161,16 @@ export const AddressElementForm = ({
isStorageConfigured={isStorageConfigured} isStorageConfigured={isStorageConfigured}
/> />
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Address}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForAddress) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -3,11 +3,13 @@
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyCalElement } from "@formbricks/types/surveys/elements"; import { TSurveyCalElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForCal } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
@@ -143,6 +145,16 @@ export const CalElementForm = ({
</AdvancedOptionToggle> </AdvancedOptionToggle>
</div> </div>
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Cal}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForCal) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX } from "react"; import { type JSX } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyConsentElement } from "@formbricks/types/surveys/elements"; import { TSurveyConsentElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForConsent } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
interface ConsentElementFormProps { interface ConsentElementFormProps {
@@ -102,6 +104,16 @@ export const ConsentElementForm = ({
placeholder="I agree to the terms and conditions" placeholder="I agree to the terms and conditions"
value={element.label} value={element.label}
/> />
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Consent}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForConsent) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX, useEffect } from "react"; import { type JSX, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyContactInfoElement } from "@formbricks/types/surveys/elements"; import { TSurveyContactInfoElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForContactInfo } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table"; import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table";
@@ -156,6 +158,16 @@ export const ContactInfoElementForm = ({
isStorageConfigured={isStorageConfigured} isStorageConfigured={isStorageConfigured}
/> />
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.ContactInfo}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForContactInfo) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX } from "react"; import { type JSX } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyDateElement } from "@formbricks/types/surveys/elements"; import { TSurveyDateElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForDate } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch"; import { OptionsSwitch } from "@/modules/ui/components/options-switch";
@@ -126,6 +128,16 @@ export const DateElementForm = ({
/> />
</div> </div>
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Date}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForDate) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -8,11 +8,13 @@ import { type JSX, useMemo, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage"; import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForFileUpload } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
@@ -229,7 +231,7 @@ export const FileUploadElementForm = ({
updateElement(elementIdx, { maxSizeInMB: parseInt(e.target.value, 10) }); updateElement(elementIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
}} }}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm" className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
/> />
MB MB
</p> </p>
@@ -290,6 +292,16 @@ export const FileUploadElementForm = ({
</div> </div>
</AdvancedOptionToggle> </AdvancedOptionToggle>
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.FileUpload}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForFileUpload) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -9,12 +9,14 @@ import { type JSX, useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n"; import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyMatrixElement } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum, TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForMatrix } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item"; import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
@@ -347,6 +349,16 @@ export const MatrixElementForm = ({
</div> </div>
</div> </div>
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Matrix}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForMatrix) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -12,11 +12,16 @@ import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n"; import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types"; import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import {
TValidationRulesForMultipleChoiceMulti,
TValidationRulesForMultipleChoiceSingle,
} from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal"; import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice"; import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
@@ -398,6 +403,28 @@ export const MultipleChoiceElementForm = ({
surveyLanguageCodes={surveyLanguageCodes} surveyLanguageCodes={surveyLanguageCodes}
locale={locale} locale={locale}
/> />
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceMulti}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForMultipleChoiceMulti) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
) : (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceSingle}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForMultipleChoiceSingle) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
)}
</form> </form>
); );
}; };
@@ -4,11 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX } from "react"; import { type JSX } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyNPSElement } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum, TSurveyNPSElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForNPS } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -140,6 +142,16 @@ export const NPSElementForm = ({
childBorder childBorder
customContainerClass="p-0 mt-4" customContainerClass="p-0 mt-4"
/> />
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.NPS}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForNPS) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -1,19 +1,22 @@
"use client"; "use client";
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { JSX, useEffect, useState } from "react"; import { JSX } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyOpenTextElement, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements"; import {
TSurveyElementTypeEnum,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForOpenText } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
interface OpenElementFormProps { interface OpenElementFormProps {
localSurvey: TSurvey; localSurvey: TSurvey;
@@ -42,43 +45,10 @@ export const OpenElementForm = ({
isExternalUrlsAllowed, isExternalUrlsAllowed,
}: OpenElementFormProps): JSX.Element => { }: OpenElementFormProps): JSX.Element => {
const { t } = useTranslation(); const { t } = useTranslation();
const elementTypes = [
{ value: "text", label: t("common.text"), icon: <MessageSquareTextIcon className="h-4 w-4" /> },
{ value: "email", label: t("common.email"), icon: <MailIcon className="h-4 w-4" /> },
{ value: "url", label: t("common.url"), icon: <LinkIcon className="h-4 w-4" /> },
{ value: "number", label: t("common.number"), icon: <HashIcon className="h-4 w-4" /> },
{ value: "phone", label: t("common.phone"), icon: <PhoneIcon className="h-4 w-4" /> },
];
const defaultPlaceholder = getPlaceholderByInputType(element.inputType ?? "text"); const defaultPlaceholder = getPlaceholderByInputType(element.inputType ?? "text");
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []); const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
const [showCharLimits, setShowCharLimits] = useState(element.inputType === "text");
const handleInputChange = (inputType: TSurveyOpenTextElementInputType) => {
const updatedAttributes = {
inputType: inputType,
placeholder: createI18nString(getPlaceholderByInputType(inputType), surveyLanguageCodes),
longAnswer: inputType === "text" ? element.longAnswer : false,
charLimit: {
min: undefined,
max: undefined,
},
};
setIsCharLimitEnabled(false);
setShowCharLimits(inputType === "text");
updateElement(elementIdx, updatedAttributes);
};
const [parent] = useAutoAnimate(); const [parent] = useAutoAnimate();
const [isCharLimitEnabled, setIsCharLimitEnabled] = useState(false);
useEffect(() => {
if (element?.charLimit?.min !== undefined || element?.charLimit?.max !== undefined) {
setIsCharLimitEnabled(true);
} else {
setIsCharLimitEnabled(false);
}
}, [element?.charLimit?.max, element?.charLimit?.min]);
return ( return (
<form> <form>
@@ -156,80 +126,7 @@ export const OpenElementForm = ({
/> />
</div> </div>
{/* Add a dropdown to select the element type */}
<div className="mt-3">
<Label htmlFor="elementType">{t("common.input_type")}</Label>
<div className="mt-2 flex items-center">
<OptionsSwitch
options={elementTypes}
currentOption={element.inputType}
handleOptionChange={handleInputChange} // Use the merged function
/>
</div>
</div>
<div className="mt-6 space-y-6"> <div className="mt-6 space-y-6">
{showCharLimits && (
<AdvancedOptionToggle
isChecked={isCharLimitEnabled}
onToggle={(checked: boolean) => {
setIsCharLimitEnabled(checked);
updateElement(elementIdx, {
charLimit: {
enabled: checked,
min: undefined,
max: undefined,
},
});
}}
htmlId={`charLimit-${element.id}`}
description={t("environments.surveys.edit.character_limit_toggle_description")}
childBorder
title={t("environments.surveys.edit.character_limit_toggle_title")}
customContainerClass="p-0">
<div className="flex gap-4 p-4">
<div className="flex items-center gap-2">
<Label htmlFor="minLength">{t("common.minimum")}</Label>
<Input
id="minLength"
name="minLength"
type="number"
min={0}
value={element?.charLimit?.min || ""}
aria-label={t("common.minimum")}
className="bg-white"
onChange={(e) =>
updateElement(elementIdx, {
charLimit: {
...element?.charLimit,
min: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="maxLength">{t("common.maximum")}</Label>
<Input
id="maxLength"
name="maxLength"
type="number"
min={0}
aria-label={t("common.maximum")}
value={element?.charLimit?.max || ""}
className="bg-white"
onChange={(e) =>
updateElement(elementIdx, {
charLimit: {
...element?.charLimit,
max: e.target.value ? parseInt(e.target.value) : undefined,
},
})
}
/>
</div>
</div>
</AdvancedOptionToggle>
)}
<div className="mt-4"> <div className="mt-4">
<AdvancedOptionToggle <AdvancedOptionToggle
isChecked={element.longAnswer !== false} isChecked={element.longAnswer !== false}
@@ -245,6 +142,16 @@ export const OpenElementForm = ({
customContainerClass="p-0" customContainerClass="p-0"
/> />
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.OpenText}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForOpenText) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</div> </div>
</form> </form>
); );
@@ -5,12 +5,14 @@ import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX } from "react"; import { type JSX } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum, TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForPictureSelection } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input"; import { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
@@ -170,6 +172,16 @@ export const PictureSelectionForm = ({
</div> </div>
</Label> </Label>
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.PictureSelection}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForPictureSelection) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -8,12 +8,14 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useRef, useState } from "react"; import { type JSX, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n"; import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyRankingElement } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum, TSurveyRankingElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForRanking } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice"; import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select"; import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
@@ -246,6 +248,16 @@ export const RankingElementForm = ({
</div> </div>
</div> </div>
</div> </div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Ranking}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForRanking) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -3,12 +3,14 @@
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react"; import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurveyRatingElement } from "@formbricks/types/surveys/elements"; import { TSurveyElementTypeEnum, TSurveyRatingElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TValidationRulesForRating } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input"; import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { Dropdown } from "@/modules/survey/editor/components/rating-type-dropdown"; import { Dropdown } from "@/modules/survey/editor/components/rating-type-dropdown";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
@@ -189,6 +191,16 @@ export const RatingElementForm = ({
customContainerClass="p-0 mt-4" customContainerClass="p-0 mt-4"
/> />
)} )}
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.Rating}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForRating) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</form> </form>
); );
}; };
@@ -0,0 +1,30 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon } from "lucide-react";
interface ValidationRuleItemProps {
id: string;
children: React.ReactNode;
}
export const ValidationRuleItem = ({ id, children }: ValidationRuleItemProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
position: isDragging ? "relative" : "static",
} as React.CSSProperties;
return (
<div ref={setNodeRef} style={style} className="flex w-full items-center gap-2">
<div {...attributes} {...listeners} className="cursor-move text-slate-400 hover:text-slate-600">
<GripVerticalIcon className="h-4 w-4" />
</div>
{children}
</div>
);
};
@@ -0,0 +1,261 @@
"use client";
import { DndContext, DragEndEvent, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv7 } from "uuid";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TValidationRule, TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
import { createRuleParams, getAvailableRuleTypes, getRuleValue } from "../lib/validation-rules-utils";
import { ValidationRuleItem } from "./validation-rule-item";
interface ValidationRulesEditorProps {
elementType: TSurveyElementTypeEnum;
validationRules: TValidationRule[];
onUpdateRules: (rules: TValidationRule[]) => void;
}
export const ValidationRulesEditor = ({
elementType,
validationRules,
onUpdateRules,
}: ValidationRulesEditorProps) => {
const { t } = useTranslation();
const ruleLabels: Record<string, string> = {
required: t("environments.surveys.edit.validation.required"),
min_length: t("environments.surveys.edit.validation.min_length"),
max_length: t("environments.surveys.edit.validation.max_length"),
pattern: t("environments.surveys.edit.validation.pattern"),
email: t("environments.surveys.edit.validation.email"),
url: t("environments.surveys.edit.validation.url"),
phone: t("environments.surveys.edit.validation.phone"),
min_value: t("environments.surveys.edit.validation.min_value"),
max_value: t("environments.surveys.edit.validation.max_value"),
min_selections: t("environments.surveys.edit.validation.min_selections"),
max_selections: t("environments.surveys.edit.validation.max_selections"),
characters: t("environments.surveys.edit.validation.characters"),
options_selected: t("environments.surveys.edit.validation.options_selected"),
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
const isEnabled = validationRules.length > 0;
const handleEnable = () => {
const availableRules = getAvailableRuleTypes(elementType, []);
if (availableRules.length > 0) {
const defaultRuleType = availableRules[0];
const config = RULE_TYPE_CONFIG[defaultRuleType];
let defaultValue: number | string | undefined = undefined;
if (config.needsValue && config.valueType === "text") {
defaultValue = "";
}
const newRule: TValidationRule = {
id: uuidv7(),
type: defaultRuleType,
params: createRuleParams(defaultRuleType, defaultValue),
} as TValidationRule;
onUpdateRules([newRule]);
}
};
const handleDisable = () => {
onUpdateRules([]);
};
const handleAddRule = (insertAfterIndex: number) => {
const availableRules = getAvailableRuleTypes(elementType, validationRules);
if (availableRules.length === 0) return;
const newRuleType = availableRules[0];
const config = RULE_TYPE_CONFIG[newRuleType];
let defaultValue: number | string | undefined = undefined;
if (config.needsValue && config.valueType === "text") {
defaultValue = "";
}
const newRule: TValidationRule = {
id: uuidv7(),
type: newRuleType,
params: createRuleParams(newRuleType, defaultValue),
} as TValidationRule;
const newRules = [...validationRules];
newRules.splice(insertAfterIndex + 1, 0, newRule);
onUpdateRules(newRules);
};
const handleDeleteRule = (ruleId: string) => {
const updated = validationRules.filter((r) => r.id !== ruleId);
onUpdateRules(updated);
};
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
return {
...rule,
type: newType,
params: createRuleParams(newType),
} as TValidationRule;
});
onUpdateRules(updated);
};
const handleRuleValueChange = (ruleId: string, value: string) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
const parsedValue = config.valueType === "number" ? Number(value) || 0 : value;
return {
...rule,
params: createRuleParams(ruleType, parsedValue),
} as TValidationRule;
});
onUpdateRules(updated);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = validationRules.findIndex((rule) => rule.id === active.id);
const newIndex = validationRules.findIndex((rule) => rule.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newRules = [...validationRules];
const [movedRule] = newRules.splice(oldIndex, 1);
newRules.splice(newIndex, 0, movedRule);
onUpdateRules(newRules);
}
};
const availableRulesForAdd = getAvailableRuleTypes(elementType, validationRules);
const canAddMore = availableRulesForAdd.length > 0;
return (
<AdvancedOptionToggle
isChecked={isEnabled}
onToggle={(checked) => (checked ? handleEnable() : handleDisable())}
htmlId="validation-rules-toggle"
title={t("environments.surveys.edit.validation_rules")}
description={t("environments.surveys.edit.validation_rules_description")}
customContainerClass="p-0 mt-4"
childrenContainerClass="flex-col p-3 gap-2">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={validationRules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
<div className="flex w-full flex-col gap-2">
{validationRules.map((rule, index) => {
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
const currentValue = getRuleValue(rule);
// Get available types for this rule (current type + unused types, no duplicates)
const otherAvailableTypes = getAvailableRuleTypes(
elementType,
validationRules.filter((r) => r.id !== rule.id)
).filter((t) => t !== ruleType);
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
return (
<ValidationRuleItem key={rule.id} id={rule.id}>
{/* Rule Type Selector */}
<Select
value={ruleType}
onValueChange={(value) => handleRuleTypeChange(rule.id, value as TValidationRuleType)}>
<SelectTrigger className={cn("bg-white", config.needsValue ? "w-[200px]" : "flex-1")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTypesForSelect.map((type) => (
<SelectItem key={type} value={type}>
{ruleLabels[RULE_TYPE_CONFIG[type].labelKey]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value Input (if needed) */}
{config.needsValue && (
<div className="flex w-full items-center gap-2">
<Input
type={config.valueType === "number" ? "number" : "text"}
value={currentValue ?? ""}
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
placeholder={config.valuePlaceholder}
className="h-9 min-w-[80px] bg-white"
min={config.valueType === "number" ? 0 : ""}
/>
{/* Unit selector (if applicable) */}
{config.unitOptions && config.unitOptions.length > 0 && (
<Select value={config.unitOptions[0].value}>
<SelectTrigger
className="flex-1 bg-white"
disabled={config.unitOptions.length === 1}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.unitOptions.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{ruleLabels[unit.labelKey]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* Delete button */}
<Button
variant="outline"
size="icon"
type="button"
onClick={() => handleDeleteRule(rule.id)}
className="shrink-0 bg-white">
<TrashIcon className="h-4 w-4" />
</Button>
{/* Add button */}
{canAddMore && (
<Button
variant="outline"
size="icon"
type="button"
onClick={() => handleAddRule(index)}
className="shrink-0 bg-white">
<PlusIcon className="h-4 w-4" />
</Button>
)}
</ValidationRuleItem>
);
})}
</div>
</SortableContext>
</DndContext>
</AdvancedOptionToggle>
);
};
@@ -0,0 +1,190 @@
import { describe, expect, test } from "vitest";
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
describe("RULE_TYPE_CONFIG", () => {
test("should have config for all validation rule types", () => {
const allRuleTypes: TValidationRuleType[] = [
"required",
"minLength",
"maxLength",
"pattern",
"email",
"url",
"phone",
"minValue",
"maxValue",
"minSelections",
"maxSelections",
];
allRuleTypes.forEach((ruleType) => {
expect(RULE_TYPE_CONFIG[ruleType]).toBeDefined();
expect(RULE_TYPE_CONFIG[ruleType].labelKey).toBeDefined();
expect(typeof RULE_TYPE_CONFIG[ruleType].labelKey).toBe("string");
expect(typeof RULE_TYPE_CONFIG[ruleType].needsValue).toBe("boolean");
});
});
describe("required rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.required;
expect(config.labelKey).toBe("required");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("minLength rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.minLength;
expect(config.labelKey).toBe("min_length");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("100");
expect(config.unitOptions).toEqual([{ value: "characters", labelKey: "characters" }]);
});
});
describe("maxLength rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.maxLength;
expect(config.labelKey).toBe("max_length");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("500");
expect(config.unitOptions).toEqual([{ value: "characters", labelKey: "characters" }]);
});
});
describe("pattern rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.pattern;
expect(config.labelKey).toBe("pattern");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("text");
expect(config.valuePlaceholder).toBe("^[A-Z].*");
expect(config.unitOptions).toBeUndefined();
});
});
describe("email rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.email;
expect(config.labelKey).toBe("email");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("url rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.url;
expect(config.labelKey).toBe("url");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("phone rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.phone;
expect(config.labelKey).toBe("phone");
expect(config.needsValue).toBe(false);
expect(config.valueType).toBeUndefined();
expect(config.valuePlaceholder).toBeUndefined();
expect(config.unitOptions).toBeUndefined();
});
});
describe("minValue rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.minValue;
expect(config.labelKey).toBe("min_value");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("0");
expect(config.unitOptions).toBeUndefined();
});
});
describe("maxValue rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.maxValue;
expect(config.labelKey).toBe("max_value");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("100");
expect(config.unitOptions).toBeUndefined();
});
});
describe("minSelections rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.minSelections;
expect(config.labelKey).toBe("min_selections");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("1");
expect(config.unitOptions).toEqual([{ value: "options", labelKey: "options_selected" }]);
});
});
describe("maxSelections rule", () => {
test("should have correct config", () => {
const config = RULE_TYPE_CONFIG.maxSelections;
expect(config.labelKey).toBe("max_selections");
expect(config.needsValue).toBe(true);
expect(config.valueType).toBe("number");
expect(config.valuePlaceholder).toBe("3");
expect(config.unitOptions).toEqual([{ value: "options", labelKey: "options_selected" }]);
});
});
describe("valueType validation", () => {
test("should have valueType 'number' for numeric rules", () => {
expect(RULE_TYPE_CONFIG.minLength.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.maxLength.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.minValue.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.maxValue.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.minSelections.valueType).toBe("number");
expect(RULE_TYPE_CONFIG.maxSelections.valueType).toBe("number");
});
test("should have valueType 'text' for text rules", () => {
expect(RULE_TYPE_CONFIG.pattern.valueType).toBe("text");
});
test("should not have valueType for rules that don't need values", () => {
expect(RULE_TYPE_CONFIG.required.valueType).toBeUndefined();
expect(RULE_TYPE_CONFIG.email.valueType).toBeUndefined();
expect(RULE_TYPE_CONFIG.url.valueType).toBeUndefined();
expect(RULE_TYPE_CONFIG.phone.valueType).toBeUndefined();
});
});
describe("unitOptions validation", () => {
test("should have unitOptions for length and selection rules", () => {
expect(RULE_TYPE_CONFIG.minLength.unitOptions).toBeDefined();
expect(RULE_TYPE_CONFIG.maxLength.unitOptions).toBeDefined();
expect(RULE_TYPE_CONFIG.minSelections.unitOptions).toBeDefined();
expect(RULE_TYPE_CONFIG.maxSelections.unitOptions).toBeDefined();
});
test("should not have unitOptions for other rules", () => {
expect(RULE_TYPE_CONFIG.required.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.pattern.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.email.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.url.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.phone.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.minValue.unitOptions).toBeUndefined();
expect(RULE_TYPE_CONFIG.maxValue.unitOptions).toBeUndefined();
});
});
});
@@ -0,0 +1,76 @@
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
// Rule type definitions with i18n keys
export const RULE_TYPE_CONFIG: Record<
TValidationRuleType,
{
labelKey: string;
needsValue: boolean;
valueType?: "number" | "text";
valuePlaceholder?: string;
unitOptions?: { value: string; labelKey: string }[];
}
> = {
required: {
labelKey: "required",
needsValue: false,
},
minLength: {
labelKey: "min_length",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
unitOptions: [{ value: "characters", labelKey: "characters" }],
},
maxLength: {
labelKey: "max_length",
needsValue: true,
valueType: "number",
valuePlaceholder: "500",
unitOptions: [{ value: "characters", labelKey: "characters" }],
},
pattern: {
labelKey: "pattern",
needsValue: true,
valueType: "text",
valuePlaceholder: "^[A-Z].*",
},
email: {
labelKey: "email",
needsValue: false,
},
url: {
labelKey: "url",
needsValue: false,
},
phone: {
labelKey: "phone",
needsValue: false,
},
minValue: {
labelKey: "min_value",
needsValue: true,
valueType: "number",
valuePlaceholder: "0",
},
maxValue: {
labelKey: "max_value",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
},
minSelections: {
labelKey: "min_selections",
needsValue: true,
valueType: "number",
valuePlaceholder: "1",
unitOptions: [{ value: "options", labelKey: "options_selected" }],
},
maxSelections: {
labelKey: "max_selections",
needsValue: true,
valueType: "number",
valuePlaceholder: "3",
unitOptions: [{ value: "options", labelKey: "options_selected" }],
},
};
@@ -0,0 +1,486 @@
import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
import { createRuleParams, getAvailableRuleTypes, getRuleValue } from "./validation-rules-utils";
describe("getAvailableRuleTypes", () => {
test("should return all applicable rules for openText element when no rules exist", () => {
const elementType = TSurveyElementTypeEnum.OpenText;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("required");
expect(available).toContain("minLength");
expect(available).toContain("maxLength");
expect(available).toContain("pattern");
expect(available).toContain("email");
expect(available).toContain("url");
expect(available).toContain("phone");
expect(available).toContain("minValue");
expect(available).toContain("maxValue");
});
test("should filter out already added rules", () => {
const elementType = TSurveyElementTypeEnum.OpenText;
const existingRules: TValidationRule[] = [
{
id: "rule1",
type: "required",
params: {},
},
{
id: "rule2",
type: "minLength",
params: { min: 10 },
},
];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).not.toContain("required");
expect(available).not.toContain("minLength");
expect(available).toContain("maxLength");
expect(available).toContain("pattern");
});
test("should return only required rule for multipleChoiceSingle element", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return empty array for multipleChoiceSingle when required is already added", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
const existingRules: TValidationRule[] = [
{
id: "rule1",
type: "required",
params: {},
},
];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual([]);
});
test("should return required, minSelections, maxSelections for multipleChoiceMulti element", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceMulti;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("required");
expect(available).toContain("minSelections");
expect(available).toContain("maxSelections");
expect(available.length).toBe(3);
});
test("should return only required rule for rating element", () => {
const elementType = TSurveyElementTypeEnum.Rating;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for nps element", () => {
const elementType = TSurveyElementTypeEnum.NPS;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for date element", () => {
const elementType = TSurveyElementTypeEnum.Date;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for consent element", () => {
const elementType = TSurveyElementTypeEnum.Consent;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for matrix element", () => {
const elementType = TSurveyElementTypeEnum.Matrix;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for ranking element", () => {
const elementType = TSurveyElementTypeEnum.Ranking;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for fileUpload element", () => {
const elementType = TSurveyElementTypeEnum.FileUpload;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return required, minSelections, maxSelections for pictureSelection element", () => {
const elementType = TSurveyElementTypeEnum.PictureSelection;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("required");
expect(available).toContain("minSelections");
expect(available).toContain("maxSelections");
expect(available.length).toBe(3);
});
test("should return only required rule for address element", () => {
const elementType = TSurveyElementTypeEnum.Address;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for contactInfo element", () => {
const elementType = TSurveyElementTypeEnum.ContactInfo;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return only required rule for cal element", () => {
const elementType = TSurveyElementTypeEnum.Cal;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["required"]);
});
test("should return empty array for cta element", () => {
const elementType = TSurveyElementTypeEnum.CTA;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual([]);
});
test("should handle unknown element type gracefully", () => {
const elementType = "unknown" as TSurveyElementTypeEnum;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual([]);
});
});
describe("getRuleValue", () => {
test("should return min value for minLength rule", () => {
const rule: TValidationRule = {
id: "rule1",
type: "minLength",
params: { min: 10 },
};
expect(getRuleValue(rule)).toBe(10);
});
test("should return max value for maxLength rule", () => {
const rule: TValidationRule = {
id: "rule2",
type: "maxLength",
params: { max: 100 },
};
expect(getRuleValue(rule)).toBe(100);
});
test("should return pattern string for pattern rule", () => {
const rule: TValidationRule = {
id: "rule3",
type: "pattern",
params: { pattern: "^[A-Z].*" },
};
expect(getRuleValue(rule)).toBe("^[A-Z].*");
});
test("should return pattern string with flags for pattern rule", () => {
const rule: TValidationRule = {
id: "rule3",
type: "pattern",
params: { pattern: "^[A-Z].*", flags: "i" },
};
expect(getRuleValue(rule)).toBe("^[A-Z].*");
});
test("should return min value for minValue rule", () => {
const rule: TValidationRule = {
id: "rule4",
type: "minValue",
params: { min: 5 },
};
expect(getRuleValue(rule)).toBe(5);
});
test("should return max value for maxValue rule", () => {
const rule: TValidationRule = {
id: "rule5",
type: "maxValue",
params: { max: 50 },
};
expect(getRuleValue(rule)).toBe(50);
});
test("should return min value for minSelections rule", () => {
const rule: TValidationRule = {
id: "rule6",
type: "minSelections",
params: { min: 2 },
};
expect(getRuleValue(rule)).toBe(2);
});
test("should return max value for maxSelections rule", () => {
const rule: TValidationRule = {
id: "rule7",
type: "maxSelections",
params: { max: 5 },
};
expect(getRuleValue(rule)).toBe(5);
});
test("should return undefined for required rule", () => {
const rule: TValidationRule = {
id: "rule8",
type: "required",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return undefined for email rule", () => {
const rule: TValidationRule = {
id: "rule9",
type: "email",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return undefined for url rule", () => {
const rule: TValidationRule = {
id: "rule10",
type: "url",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return undefined for phone rule", () => {
const rule: TValidationRule = {
id: "rule11",
type: "phone",
params: {},
};
expect(getRuleValue(rule)).toBeUndefined();
});
test("should return empty string for pattern rule with empty pattern", () => {
const rule: TValidationRule = {
id: "rule12",
type: "pattern",
params: { pattern: "" },
};
expect(getRuleValue(rule)).toBe("");
});
});
describe("createRuleParams", () => {
test("should create empty params for required rule", () => {
const params = createRuleParams("required");
expect(params).toEqual({});
});
test("should create params for minLength rule with value", () => {
const params = createRuleParams("minLength", 10);
expect(params).toEqual({ min: 10 });
});
test("should create params for minLength rule without value (defaults to 0)", () => {
const params = createRuleParams("minLength");
expect(params).toEqual({ min: 0 });
});
test("should create params for maxLength rule with value", () => {
const params = createRuleParams("maxLength", 100);
expect(params).toEqual({ max: 100 });
});
test("should create params for maxLength rule without value (defaults to 100)", () => {
const params = createRuleParams("maxLength");
expect(params).toEqual({ max: 100 });
});
test("should create params for pattern rule with string value", () => {
const params = createRuleParams("pattern", "^[A-Z].*");
expect(params).toEqual({ pattern: "^[A-Z].*" });
});
test("should create params for pattern rule without value (defaults to empty string)", () => {
const params = createRuleParams("pattern");
expect(params).toEqual({ pattern: "" });
});
test("should create empty params for email rule", () => {
const params = createRuleParams("email");
expect(params).toEqual({});
});
test("should create empty params for url rule", () => {
const params = createRuleParams("url");
expect(params).toEqual({});
});
test("should create empty params for phone rule", () => {
const params = createRuleParams("phone");
expect(params).toEqual({});
});
test("should create params for minValue rule with value", () => {
const params = createRuleParams("minValue", 5);
expect(params).toEqual({ min: 5 });
});
test("should create params for minValue rule without value (defaults to 0)", () => {
const params = createRuleParams("minValue");
expect(params).toEqual({ min: 0 });
});
test("should create params for maxValue rule with value", () => {
const params = createRuleParams("maxValue", 50);
expect(params).toEqual({ max: 50 });
});
test("should create params for maxValue rule without value (defaults to 100)", () => {
const params = createRuleParams("maxValue");
expect(params).toEqual({ max: 100 });
});
test("should create params for minSelections rule with value", () => {
const params = createRuleParams("minSelections", 2);
expect(params).toEqual({ min: 2 });
});
test("should create params for minSelections rule without value (defaults to 1)", () => {
const params = createRuleParams("minSelections");
expect(params).toEqual({ min: 1 });
});
test("should create params for maxSelections rule with value", () => {
const params = createRuleParams("maxSelections", 5);
expect(params).toEqual({ max: 5 });
});
test("should create params for maxSelections rule without value (defaults to 3)", () => {
const params = createRuleParams("maxSelections");
expect(params).toEqual({ max: 3 });
});
test("should convert string number to number for minLength", () => {
const params = createRuleParams("minLength", "10");
expect(params).toEqual({ min: 10 });
});
test("should convert string number to number for maxLength", () => {
const params = createRuleParams("maxLength", "100");
expect(params).toEqual({ max: 100 });
});
test("should convert string number to number for minValue", () => {
const params = createRuleParams("minValue", "5");
expect(params).toEqual({ min: 5 });
});
test("should convert string number to number for maxValue", () => {
const params = createRuleParams("maxValue", "50");
expect(params).toEqual({ max: 50 });
});
test("should convert string number to number for minSelections", () => {
const params = createRuleParams("minSelections", "2");
expect(params).toEqual({ min: 2 });
});
test("should convert string number to number for maxSelections", () => {
const params = createRuleParams("maxSelections", "5");
expect(params).toEqual({ max: 5 });
});
test("should handle invalid string number (defaults to 0 for minLength)", () => {
const params = createRuleParams("minLength", "invalid");
expect(params).toEqual({ min: 0 });
});
test("should handle invalid string number (defaults to 100 for maxLength)", () => {
const params = createRuleParams("maxLength", "invalid");
expect(params).toEqual({ max: 100 });
});
test("should handle invalid string number (defaults to 0 for minValue)", () => {
const params = createRuleParams("minValue", "invalid");
expect(params).toEqual({ min: 0 });
});
test("should handle invalid string number (defaults to 100 for maxValue)", () => {
const params = createRuleParams("maxValue", "invalid");
expect(params).toEqual({ max: 100 });
});
test("should handle invalid string number (defaults to 1 for minSelections)", () => {
const params = createRuleParams("minSelections", "invalid");
expect(params).toEqual({ min: 1 });
});
test("should handle invalid string number (defaults to 3 for maxSelections)", () => {
const params = createRuleParams("maxSelections", "invalid");
expect(params).toEqual({ max: 3 });
});
});
@@ -0,0 +1,74 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
APPLICABLE_RULES,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
/**
* Get available rule types for an element type, excluding already added rules
*/
export const getAvailableRuleTypes = (
elementType: TSurveyElementTypeEnum,
existingRules: TValidationRule[]
): TValidationRuleType[] => {
const elementTypeKey = elementType.toString();
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
// Filter out rules that are already added (for non-repeatable rules)
const existingTypes = new Set(existingRules.map((r) => r.type));
return applicable.filter((ruleType) => {
// Allow only one of each rule type
return !existingTypes.has(ruleType);
});
};
/**
* Get the value from rule params based on rule type
*/
export const getRuleValue = (rule: TValidationRule): number | string | undefined => {
const params = rule.params as Record<string, unknown>;
if ("min" in params) return params.min as number;
if ("max" in params) return params.max as number;
if ("pattern" in params) {
const pattern = params.pattern as string;
return pattern ?? "";
}
return undefined;
};
/**
* Create params object from rule type and value (without type field)
*/
export const createRuleParams = (
ruleType: TValidationRuleType,
value?: number | string
): TValidationRule["params"] => {
switch (ruleType) {
case "required":
return {};
case "minLength":
return { min: Number(value) || 0 };
case "maxLength":
return { max: Number(value) || 100 };
case "pattern":
return { pattern: value === undefined || value === null ? "" : String(value) };
case "email":
return {};
case "url":
return {};
case "phone":
return {};
case "minValue":
return { min: Number(value) || 0 };
case "maxValue":
return { max: Number(value) || 100 };
case "minSelections":
return { min: Number(value) || 1 };
case "maxSelections":
return { max: Number(value) || 3 };
default:
return {};
}
};
@@ -29,6 +29,7 @@ export function AddressElement({
}: Readonly<AddressElementProps>) { }: Readonly<AddressElementProps>) {
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -117,7 +118,7 @@ export function AddressElement({
fields={formFields} fields={formFields}
value={convertToValueObject(value)} value={convertToValueObject(value)}
onChange={handleChange} onChange={handleChange}
required={element.required} required={isRequired}
dir={dir} dir={dir}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
videoUrl={element.videoUrl} videoUrl={element.videoUrl}
@@ -32,6 +32,7 @@ export function CalElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl; const isMediaAvailable = element.imageUrl || element.videoUrl;
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId); useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const onSuccessfulBooking = useCallback(() => { const onSuccessfulBooking = useCallback(() => {
@@ -46,7 +47,7 @@ export function CalElement({
key={element.id} key={element.id}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (element.required && !value) { if (isRequired && !value) {
setErrorMessage(t("errors.please_book_an_appointment")); setErrorMessage(t("errors.please_book_an_appointment"));
return; return;
} }
@@ -62,7 +63,7 @@ export function CalElement({
<Headline <Headline
headline={getLocalizedValue(element.headline, languageCode)} headline={getLocalizedValue(element.headline, languageCode)}
elementId={element.id} elementId={element.id}
required={element.required} validationRules={element.validationRules}
/> />
<Subheader <Subheader
subheader={element.subheader ? getLocalizedValue(element.subheader, languageCode) : ""} subheader={element.subheader ? getLocalizedValue(element.subheader, languageCode) : ""}
@@ -31,6 +31,7 @@ export function ConsentElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -40,7 +41,7 @@ export function ConsentElement({
}; };
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required && value !== "accepted") { if (isRequired && value !== "accepted") {
setErrorMessage(t("errors.please_fill_out_this_field")); setErrorMessage(t("errors.please_fill_out_this_field"));
return false; return false;
} }
@@ -65,7 +66,7 @@ export function ConsentElement({
checkboxLabel={getLocalizedValue(element.label, languageCode)} checkboxLabel={getLocalizedValue(element.label, languageCode)}
value={value === "accepted"} value={value === "accepted"}
onChange={handleChange} onChange={handleChange}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
dir={dir} dir={dir}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
@@ -30,6 +30,7 @@ export function ContactInfoElement({
}: Readonly<ContactInfoElementProps>) { }: Readonly<ContactInfoElementProps>) {
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -113,7 +114,7 @@ export function ContactInfoElement({
fields={formFields} fields={formFields}
value={convertToValueObject(value)} value={convertToValueObject(value)}
onChange={handleChange} onChange={handleChange}
required={element.required} required={isRequired}
dir={dir} dir={dir}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
videoUrl={element.videoUrl} videoUrl={element.videoUrl}
@@ -30,6 +30,7 @@ export function DateElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -41,7 +42,7 @@ export function DateElement({
}; };
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required && (!value || value.trim() === "")) { if (isRequired && (!value || value.trim() === "")) {
setErrorMessage(t("errors.please_select_a_date")); setErrorMessage(t("errors.please_select_a_date"));
return false; return false;
} }
@@ -75,7 +76,7 @@ export function DateElement({
onChange={handleChange} onChange={handleChange}
minDate={getMinDate()} minDate={getMinDate()}
maxDate={getMaxDate()} maxDate={getMaxDate()}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
locale={languageCode} locale={languageCode}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
@@ -37,6 +37,7 @@ export function FileUploadElement({
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -326,7 +327,7 @@ export function FileUploadElement({
); );
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required && (!value || value.length === 0)) { if (isRequired && (!value || value.length === 0)) {
setErrorMessage(t("errors.please_upload_a_file")); setErrorMessage(t("errors.please_upload_a_file"));
return false; return false;
} }
@@ -353,7 +354,7 @@ export function FileUploadElement({
onFileSelect={handleFileSelect} onFileSelect={handleFileSelect}
allowMultiple={element.allowMultipleFiles} allowMultiple={element.allowMultipleFiles}
allowedFileExtensions={element.allowedFileExtensions} allowedFileExtensions={element.allowedFileExtensions}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
isUploading={isUploading} isUploading={isUploading}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
@@ -29,6 +29,7 @@ export function MatrixElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -121,7 +122,7 @@ export function MatrixElement({
}; };
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required) { if (isRequired) {
const hasUnansweredRows = rows.some((row) => !value[row.label]); const hasUnansweredRows = rows.some((row) => !value[row.label]);
if (hasUnansweredRows) { if (hasUnansweredRows) {
setErrorMessage(t("errors.please_select_an_option")); setErrorMessage(t("errors.please_select_an_option"));
@@ -150,7 +151,7 @@ export function MatrixElement({
columns={columns} columns={columns}
value={convertValueToIds(value)} value={convertValueToIds(value)}
onChange={handleChange} onChange={handleChange}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
videoUrl={element.videoUrl} videoUrl={element.videoUrl}
@@ -33,6 +33,7 @@ export function MultipleChoiceMultiElement({
const [otherValue, setOtherValue] = useState(""); const [otherValue, setOtherValue] = useState("");
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
const { t } = useTranslation(); const { t } = useTranslation();
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -174,11 +175,11 @@ export function MultipleChoiceMultiElement({
}; };
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required && (!Array.isArray(value) || value.length === 0)) { if (isRequired && (!Array.isArray(value) || value.length === 0)) {
setErrorMessage(t("errors.please_select_an_option")); setErrorMessage(t("errors.please_select_an_option"));
return false; return false;
} }
if (element.required && isOtherSelected && !otherValue.trim()) { if (isRequired && isOtherSelected && !otherValue.trim()) {
setErrorMessage(t("errors.please_fill_out_this_field")); setErrorMessage(t("errors.please_fill_out_this_field"));
return false; return false;
} }
@@ -262,7 +263,7 @@ export function MultipleChoiceMultiElement({
options={allOptions} options={allOptions}
value={selectedValues} value={selectedValues}
onChange={handleMultiSelectChange} onChange={handleMultiSelectChange}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
dir={dir} dir={dir}
otherOptionId={otherOption?.id} otherOptionId={otherOption?.id}
@@ -33,6 +33,7 @@ export function MultipleChoiceSingleElement({
const [otherValue, setOtherValue] = useState(""); const [otherValue, setOtherValue] = useState("");
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -154,12 +155,12 @@ export function MultipleChoiceSingleElement({
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
// Check if nothing is selected // Check if nothing is selected
if (element.required && selectedValue === undefined) { if (isRequired && selectedValue === undefined) {
setErrorMessage(t("errors.please_select_an_option")); setErrorMessage(t("errors.please_select_an_option"));
return false; return false;
} }
// Check if "other" is selected but not filled // Check if "other" is selected but not filled
if (element.required && isOtherSelected && !otherValue.trim()) { if (isRequired && isOtherSelected && !otherValue.trim()) {
setErrorMessage(t("errors.please_fill_out_this_field")); setErrorMessage(t("errors.please_fill_out_this_field"));
return false; return false;
} }
@@ -184,7 +185,7 @@ export function MultipleChoiceSingleElement({
options={allOptions} options={allOptions}
value={selectedValue} value={selectedValue}
onChange={handleChange} onChange={handleChange}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
dir={dir} dir={dir}
otherOptionId={otherOption?.id} otherOptionId={otherOption?.id}
@@ -32,6 +32,7 @@ export function NPSElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -43,7 +44,7 @@ export function NPSElement({
}; };
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required && value === undefined) { if (isRequired && value === undefined) {
setErrorMessage(t("errors.please_select_an_option")); setErrorMessage(t("errors.please_select_an_option"));
return false; return false;
} }
@@ -70,7 +71,7 @@ export function NPSElement({
lowerLabel={getLocalizedValue(element.lowerLabel, languageCode)} lowerLabel={getLocalizedValue(element.lowerLabel, languageCode)}
upperLabel={getLocalizedValue(element.upperLabel, languageCode)} upperLabel={getLocalizedValue(element.upperLabel, languageCode)}
colorCoding={element.isColorCodingEnabled} colorCoding={element.isColorCodingEnabled}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
dir={dir} dir={dir}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
@@ -33,6 +33,7 @@ export function OpenTextElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -43,7 +44,7 @@ export function OpenTextElement({
}; };
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required && (!value || value.trim() === "")) { if (isRequired && (!value || value.trim() === "")) {
setErrorMessage(t("errors.please_fill_out_this_field")); setErrorMessage(t("errors.please_fill_out_this_field"));
return false; return false;
} }
@@ -122,7 +123,7 @@ export function OpenTextElement({
placeholder={getLocalizedValue(element.placeholder, languageCode)} placeholder={getLocalizedValue(element.placeholder, languageCode)}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
required={element.required} required={isRequired}
longAnswer={element.longAnswer !== false} longAnswer={element.longAnswer !== false}
inputType={getInputType()} inputType={getInputType()}
charLimit={element.inputType === "text" ? element.charLimit : undefined} charLimit={element.inputType === "text" ? element.charLimit : undefined}
@@ -32,6 +32,7 @@ export function PictureSelectionElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
// Convert choices to PictureSelectOption format // Convert choices to PictureSelectOption format
@@ -66,7 +67,7 @@ export function PictureSelectionElement({
const handleSubmit = (e: Event) => { const handleSubmit = (e: Event) => {
e.preventDefault(); e.preventDefault();
if (element.required) { if (isRequired) {
if (element.allowMulti) { if (element.allowMulti) {
if (!currentValue || !Array.isArray(currentValue) || currentValue.length === 0) { if (!currentValue || !Array.isArray(currentValue) || currentValue.length === 0) {
setErrorMessage(t("errors.please_select_an_option")); setErrorMessage(t("errors.please_select_an_option"));
@@ -94,7 +95,7 @@ export function PictureSelectionElement({
value={currentValue} value={currentValue}
onChange={handleChange} onChange={handleChange}
allowMulti={element.allowMulti} allowMulti={element.allowMulti}
required={element.required} required={isRequired}
dir={dir} dir={dir}
errorMessage={errorMessage} errorMessage={errorMessage}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
@@ -31,6 +31,7 @@ export function RankingElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -109,7 +110,7 @@ export function RankingElement({
const isValueArray = Array.isArray(value); const isValueArray = Array.isArray(value);
const allItemsRanked = isValueArray && value.length === element.choices.length; const allItemsRanked = isValueArray && value.length === element.choices.length;
if ((element.required && !allItemsRanked) || (!element.required && value.length > 0 && !allItemsRanked)) { if ((isRequired && !allItemsRanked) || (!isRequired && value.length > 0 && !allItemsRanked)) {
setErrorMessage(t("errors.please_rank_all_items_before_submitting")); setErrorMessage(t("errors.please_rank_all_items_before_submitting"));
return false; return false;
} }
@@ -135,7 +136,7 @@ export function RankingElement({
options={options} options={options}
value={selectedValues} value={selectedValues}
onChange={handleChange} onChange={handleChange}
required={element.required} required={isRequired}
errorMessage={errorMessage} errorMessage={errorMessage}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
videoUrl={element.videoUrl} videoUrl={element.videoUrl}
@@ -30,6 +30,7 @@ export function RatingElement({
const [startTime, setStartTime] = useState(performance.now()); const [startTime, setStartTime] = useState(performance.now());
const isCurrent = element.id === currentElementId; const isCurrent = element.id === currentElementId;
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined); const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isRequired = element.validationRules?.some((rule) => rule.type === "required") ?? false;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -41,7 +42,7 @@ export function RatingElement({
}; };
const validateRequired = (): boolean => { const validateRequired = (): boolean => {
if (element.required && !value) { if (isRequired && !value) {
setErrorMessage(t("errors.please_select_an_option")); setErrorMessage(t("errors.please_select_an_option"));
return false; return false;
} }
@@ -69,7 +70,7 @@ export function RatingElement({
lowerLabel={getLocalizedValue(element.lowerLabel, languageCode)} lowerLabel={getLocalizedValue(element.lowerLabel, languageCode)}
upperLabel={getLocalizedValue(element.upperLabel, languageCode)} upperLabel={getLocalizedValue(element.upperLabel, languageCode)}
colorCoding={element.isColorCodingEnabled} colorCoding={element.isColorCodingEnabled}
required={element.required} required={isRequired}
dir={dir} dir={dir}
imageUrl={element.imageUrl} imageUrl={element.imageUrl}
videoUrl={element.videoUrl} videoUrl={element.videoUrl}
@@ -1,15 +1,22 @@
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils"; import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
interface HeadlineProps { interface HeadlineProps {
headline: string; headline: string;
elementId: string; elementId: string;
required?: boolean; validationRules?: TValidationRule[];
alignTextCenter?: boolean; alignTextCenter?: boolean;
} }
export function Headline({ headline, elementId, required = true, alignTextCenter = false }: HeadlineProps) { export function Headline({
headline,
elementId,
validationRules,
alignTextCenter = false,
}: Readonly<HeadlineProps>) {
const hasRequiredRule = validationRules?.some((rule) => rule.type === "required") ?? false;
const { t } = useTranslation(); const { t } = useTranslation();
const isQuestionCard = elementId !== "EndingCard" && elementId !== "welcomeCard"; const isQuestionCard = elementId !== "EndingCard" && elementId !== "welcomeCard";
// Strip inline styles BEFORE parsing to avoid CSP violations // Strip inline styles BEFORE parsing to avoid CSP violations
@@ -18,14 +25,14 @@ export function Headline({ headline, elementId, required = true, alignTextCenter
const safeHtml = const safeHtml =
isHeadlineHtml && strippedHeadline isHeadlineHtml && strippedHeadline
? DOMPurify.sanitize(strippedHeadline, { ? DOMPurify.sanitize(strippedHeadline, {
ADD_ATTR: ["target"], ADD_ATTR: ["target"],
FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles
}) })
: ""; : "";
return ( return (
<label htmlFor={elementId} className="text-heading mb-[3px] flex flex-col"> <label htmlFor={elementId} className="text-heading mb-[3px] flex flex-col">
{required && isQuestionCard && ( {hasRequiredRule && isQuestionCard && (
<span <span
className="mb-[3px] text-xs leading-6 font-normal opacity-60" className="mb-[3px] text-xs leading-6 font-normal opacity-60"
tabIndex={-1} tabIndex={-1}
+52 -6
View File
@@ -3,6 +3,22 @@ import { ZUrl } from "../common";
import { ZI18nString } from "../i18n"; import { ZI18nString } from "../i18n";
import { ZAllowedFileExtension } from "../storage"; import { ZAllowedFileExtension } from "../storage";
import { FORBIDDEN_IDS } from "./validation"; import { FORBIDDEN_IDS } from "./validation";
import {
ZValidationRulesForAddress,
ZValidationRulesForCal,
ZValidationRulesForConsent,
ZValidationRulesForContactInfo,
ZValidationRulesForDate,
ZValidationRulesForFileUpload,
ZValidationRulesForMatrix,
ZValidationRulesForMultipleChoiceMulti,
ZValidationRulesForMultipleChoiceSingle,
ZValidationRulesForNPS,
ZValidationRulesForOpenText,
ZValidationRulesForPictureSelection,
ZValidationRulesForRanking,
ZValidationRulesForRating,
} from "./validation-rules";
// Element Type Enum (same as question types) // Element Type Enum (same as question types)
export enum TSurveyElementTypeEnum { export enum TSurveyElementTypeEnum {
@@ -50,6 +66,7 @@ export const ZSurveyElementId = z.string().superRefine((id, ctx) => {
export type TSurveyElementId = z.infer<typeof ZSurveyElementId>; export type TSurveyElementId = z.infer<typeof ZSurveyElementId>;
// Base element (like ZSurveyQuestionBase but WITHOUT logic, buttonLabel, backButtonLabel) // Base element (like ZSurveyQuestionBase but WITHOUT logic, buttonLabel, backButtonLabel)
// Note: validationRules is not included in base - each element type will add its own narrowed schema
export const ZSurveyElementBase = z.object({ export const ZSurveyElementBase = z.object({
id: ZSurveyElementId, id: ZSurveyElementId,
type: z.nativeEnum(TSurveyElementTypeEnum), type: z.nativeEnum(TSurveyElementTypeEnum),
@@ -80,6 +97,7 @@ export const ZSurveyOpenTextElement = ZSurveyElementBase.extend({
max: z.number().optional(), max: z.number().optional(),
}) })
.default({ enabled: false }), .default({ enabled: false }),
validationRules: ZValidationRulesForOpenText.optional(),
}).superRefine((data, ctx) => { }).superRefine((data, ctx) => {
if (data.charLimit.enabled && data.charLimit.min === undefined && data.charLimit.max === undefined) { if (data.charLimit.enabled && data.charLimit.min === undefined && data.charLimit.max === undefined) {
ctx.addIssue({ ctx.addIssue({
@@ -116,6 +134,7 @@ export type TSurveyOpenTextElement = z.infer<typeof ZSurveyOpenTextElement>;
export const ZSurveyConsentElement = ZSurveyElementBase.extend({ export const ZSurveyConsentElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Consent), type: z.literal(TSurveyElementTypeEnum.Consent),
label: ZI18nString, label: ZI18nString,
validationRules: ZValidationRulesForConsent.optional(),
}); });
export type TSurveyConsentElement = z.infer<typeof ZSurveyConsentElement>; export type TSurveyConsentElement = z.infer<typeof ZSurveyConsentElement>;
@@ -131,18 +150,34 @@ export type TSurveyElementChoice = z.infer<typeof ZSurveyElementChoice>;
export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]); export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]);
export type TShuffleOption = z.infer<typeof ZShuffleOption>; export type TShuffleOption = z.infer<typeof ZShuffleOption>;
export const ZSurveyMultipleChoiceElement = ZSurveyElementBase.extend({ // Multiple Choice Single Element
type: z.union([ export const ZSurveyMultipleChoiceSingleElement = ZSurveyElementBase.extend({
z.literal(TSurveyElementTypeEnum.MultipleChoiceSingle), type: z.literal(TSurveyElementTypeEnum.MultipleChoiceSingle),
z.literal(TSurveyElementTypeEnum.MultipleChoiceMulti),
]),
choices: z choices: z
.array(ZSurveyElementChoice) .array(ZSurveyElementChoice)
.min(2, { message: "Multiple Choice Element must have at least two choices" }), .min(2, { message: "Multiple Choice Element must have at least two choices" }),
shuffleOption: ZShuffleOption.optional(), shuffleOption: ZShuffleOption.optional(),
otherOptionPlaceholder: ZI18nString.optional(), otherOptionPlaceholder: ZI18nString.optional(),
validationRules: ZValidationRulesForMultipleChoiceSingle.optional(),
}); });
// Multiple Choice Multi Element
export const ZSurveyMultipleChoiceMultiElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.MultipleChoiceMulti),
choices: z
.array(ZSurveyElementChoice)
.min(2, { message: "Multiple Choice Element must have at least two choices" }),
shuffleOption: ZShuffleOption.optional(),
otherOptionPlaceholder: ZI18nString.optional(),
validationRules: ZValidationRulesForMultipleChoiceMulti.optional(),
});
// Union type for Multiple Choice Elements
export const ZSurveyMultipleChoiceElement = z.union([
ZSurveyMultipleChoiceSingleElement,
ZSurveyMultipleChoiceMultiElement,
]);
export type TSurveyMultipleChoiceElement = z.infer<typeof ZSurveyMultipleChoiceElement>; export type TSurveyMultipleChoiceElement = z.infer<typeof ZSurveyMultipleChoiceElement>;
// NPS Element // NPS Element
@@ -151,6 +186,7 @@ export const ZSurveyNPSElement = ZSurveyElementBase.extend({
lowerLabel: ZI18nString.optional(), lowerLabel: ZI18nString.optional(),
upperLabel: ZI18nString.optional(), upperLabel: ZI18nString.optional(),
isColorCodingEnabled: z.boolean().optional().default(false), isColorCodingEnabled: z.boolean().optional().default(false),
validationRules: ZValidationRulesForNPS.optional(),
}); });
export type TSurveyNPSElement = z.infer<typeof ZSurveyNPSElement>; export type TSurveyNPSElement = z.infer<typeof ZSurveyNPSElement>;
@@ -202,6 +238,7 @@ export const ZSurveyRatingElement = ZSurveyElementBase.extend({
lowerLabel: ZI18nString.optional(), lowerLabel: ZI18nString.optional(),
upperLabel: ZI18nString.optional(), upperLabel: ZI18nString.optional(),
isColorCodingEnabled: z.boolean().optional().default(false), isColorCodingEnabled: z.boolean().optional().default(false),
validationRules: ZValidationRulesForRating.optional(),
}); });
export type TSurveyRatingElement = z.infer<typeof ZSurveyRatingElement>; export type TSurveyRatingElement = z.infer<typeof ZSurveyRatingElement>;
@@ -220,6 +257,7 @@ export const ZSurveyPictureSelectionElement = ZSurveyElementBase.extend({
choices: z choices: z
.array(ZSurveyPictureChoice) .array(ZSurveyPictureChoice)
.min(2, { message: "Picture Selection element must have a minimum of 2 choices" }), .min(2, { message: "Picture Selection element must have a minimum of 2 choices" }),
validationRules: ZValidationRulesForPictureSelection.optional(),
}); });
export type TSurveyPictureSelectionElement = z.infer<typeof ZSurveyPictureSelectionElement>; export type TSurveyPictureSelectionElement = z.infer<typeof ZSurveyPictureSelectionElement>;
@@ -229,6 +267,7 @@ export const ZSurveyDateElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Date), type: z.literal(TSurveyElementTypeEnum.Date),
html: ZI18nString.optional(), html: ZI18nString.optional(),
format: z.enum(["M-d-y", "d-M-y", "y-M-d"]), format: z.enum(["M-d-y", "d-M-y", "y-M-d"]),
validationRules: ZValidationRulesForDate.optional(),
}); });
export type TSurveyDateElement = z.infer<typeof ZSurveyDateElement>; export type TSurveyDateElement = z.infer<typeof ZSurveyDateElement>;
@@ -239,6 +278,7 @@ export const ZSurveyFileUploadElement = ZSurveyElementBase.extend({
allowMultipleFiles: z.boolean(), allowMultipleFiles: z.boolean(),
maxSizeInMB: z.number().optional(), maxSizeInMB: z.number().optional(),
allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), allowedFileExtensions: z.array(ZAllowedFileExtension).optional(),
validationRules: ZValidationRulesForFileUpload.optional(),
}); });
export type TSurveyFileUploadElement = z.infer<typeof ZSurveyFileUploadElement>; export type TSurveyFileUploadElement = z.infer<typeof ZSurveyFileUploadElement>;
@@ -248,6 +288,7 @@ export const ZSurveyCalElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Cal), type: z.literal(TSurveyElementTypeEnum.Cal),
calUserName: z.string().min(1, { message: "Cal user name is required" }), calUserName: z.string().min(1, { message: "Cal user name is required" }),
calHost: z.string().optional(), calHost: z.string().optional(),
validationRules: ZValidationRulesForCal.optional(),
}); });
export type TSurveyCalElement = z.infer<typeof ZSurveyCalElement>; export type TSurveyCalElement = z.infer<typeof ZSurveyCalElement>;
@@ -265,6 +306,7 @@ export const ZSurveyMatrixElement = ZSurveyElementBase.extend({
rows: z.array(ZSurveyMatrixElementChoice), rows: z.array(ZSurveyMatrixElementChoice),
columns: z.array(ZSurveyMatrixElementChoice), columns: z.array(ZSurveyMatrixElementChoice),
shuffleOption: ZShuffleOption.optional().default("none"), shuffleOption: ZShuffleOption.optional().default("none"),
validationRules: ZValidationRulesForMatrix.optional(),
}); });
export type TSurveyMatrixElement = z.infer<typeof ZSurveyMatrixElement>; export type TSurveyMatrixElement = z.infer<typeof ZSurveyMatrixElement>;
@@ -286,6 +328,7 @@ export const ZSurveyAddressElement = ZSurveyElementBase.extend({
state: ZToggleInputConfig, state: ZToggleInputConfig,
zip: ZToggleInputConfig, zip: ZToggleInputConfig,
country: ZToggleInputConfig, country: ZToggleInputConfig,
validationRules: ZValidationRulesForAddress.optional(),
}); });
export type TSurveyAddressElement = z.infer<typeof ZSurveyAddressElement>; export type TSurveyAddressElement = z.infer<typeof ZSurveyAddressElement>;
@@ -299,6 +342,7 @@ export const ZSurveyRankingElement = ZSurveyElementBase.extend({
.max(25, { message: "Ranking Element can have at most 25 options" }), .max(25, { message: "Ranking Element can have at most 25 options" }),
otherOptionPlaceholder: ZI18nString.optional(), otherOptionPlaceholder: ZI18nString.optional(),
shuffleOption: ZShuffleOption.optional(), shuffleOption: ZShuffleOption.optional(),
validationRules: ZValidationRulesForRanking.optional(),
}); });
export type TSurveyRankingElement = z.infer<typeof ZSurveyRankingElement>; export type TSurveyRankingElement = z.infer<typeof ZSurveyRankingElement>;
@@ -311,6 +355,7 @@ export const ZSurveyContactInfoElement = ZSurveyElementBase.extend({
email: ZToggleInputConfig, email: ZToggleInputConfig,
phone: ZToggleInputConfig, phone: ZToggleInputConfig,
company: ZToggleInputConfig, company: ZToggleInputConfig,
validationRules: ZValidationRulesForContactInfo.optional(),
}); });
export type TSurveyContactInfoElement = z.infer<typeof ZSurveyContactInfoElement>; export type TSurveyContactInfoElement = z.infer<typeof ZSurveyContactInfoElement>;
@@ -319,7 +364,8 @@ export type TSurveyContactInfoElement = z.infer<typeof ZSurveyContactInfoElement
export const ZSurveyElement = z.union([ export const ZSurveyElement = z.union([
ZSurveyOpenTextElement, ZSurveyOpenTextElement,
ZSurveyConsentElement, ZSurveyConsentElement,
ZSurveyMultipleChoiceElement, ZSurveyMultipleChoiceSingleElement,
ZSurveyMultipleChoiceMultiElement,
ZSurveyNPSElement, ZSurveyNPSElement,
ZSurveyCTAElement, ZSurveyCTAElement,
ZSurveyRatingElement, ZSurveyRatingElement,
+460
View File
@@ -0,0 +1,460 @@
import { z } from "zod";
import { ZI18nString } from "../i18n";
// Validation rule type enum - extensible for future rule types
export const ZValidationRuleType = z.enum([
// Universal rules
"required",
// Text/OpenText rules
"minLength",
"maxLength",
"pattern",
"email",
"url",
"phone",
// Numeric rules (for OpenText inputType=number)
"minValue",
"maxValue",
// Selection rules (MultiSelect, PictureSelection)
"minSelections",
"maxSelections",
]);
export type TValidationRuleType = z.infer<typeof ZValidationRuleType>;
// Rule params - union for type-safe params per rule type (type is now at rule level)
export const ZValidationRuleParamsRequired = z.object({});
export const ZValidationRuleParamsMinLength = z.object({
min: z.number().min(0),
});
export const ZValidationRuleParamsMaxLength = z.object({
max: z.number().min(1),
});
export const ZValidationRuleParamsPattern = z.object({
pattern: z.string().min(1),
flags: z.string().optional(),
});
export const ZValidationRuleParamsEmail = z.object({});
export const ZValidationRuleParamsUrl = z.object({});
export const ZValidationRuleParamsPhone = z.object({});
export const ZValidationRuleParamsMinValue = z.object({
min: z.number(),
});
export const ZValidationRuleParamsMaxValue = z.object({
max: z.number(),
});
export const ZValidationRuleParamsMinSelections = z.object({
min: z.number().min(1),
});
export const ZValidationRuleParamsMaxSelections = z.object({
max: z.number().min(1),
});
// Union of all params types
export const ZValidationRuleParams = z.union([
ZValidationRuleParamsRequired,
ZValidationRuleParamsMinLength,
ZValidationRuleParamsMaxLength,
ZValidationRuleParamsPattern,
ZValidationRuleParamsEmail,
ZValidationRuleParamsUrl,
ZValidationRuleParamsPhone,
ZValidationRuleParamsMinValue,
ZValidationRuleParamsMaxValue,
ZValidationRuleParamsMinSelections,
ZValidationRuleParamsMaxSelections,
]);
export type TValidationRuleParams = z.infer<typeof ZValidationRuleParams>;
// Extract specific param types for validators
export type TValidationRuleParamsRequired = z.infer<typeof ZValidationRuleParamsRequired>;
export type TValidationRuleParamsMinLength = z.infer<typeof ZValidationRuleParamsMinLength>;
export type TValidationRuleParamsMaxLength = z.infer<typeof ZValidationRuleParamsMaxLength>;
export type TValidationRuleParamsPattern = z.infer<typeof ZValidationRuleParamsPattern>;
export type TValidationRuleParamsEmail = z.infer<typeof ZValidationRuleParamsEmail>;
export type TValidationRuleParamsUrl = z.infer<typeof ZValidationRuleParamsUrl>;
export type TValidationRuleParamsPhone = z.infer<typeof ZValidationRuleParamsPhone>;
export type TValidationRuleParamsMinValue = z.infer<typeof ZValidationRuleParamsMinValue>;
export type TValidationRuleParamsMaxValue = z.infer<typeof ZValidationRuleParamsMaxValue>;
export type TValidationRuleParamsMinSelections = z.infer<typeof ZValidationRuleParamsMinSelections>;
export type TValidationRuleParamsMaxSelections = z.infer<typeof ZValidationRuleParamsMaxSelections>;
// Validation rule stored on element - discriminated union with type at top level
export const ZValidationRule = z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("minLength"),
params: ZValidationRuleParamsMinLength,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("maxLength"),
params: ZValidationRuleParamsMaxLength,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("pattern"),
params: ZValidationRuleParamsPattern,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("email"),
params: ZValidationRuleParamsEmail,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("url"),
params: ZValidationRuleParamsUrl,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("phone"),
params: ZValidationRuleParamsPhone,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("minValue"),
params: ZValidationRuleParamsMinValue,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("maxValue"),
params: ZValidationRuleParamsMaxValue,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("minSelections"),
params: ZValidationRuleParamsMinSelections,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("maxSelections"),
params: ZValidationRuleParamsMaxSelections,
customErrorMessage: ZI18nString.optional(),
}),
]);
export type TValidationRule = z.infer<typeof ZValidationRule>;
// Array of validation rules
export const ZValidationRules = z.array(ZValidationRule);
export type TValidationRules = z.infer<typeof ZValidationRules>;
// Applicable rules per element type - const arrays for type inference (must be defined before types)
const OPEN_TEXT_RULES = [
"required",
"minLength",
"maxLength",
"pattern",
"email",
"url",
"phone",
"minValue",
"maxValue",
] as const;
const MULTIPLE_CHOICE_SINGLE_RULES = ["required"] as const;
const MULTIPLE_CHOICE_MULTI_RULES = ["required", "minSelections", "maxSelections"] as const;
const RATING_RULES = ["required"] as const;
const NPS_RULES = ["required"] as const;
const DATE_RULES = ["required"] as const;
const CONSENT_RULES = ["required"] as const;
const MATRIX_RULES = ["required"] as const;
const RANKING_RULES = ["required"] as const;
const FILE_UPLOAD_RULES = ["required"] as const;
const PICTURE_SELECTION_RULES = ["required", "minSelections", "maxSelections"] as const;
const ADDRESS_RULES = ["required"] as const;
const CONTACT_INFO_RULES = ["required"] as const;
const CAL_RULES = ["required"] as const;
const CTA_RULES = [] as const;
// Applicable rules per element type
export const APPLICABLE_RULES: Record<string, TValidationRuleType[]> = {
openText: [...OPEN_TEXT_RULES],
multipleChoiceSingle: [...MULTIPLE_CHOICE_SINGLE_RULES],
multipleChoiceMulti: [...MULTIPLE_CHOICE_MULTI_RULES],
rating: [...RATING_RULES],
nps: [...NPS_RULES],
date: [...DATE_RULES],
consent: [...CONSENT_RULES],
matrix: [...MATRIX_RULES],
ranking: [...RANKING_RULES],
fileUpload: [...FILE_UPLOAD_RULES],
pictureSelection: [...PICTURE_SELECTION_RULES],
address: [...ADDRESS_RULES],
contactInfo: [...CONTACT_INFO_RULES],
cal: [...CAL_RULES],
cta: [...CTA_RULES], // CTA never validates
};
// Type helper to filter validation rules by allowed types
export type TValidationRuleForElementType<T extends TValidationRuleType> = Extract<
TValidationRule,
{ type: T }
>;
// Type helper to get validation rules array for specific element type
export type TValidationRulesForElementType<T extends readonly TValidationRuleType[]> =
TValidationRuleForElementType<T[number]>[];
// Specific validation rule types for each element type
export type TValidationRulesForOpenText = TValidationRulesForElementType<typeof OPEN_TEXT_RULES>;
export type TValidationRulesForMultipleChoiceSingle = TValidationRulesForElementType<
typeof MULTIPLE_CHOICE_SINGLE_RULES
>;
export type TValidationRulesForMultipleChoiceMulti = TValidationRulesForElementType<
typeof MULTIPLE_CHOICE_MULTI_RULES
>;
export type TValidationRulesForRating = TValidationRulesForElementType<typeof RATING_RULES>;
export type TValidationRulesForNPS = TValidationRulesForElementType<typeof NPS_RULES>;
export type TValidationRulesForDate = TValidationRulesForElementType<typeof DATE_RULES>;
export type TValidationRulesForConsent = TValidationRulesForElementType<typeof CONSENT_RULES>;
export type TValidationRulesForMatrix = TValidationRulesForElementType<typeof MATRIX_RULES>;
export type TValidationRulesForRanking = TValidationRulesForElementType<typeof RANKING_RULES>;
export type TValidationRulesForFileUpload = TValidationRulesForElementType<typeof FILE_UPLOAD_RULES>;
export type TValidationRulesForPictureSelection = TValidationRulesForElementType<
typeof PICTURE_SELECTION_RULES
>;
export type TValidationRulesForAddress = TValidationRulesForElementType<typeof ADDRESS_RULES>;
export type TValidationRulesForContactInfo = TValidationRulesForElementType<typeof CONTACT_INFO_RULES>;
export type TValidationRulesForCal = TValidationRulesForElementType<typeof CAL_RULES>;
export type TValidationRulesForCTA = TValidationRulesForElementType<typeof CTA_RULES>;
// Element-specific validation rules schemas (manually created for type safety)
// These are narrowed versions of ZValidationRule that only include applicable rule types
export const ZValidationRulesForOpenText: z.ZodType<TValidationRulesForOpenText> = z.array(
z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("minLength"),
params: ZValidationRuleParamsMinLength,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("maxLength"),
params: ZValidationRuleParamsMaxLength,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("pattern"),
params: ZValidationRuleParamsPattern,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("email"),
params: ZValidationRuleParamsEmail,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("url"),
params: ZValidationRuleParamsUrl,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("phone"),
params: ZValidationRuleParamsPhone,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("minValue"),
params: ZValidationRuleParamsMinValue,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("maxValue"),
params: ZValidationRuleParamsMaxValue,
customErrorMessage: ZI18nString.optional(),
}),
])
);
export const ZValidationRulesForMultipleChoiceSingle: z.ZodType<TValidationRulesForMultipleChoiceSingle> =
z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForMultipleChoiceMulti: z.ZodType<TValidationRulesForMultipleChoiceMulti> =
z.array(
z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("minSelections"),
params: ZValidationRuleParamsMinSelections,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("maxSelections"),
params: ZValidationRuleParamsMaxSelections,
customErrorMessage: ZI18nString.optional(),
}),
])
);
export const ZValidationRulesForRating: z.ZodType<TValidationRulesForRating> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForNPS: z.ZodType<TValidationRulesForNPS> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForDate: z.ZodType<TValidationRulesForDate> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForConsent: z.ZodType<TValidationRulesForConsent> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForMatrix: z.ZodType<TValidationRulesForMatrix> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForRanking: z.ZodType<TValidationRulesForRanking> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForFileUpload: z.ZodType<TValidationRulesForFileUpload> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForPictureSelection: z.ZodType<TValidationRulesForPictureSelection> = z.array(
z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("minSelections"),
params: ZValidationRuleParamsMinSelections,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("maxSelections"),
params: ZValidationRuleParamsMaxSelections,
customErrorMessage: ZI18nString.optional(),
}),
])
);
export const ZValidationRulesForAddress: z.ZodType<TValidationRulesForAddress> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForContactInfo: z.ZodType<TValidationRulesForContactInfo> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForCal: z.ZodType<TValidationRulesForCal> = z.array(
z.object({
id: z.string(),
type: z.literal("required"),
params: ZValidationRuleParamsRequired,
customErrorMessage: ZI18nString.optional(),
})
);
export const ZValidationRulesForCTA: z.ZodType<TValidationRulesForCTA> = z.array(z.never());