mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 11:28:58 -05:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4161e4c4e9 | |||
| 4c38b4989a | |||
| 9bdb040dbf | |||
| 1473897e45 | |||
| ddf25932ed | |||
| dce291825d | |||
| 760408a09a | |||
| 86b45c22b2 | |||
| ca810c83cb | |||
| e1926b46da | |||
| 539e7a0fc3 | |||
| 4477006cfb | |||
| ea661657b5 | |||
| 0a134038de | |||
| a117ff596d | |||
| 08773a3784 | |||
| 1f59ef3d57 | |||
| 2ee81b6aff | |||
| 80107d7ec2 | |||
| 89ffae9694 | |||
| e8345d3617 | |||
| dd1c525ed7 | |||
| 632da15df6 | |||
| 223f8c3cc7 | |||
| 3bde4be90d | |||
| 7362f8c5c9 | |||
| 79c0c3a209 | |||
| 018563ba03 | |||
| 5dddfc0406 | |||
| 67161155a9 | |||
| 9b0cf5f532 | |||
| a32241d7c8 | |||
| a296ad189a | |||
| 942cb0f8d0 | |||
| 3e3b8cc349 |
+37
-13
@@ -212,7 +212,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
|
||||||
@@ -236,13 +235,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
|
||||||
@@ -295,7 +292,7 @@ checksums:
|
|||||||
common/placeholder: 88c2c168aff12ca70148fcb5f6b4c7b1
|
common/placeholder: 88c2c168aff12ca70148fcb5f6b4c7b1
|
||||||
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
|
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
|
||||||
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
|
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
|
||||||
common/please_upgrade_your_plan: bfe98d41cd7383ad42169785d8c818fc
|
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||||
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
|
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
|
||||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||||
@@ -1095,7 +1092,6 @@ checksums:
|
|||||||
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
||||||
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
||||||
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
||||||
environments/surveys/edit/allow_file_type: ec4f1e0c5b764990c3b1560d0d8dc2af
|
|
||||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||||
@@ -1158,8 +1154,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
|
||||||
@@ -1179,7 +1173,6 @@ checksums:
|
|||||||
environments/surveys/edit/contact_fields: 0d4e3f4d2eb3481aabe3ac60a692fa74
|
environments/surveys/edit/contact_fields: 0d4e3f4d2eb3481aabe3ac60a692fa74
|
||||||
environments/surveys/edit/contains: 41c8c25407527a5336404313f4c8d650
|
environments/surveys/edit/contains: 41c8c25407527a5336404313f4c8d650
|
||||||
environments/surveys/edit/continue_to_settings: b9853a7eedb3ae295088268fe5a44824
|
environments/surveys/edit/continue_to_settings: b9853a7eedb3ae295088268fe5a44824
|
||||||
environments/surveys/edit/control_which_file_types_can_be_uploaded: 97144e65d91e2ca0114af923ba5924f4
|
|
||||||
environments/surveys/edit/convert_to_multiple_choice: e5396019ae897f6ec4c4295394c115e3
|
environments/surveys/edit/convert_to_multiple_choice: e5396019ae897f6ec4c4295394c115e3
|
||||||
environments/surveys/edit/convert_to_single_choice: 8ecabfcb9276f29e6ac962ffcbc1ba64
|
environments/surveys/edit/convert_to_single_choice: 8ecabfcb9276f29e6ac962ffcbc1ba64
|
||||||
environments/surveys/edit/country: 73581fc33a1e83e6a56db73558e7b5c6
|
environments/surveys/edit/country: 73581fc33a1e83e6a56db73558e7b5c6
|
||||||
@@ -1333,8 +1326,7 @@ checksums:
|
|||||||
environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||||
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
|
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||||
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
|
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
|
||||||
environments/surveys/edit/limit_file_types: 2ee563bc98c65f565014945d6fef389c
|
environments/surveys/edit/limit_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
|
||||||
environments/surveys/edit/limit_the_maximum_file_size: f3f8682de34eaae30351d570805ba172
|
|
||||||
environments/surveys/edit/limit_upload_file_size_to: 949c48d25ae45259cc19464e95752d29
|
environments/surveys/edit/limit_upload_file_size_to: 949c48d25ae45259cc19464e95752d29
|
||||||
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
|
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
|
||||||
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
|
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
|
||||||
@@ -1380,7 +1372,6 @@ checksums:
|
|||||||
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
||||||
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
|
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
|
||||||
environments/surveys/edit/pin_must_be_a_four_digit_number: 9f9c8c55d99f7b24fbcf6e7e377b726f
|
environments/surveys/edit/pin_must_be_a_four_digit_number: 9f9c8c55d99f7b24fbcf6e7e377b726f
|
||||||
environments/surveys/edit/please_enter_a_file_extension: 60ad12bce720593482809c002a542a97
|
|
||||||
environments/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
|
environments/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
|
||||||
environments/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
|
environments/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
|
||||||
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
||||||
@@ -1456,6 +1447,7 @@ checksums:
|
|||||||
environments/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
|
environments/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
|
||||||
environments/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
|
environments/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
|
||||||
environments/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
|
environments/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
|
||||||
|
environments/surveys/edit/select_field: 45665a44f7d5707506364f17f28db3bf
|
||||||
environments/surveys/edit/select_or_type_value: a99c307b2cc3f9f6f893babd546d7296
|
environments/surveys/edit/select_or_type_value: a99c307b2cc3f9f6f893babd546d7296
|
||||||
environments/surveys/edit/select_ordering: c8f632a17fe78d8b7f87e82df9351ff9
|
environments/surveys/edit/select_ordering: c8f632a17fe78d8b7f87e82df9351ff9
|
||||||
environments/surveys/edit/select_saved_action: de31ab9cbb2bb67a050df717de7cdde4
|
environments/surveys/edit/select_saved_action: de31ab9cbb2bb67a050df717de7cdde4
|
||||||
@@ -1503,8 +1495,6 @@ checksums:
|
|||||||
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
|
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
|
||||||
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
||||||
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
||||||
environments/surveys/edit/this_extension_is_already_added: 201d636539836c95958e28cecd8f3240
|
|
||||||
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
|
|
||||||
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||||
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||||
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||||
@@ -1525,6 +1515,40 @@ 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: e26d6bb531181ec1ed551e264bc86259
|
||||||
|
environments/surveys/edit/validation/contains: 41c8c25407527a5336404313f4c8d650
|
||||||
|
environments/surveys/edit/validation/does_not_contain: d618eb0f854f7efa0d7c644e6628fa42
|
||||||
|
environments/surveys/edit/validation/email: a481cd9fba3e145252458ee1eaa9bd3b
|
||||||
|
environments/surveys/edit/validation/file_extension_is: c102e4962dd7b8b17faec31ecda6c9bd
|
||||||
|
environments/surveys/edit/validation/file_extension_is_not: e5067a8ad6b89cd979651c9d8ee7c614
|
||||||
|
environments/surveys/edit/validation/is: 1940eeb4f6f0189788fde5403c6e9e9a
|
||||||
|
environments/surveys/edit/validation/is_between: 5721c877c60f0005dc4ce78d4c0d3fdc
|
||||||
|
environments/surveys/edit/validation/is_earlier_than: 3829d0a060cfc2c7f5f0281a55759612
|
||||||
|
environments/surveys/edit/validation/is_greater_than: b9542ab0e0ea0ee18e82931b160b1385
|
||||||
|
environments/surveys/edit/validation/is_later_than: 315eba60c6b8ca4cb3dd95c564ada456
|
||||||
|
environments/surveys/edit/validation/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
|
||||||
|
environments/surveys/edit/validation/is_not: 8c7817ecdb08e6fa92fdf3487e0c8c9d
|
||||||
|
environments/surveys/edit/validation/is_not_between: 4579a41b4e74d940eb036e13b3c63258
|
||||||
|
environments/surveys/edit/validation/kb: 476c6cddd277e93a1bb7af4a763e95dc
|
||||||
|
environments/surveys/edit/validation/max_length: 6edf9e1149c3893da102d9464138da22
|
||||||
|
environments/surveys/edit/validation/max_selections: 6edf9e1149c3893da102d9464138da22
|
||||||
|
environments/surveys/edit/validation/max_value: 6edf9e1149c3893da102d9464138da22
|
||||||
|
environments/surveys/edit/validation/mb: dbcf612f2d898197a764a442747b5c06
|
||||||
|
environments/surveys/edit/validation/min_length: 204dbf1f1b3aa34c8b981642b1694262
|
||||||
|
environments/surveys/edit/validation/min_selections: 204dbf1f1b3aa34c8b981642b1694262
|
||||||
|
environments/surveys/edit/validation/min_value: 204dbf1f1b3aa34c8b981642b1694262
|
||||||
|
environments/surveys/edit/validation/minimum_options_ranked: 2dca1fb216c977a044987c65a0ca95c9
|
||||||
|
environments/surveys/edit/validation/minimum_rows_answered: a8766a986cd73db0bb9daff49b271ed6
|
||||||
|
environments/surveys/edit/validation/options_selected: a7f72a7059a49a2a6d5b90f7a2a8aa44
|
||||||
|
environments/surveys/edit/validation/pattern: c6f01d7bc9baa21a40ea38fa986bd5a0
|
||||||
|
environments/surveys/edit/validation/phone: bcd7bd37a475ab1f80ea4c5b4d4d0bb5
|
||||||
|
environments/surveys/edit/validation/rank_all_options: a885523e9d7820c9b0529bca37e48ccc
|
||||||
|
environments/surveys/edit/validation/select_file_extensions: 208ccb7bd4dde20b0d79bdd1fa763076
|
||||||
|
environments/surveys/edit/validation/url: 4006a4d8dfac013758f0053f6aa67cdd
|
||||||
|
environments/surveys/edit/validation_logic_and: 83bb027b15e28b3dc1d6e16c7fc86056
|
||||||
|
environments/surveys/edit/validation_logic_or: 32c9f3998984fd32a2b5bc53f2d97429
|
||||||
|
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
|
||||||
|
|||||||
+43
-17
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Platzhalter",
|
"placeholder": "Platzhalter",
|
||||||
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
|
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
|
||||||
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
|
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
|
||||||
"please_upgrade_your_plan": "Bitte upgrade deinen Plan.",
|
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
|
||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
"preview_survey": "Umfragevorschau",
|
"preview_survey": "Umfragevorschau",
|
||||||
"privacy": "Datenschutz",
|
"privacy": "Datenschutz",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
|
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
|
||||||
"adjust_the_theme_in_the": "Passe das Thema an in den",
|
"adjust_the_theme_in_the": "Passe das Thema an in den",
|
||||||
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
|
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
|
||||||
"allow_file_type": "Dateityp begrenzen",
|
|
||||||
"allow_multi_select": "Mehrfachauswahl erlauben",
|
"allow_multi_select": "Mehrfachauswahl erlauben",
|
||||||
"allow_multiple_files": "Mehrere Dateien zulassen",
|
"allow_multiple_files": "Mehrere Dateien zulassen",
|
||||||
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
|
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Kontaktfelder",
|
"contact_fields": "Kontaktfelder",
|
||||||
"contains": "enthält",
|
"contains": "enthält",
|
||||||
"continue_to_settings": "Weiter zu den Einstellungen",
|
"continue_to_settings": "Weiter zu den Einstellungen",
|
||||||
"control_which_file_types_can_be_uploaded": "Steuere, welche Dateitypen hochgeladen werden können.",
|
|
||||||
"convert_to_multiple_choice": "In Multiple-Choice umwandeln",
|
"convert_to_multiple_choice": "In Multiple-Choice umwandeln",
|
||||||
"convert_to_single_choice": "In Einzelauswahl umwandeln",
|
"convert_to_single_choice": "In Einzelauswahl umwandeln",
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
@@ -1367,7 +1360,7 @@
|
|||||||
"hide_question_settings": "Frageeinstellungen ausblenden",
|
"hide_question_settings": "Frageeinstellungen ausblenden",
|
||||||
"hostname": "Hostname",
|
"hostname": "Hostname",
|
||||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
||||||
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
|
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
|
||||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
|
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
|
||||||
"ignore_global_waiting_time": "Abkühlphase ignorieren",
|
"ignore_global_waiting_time": "Abkühlphase ignorieren",
|
||||||
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "Schlüssel",
|
"key": "Schlüssel",
|
||||||
"last_name": "Nachname",
|
"last_name": "Nachname",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
|
||||||
"limit_file_types": "Dateitypen einschränken",
|
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
|
||||||
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
|
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
|
||||||
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
|
|
||||||
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
||||||
"load_segment": "Segment laden",
|
"load_segment": "Segment laden",
|
||||||
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
||||||
@@ -1418,8 +1410,8 @@
|
|||||||
"manage_languages": "Sprachen verwalten",
|
"manage_languages": "Sprachen verwalten",
|
||||||
"matrix_all_fields": "Alle Felder",
|
"matrix_all_fields": "Alle Felder",
|
||||||
"matrix_rows": "Zeilen",
|
"matrix_rows": "Zeilen",
|
||||||
"max_file_size": "Max. Dateigröße",
|
"max_file_size": "Maximale Dateigröße",
|
||||||
"max_file_size_limit_is": "Max. Dateigröße ist",
|
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
|
||||||
"move_question_to_block": "Frage in Block verschieben",
|
"move_question_to_block": "Frage in Block verschieben",
|
||||||
"multiply": "Multiplizieren *",
|
"multiply": "Multiplizieren *",
|
||||||
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
|
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Bild {idx}",
|
"picture_idx": "Bild {idx}",
|
||||||
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
|
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
|
||||||
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
|
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
|
||||||
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
|
|
||||||
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
|
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
|
||||||
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
|
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
|
||||||
"please_specify": "Bitte angeben",
|
"please_specify": "Bitte angeben",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Nach Bildern suchen",
|
"search_for_images": "Nach Bildern suchen",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt.",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt.",
|
||||||
"seconds_before_showing_the_survey": "Sekunden, bevor die Umfrage angezeigt wird.",
|
"seconds_before_showing_the_survey": "Sekunden, bevor die Umfrage angezeigt wird.",
|
||||||
|
"select_field": "Feld auswählen",
|
||||||
"select_or_type_value": "Auswählen oder Wert eingeben",
|
"select_or_type_value": "Auswählen oder Wert eingeben",
|
||||||
"select_ordering": "Anordnung auswählen",
|
"select_ordering": "Anordnung auswählen",
|
||||||
"select_saved_action": "Gespeicherte Aktion auswählen",
|
"select_saved_action": "Gespeicherte Aktion auswählen",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
|
||||||
"then": "dann",
|
"then": "dann",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
|
||||||
"this_extension_is_already_added": "Diese Erweiterung ist bereits hinzugefügt.",
|
|
||||||
"this_file_type_is_not_supported": "Dieser Dateityp wird nicht unterstützt.",
|
|
||||||
"three_points": "3 Punkte",
|
"three_points": "3 Punkte",
|
||||||
"times": "Zeiten",
|
"times": "Zeiten",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "enthält",
|
||||||
|
"does_not_contain": "enthält nicht",
|
||||||
|
"email": "Ist gültige E-Mail",
|
||||||
|
"file_extension_is": "Dateierweiterung ist",
|
||||||
|
"file_extension_is_not": "Dateierweiterung ist nicht",
|
||||||
|
"is": "ist",
|
||||||
|
"is_between": "ist zwischen",
|
||||||
|
"is_earlier_than": "ist früher als",
|
||||||
|
"is_greater_than": "ist größer als",
|
||||||
|
"is_later_than": "ist später als",
|
||||||
|
"is_less_than": "ist weniger als",
|
||||||
|
"is_not": "ist nicht",
|
||||||
|
"is_not_between": "ist nicht zwischen",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "Höchstens",
|
||||||
|
"max_selections": "Höchstens",
|
||||||
|
"max_value": "Höchstens",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "Mindestens",
|
||||||
|
"min_selections": "Mindestens",
|
||||||
|
"min_value": "Mindestens",
|
||||||
|
"minimum_options_ranked": "Mindestanzahl bewerteter Optionen",
|
||||||
|
"minimum_rows_answered": "Mindestanzahl beantworteter Zeilen",
|
||||||
|
"options_selected": "Optionen ausgewählt",
|
||||||
|
"pattern": "Entspricht Regex-Muster",
|
||||||
|
"phone": "Ist gültige Telefonnummer",
|
||||||
|
"rank_all_options": "Alle Optionen bewerten",
|
||||||
|
"select_file_extensions": "Dateierweiterungen auswählen...",
|
||||||
|
"url": "Ist gültige URL"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Alle sind wahr",
|
||||||
|
"validation_logic_or": "mindestens eine ist wahr",
|
||||||
|
"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.",
|
||||||
|
|||||||
+39
-13
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Placeholder",
|
"placeholder": "Placeholder",
|
||||||
"please_select_at_least_one_survey": "Please select at least one survey",
|
"please_select_at_least_one_survey": "Please select at least one survey",
|
||||||
"please_select_at_least_one_trigger": "Please select at least one trigger",
|
"please_select_at_least_one_trigger": "Please select at least one trigger",
|
||||||
"please_upgrade_your_plan": "Please upgrade your plan.",
|
"please_upgrade_your_plan": "Please upgrade your plan",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"preview_survey": "Preview Survey",
|
"preview_survey": "Preview Survey",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
|
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
|
||||||
"adjust_the_theme_in_the": "Adjust the theme in the",
|
"adjust_the_theme_in_the": "Adjust the theme in the",
|
||||||
"all_other_answers_will_continue_to": "All other answers will continue to",
|
"all_other_answers_will_continue_to": "All other answers will continue to",
|
||||||
"allow_file_type": "Allow file type",
|
|
||||||
"allow_multi_select": "Allow multi-select",
|
"allow_multi_select": "Allow multi-select",
|
||||||
"allow_multiple_files": "Allow multiple files",
|
"allow_multiple_files": "Allow multiple files",
|
||||||
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
|
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Contact Fields",
|
"contact_fields": "Contact Fields",
|
||||||
"contains": "Contains",
|
"contains": "Contains",
|
||||||
"continue_to_settings": "Continue to Settings",
|
"continue_to_settings": "Continue to Settings",
|
||||||
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
|
|
||||||
"convert_to_multiple_choice": "Convert to Multi-select",
|
"convert_to_multiple_choice": "Convert to Multi-select",
|
||||||
"convert_to_single_choice": "Convert to Single-select",
|
"convert_to_single_choice": "Convert to Single-select",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
@@ -1404,8 +1397,7 @@
|
|||||||
"key": "Key",
|
"key": "Key",
|
||||||
"last_name": "Last Name",
|
"last_name": "Last Name",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Let people upload up to 25 files at the same time.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Let people upload up to 25 files at the same time.",
|
||||||
"limit_file_types": "Limit file types",
|
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
|
||||||
"limit_the_maximum_file_size": "Limit the maximum file size",
|
|
||||||
"limit_upload_file_size_to": "Limit upload file size to",
|
"limit_upload_file_size_to": "Limit upload file size to",
|
||||||
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
|
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
|
||||||
"load_segment": "Load segment",
|
"load_segment": "Load segment",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Picture {idx}",
|
"picture_idx": "Picture {idx}",
|
||||||
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
|
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
|
||||||
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
|
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
|
||||||
"please_enter_a_file_extension": "Please enter a file extension.",
|
|
||||||
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
|
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Please set a survey trigger",
|
"please_set_a_survey_trigger": "Please set a survey trigger",
|
||||||
"please_specify": "Please specify",
|
"please_specify": "Please specify",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Search for images",
|
"search_for_images": "Search for images",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconds after trigger the survey will be closed if no response",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconds after trigger the survey will be closed if no response",
|
||||||
"seconds_before_showing_the_survey": "seconds before showing the survey.",
|
"seconds_before_showing_the_survey": "seconds before showing the survey.",
|
||||||
|
"select_field": "Select field",
|
||||||
"select_or_type_value": "Select or type value",
|
"select_or_type_value": "Select or type value",
|
||||||
"select_ordering": "Select ordering",
|
"select_ordering": "Select ordering",
|
||||||
"select_saved_action": "Select saved action",
|
"select_saved_action": "Select saved action",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they don't respond.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they don't respond.",
|
||||||
"then": "Then",
|
"then": "Then",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
|
||||||
"this_extension_is_already_added": "This extension is already added.",
|
|
||||||
"this_file_type_is_not_supported": "This file type is not supported.",
|
|
||||||
"three_points": "3 points",
|
"three_points": "3 points",
|
||||||
"times": "times",
|
"times": "times",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Contains",
|
||||||
|
"does_not_contain": "Does not contain",
|
||||||
|
"email": "Is valid email",
|
||||||
|
"file_extension_is": "File extension is",
|
||||||
|
"file_extension_is_not": "File extension is not",
|
||||||
|
"is": "Is",
|
||||||
|
"is_between": "Is between",
|
||||||
|
"is_earlier_than": "Is earlier than",
|
||||||
|
"is_greater_than": "Is greater than",
|
||||||
|
"is_later_than": "Is later than",
|
||||||
|
"is_less_than": "Is less than",
|
||||||
|
"is_not": "Is not",
|
||||||
|
"is_not_between": "Is not between",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "At most",
|
||||||
|
"max_selections": "At most",
|
||||||
|
"max_value": "At most",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "At least",
|
||||||
|
"min_selections": "At least",
|
||||||
|
"min_value": "At least",
|
||||||
|
"minimum_options_ranked": "Minimum options ranked",
|
||||||
|
"minimum_rows_answered": "Minimum rows answered",
|
||||||
|
"options_selected": "Options selected",
|
||||||
|
"pattern": "Matches regex pattern",
|
||||||
|
"phone": "Is valid phone",
|
||||||
|
"rank_all_options": "Rank all options",
|
||||||
|
"select_file_extensions": "Select file extensions...",
|
||||||
|
"url": "Is valid URL"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "All are true",
|
||||||
|
"validation_logic_or": "any is true",
|
||||||
|
"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.",
|
||||||
|
|||||||
+40
-14
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Marcador de posición",
|
"placeholder": "Marcador de posición",
|
||||||
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
|
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
|
||||||
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
|
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
|
||||||
"please_upgrade_your_plan": "Por favor, actualiza tu plan.",
|
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
|
||||||
"preview": "Vista previa",
|
"preview": "Vista previa",
|
||||||
"preview_survey": "Vista previa de la encuesta",
|
"preview_survey": "Vista previa de la encuesta",
|
||||||
"privacy": "Política de privacidad",
|
"privacy": "Política de privacidad",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
|
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
|
||||||
"adjust_the_theme_in_the": "Ajustar el tema en el",
|
"adjust_the_theme_in_the": "Ajustar el tema en el",
|
||||||
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
|
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
|
||||||
"allow_file_type": "Permitir tipo de archivo",
|
|
||||||
"allow_multi_select": "Permitir selección múltiple",
|
"allow_multi_select": "Permitir selección múltiple",
|
||||||
"allow_multiple_files": "Permitir múltiples archivos",
|
"allow_multiple_files": "Permitir múltiples archivos",
|
||||||
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
|
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Campos de contacto",
|
"contact_fields": "Campos de contacto",
|
||||||
"contains": "Contiene",
|
"contains": "Contiene",
|
||||||
"continue_to_settings": "Continuar a ajustes",
|
"continue_to_settings": "Continuar a ajustes",
|
||||||
"control_which_file_types_can_be_uploaded": "Controla qué tipos de archivos se pueden subir.",
|
|
||||||
"convert_to_multiple_choice": "Convertir a selección múltiple",
|
"convert_to_multiple_choice": "Convertir a selección múltiple",
|
||||||
"convert_to_single_choice": "Convertir a selección única",
|
"convert_to_single_choice": "Convertir a selección única",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "Clave",
|
"key": "Clave",
|
||||||
"last_name": "Apellido",
|
"last_name": "Apellido",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que las personas suban hasta 25 archivos al mismo tiempo.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que las personas suban hasta 25 archivos al mismo tiempo.",
|
||||||
"limit_file_types": "Limitar tipos de archivo",
|
"limit_the_maximum_file_size": "Limita el tamaño máximo de archivo para las subidas.",
|
||||||
"limit_the_maximum_file_size": "Limitar el tamaño máximo de archivo",
|
"limit_upload_file_size_to": "Limitar el tamaño de archivo de subida a",
|
||||||
"limit_upload_file_size_to": "Limitar tamaño de subida de archivos a",
|
|
||||||
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
|
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
|
||||||
"load_segment": "Cargar segmento",
|
"load_segment": "Cargar segmento",
|
||||||
"logic_error_warning": "El cambio causará errores lógicos",
|
"logic_error_warning": "El cambio causará errores lógicos",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Imagen {idx}",
|
"picture_idx": "Imagen {idx}",
|
||||||
"pin_can_only_contain_numbers": "El PIN solo puede contener números.",
|
"pin_can_only_contain_numbers": "El PIN solo puede contener números.",
|
||||||
"pin_must_be_a_four_digit_number": "El PIN debe ser un número de cuatro dígitos.",
|
"pin_must_be_a_four_digit_number": "El PIN debe ser un número de cuatro dígitos.",
|
||||||
"please_enter_a_file_extension": "Por favor, introduce una extensión de archivo.",
|
|
||||||
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
|
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
|
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
|
||||||
"please_specify": "Por favor, especifica",
|
"please_specify": "Por favor, especifica",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Buscar imágenes",
|
"search_for_images": "Buscar imágenes",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos después de activarse, la encuesta se cerrará si no hay respuesta",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos después de activarse, la encuesta se cerrará si no hay respuesta",
|
||||||
"seconds_before_showing_the_survey": "segundos antes de mostrar la encuesta.",
|
"seconds_before_showing_the_survey": "segundos antes de mostrar la encuesta.",
|
||||||
|
"select_field": "Seleccionar campo",
|
||||||
"select_or_type_value": "Selecciona o escribe un valor",
|
"select_or_type_value": "Selecciona o escribe un valor",
|
||||||
"select_ordering": "Seleccionar ordenación",
|
"select_ordering": "Seleccionar ordenación",
|
||||||
"select_saved_action": "Seleccionar acción guardada",
|
"select_saved_action": "Seleccionar acción guardada",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
|
||||||
"then": "Entonces",
|
"then": "Entonces",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
|
||||||
"this_extension_is_already_added": "Esta extensión ya está añadida.",
|
|
||||||
"this_file_type_is_not_supported": "Este tipo de archivo no es compatible.",
|
|
||||||
"three_points": "3 puntos",
|
"three_points": "3 puntos",
|
||||||
"times": "veces",
|
"times": "veces",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Contiene",
|
||||||
|
"does_not_contain": "No contiene",
|
||||||
|
"email": "Es un correo electrónico válido",
|
||||||
|
"file_extension_is": "La extensión del archivo es",
|
||||||
|
"file_extension_is_not": "La extensión del archivo no es",
|
||||||
|
"is": "Es",
|
||||||
|
"is_between": "Está entre",
|
||||||
|
"is_earlier_than": "Es anterior a",
|
||||||
|
"is_greater_than": "Es mayor que",
|
||||||
|
"is_later_than": "Es posterior a",
|
||||||
|
"is_less_than": "Es menor que",
|
||||||
|
"is_not": "No es",
|
||||||
|
"is_not_between": "No está entre",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "Como máximo",
|
||||||
|
"max_selections": "Como máximo",
|
||||||
|
"max_value": "Como máximo",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "Al menos",
|
||||||
|
"min_selections": "Al menos",
|
||||||
|
"min_value": "Al menos",
|
||||||
|
"minimum_options_ranked": "Opciones mínimas clasificadas",
|
||||||
|
"minimum_rows_answered": "Filas mínimas respondidas",
|
||||||
|
"options_selected": "Opciones seleccionadas",
|
||||||
|
"pattern": "Coincide con el patrón regex",
|
||||||
|
"phone": "Es un teléfono válido",
|
||||||
|
"rank_all_options": "Clasificar todas las opciones",
|
||||||
|
"select_file_extensions": "Selecciona extensiones de archivo...",
|
||||||
|
"url": "Es una URL válida"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Todas son verdaderas",
|
||||||
|
"validation_logic_or": "alguna es verdadera",
|
||||||
|
"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.",
|
||||||
|
|||||||
+42
-16
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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é!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Remplaçant",
|
"placeholder": "Remplaçant",
|
||||||
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
|
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
|
||||||
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
|
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
|
||||||
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan.",
|
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
|
||||||
"preview": "Aperçu",
|
"preview": "Aperçu",
|
||||||
"preview_survey": "Aperçu de l'enquête",
|
"preview_survey": "Aperçu de l'enquête",
|
||||||
"privacy": "Politique de confidentialité",
|
"privacy": "Politique de confidentialité",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
|
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
|
||||||
"adjust_the_theme_in_the": "Ajustez le thème dans le",
|
"adjust_the_theme_in_the": "Ajustez le thème dans le",
|
||||||
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
|
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
|
||||||
"allow_file_type": "Autoriser le type de fichier",
|
|
||||||
"allow_multi_select": "Autoriser la sélection multiple",
|
"allow_multi_select": "Autoriser la sélection multiple",
|
||||||
"allow_multiple_files": "Autoriser plusieurs fichiers",
|
"allow_multiple_files": "Autoriser plusieurs fichiers",
|
||||||
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
|
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Champs de contact",
|
"contact_fields": "Champs de contact",
|
||||||
"contains": "Contient",
|
"contains": "Contient",
|
||||||
"continue_to_settings": "Continuer vers les paramètres",
|
"continue_to_settings": "Continuer vers les paramètres",
|
||||||
"control_which_file_types_can_be_uploaded": "Contrôlez quels types de fichiers peuvent être téléchargés.",
|
|
||||||
"convert_to_multiple_choice": "Convertir en choix multiples",
|
"convert_to_multiple_choice": "Convertir en choix multiples",
|
||||||
"convert_to_single_choice": "Convertir en choix unique",
|
"convert_to_single_choice": "Convertir en choix unique",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
@@ -1367,7 +1360,7 @@
|
|||||||
"hide_question_settings": "Masquer les paramètres de la question",
|
"hide_question_settings": "Masquer les paramètres de la question",
|
||||||
"hostname": "Nom d'hôte",
|
"hostname": "Nom d'hôte",
|
||||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
||||||
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
|
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
|
||||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
|
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
|
||||||
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
|
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
|
||||||
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "Clé",
|
"key": "Clé",
|
||||||
"last_name": "Nom de famille",
|
"last_name": "Nom de famille",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permettre aux utilisateurs de télécharger jusqu'à 25 fichiers en même temps.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Permettre aux utilisateurs de télécharger jusqu'à 25 fichiers en même temps.",
|
||||||
"limit_file_types": "Limiter les types de fichiers",
|
"limit_the_maximum_file_size": "Limiter la taille maximale des fichiers pour les téléversements.",
|
||||||
"limit_the_maximum_file_size": "Limiter la taille maximale du fichier",
|
"limit_upload_file_size_to": "Limiter la taille de téléversement des fichiers à",
|
||||||
"limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à",
|
|
||||||
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
||||||
"load_segment": "Segment de chargement",
|
"load_segment": "Segment de chargement",
|
||||||
"logic_error_warning": "Changer causera des erreurs logiques",
|
"logic_error_warning": "Changer causera des erreurs logiques",
|
||||||
@@ -1419,7 +1411,7 @@
|
|||||||
"matrix_all_fields": "Tous les champs",
|
"matrix_all_fields": "Tous les champs",
|
||||||
"matrix_rows": "Lignes",
|
"matrix_rows": "Lignes",
|
||||||
"max_file_size": "Taille maximale du fichier",
|
"max_file_size": "Taille maximale du fichier",
|
||||||
"max_file_size_limit_is": "La taille maximale du fichier est",
|
"max_file_size_limit_is": "La limite de taille maximale du fichier est",
|
||||||
"move_question_to_block": "Déplacer la question vers le bloc",
|
"move_question_to_block": "Déplacer la question vers le bloc",
|
||||||
"multiply": "Multiplier *",
|
"multiply": "Multiplier *",
|
||||||
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
|
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Image {idx}",
|
"picture_idx": "Image {idx}",
|
||||||
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
|
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
|
||||||
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
|
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
|
||||||
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
|
|
||||||
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
|
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
|
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
|
||||||
"please_specify": "Veuillez préciser",
|
"please_specify": "Veuillez préciser",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Rechercher des images",
|
"search_for_images": "Rechercher des images",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Les secondes après le déclenchement, l'enquête sera fermée si aucune réponse n'est donnée.",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Les secondes après le déclenchement, l'enquête sera fermée si aucune réponse n'est donnée.",
|
||||||
"seconds_before_showing_the_survey": "secondes avant de montrer l'enquête.",
|
"seconds_before_showing_the_survey": "secondes avant de montrer l'enquête.",
|
||||||
|
"select_field": "Sélectionner un champ",
|
||||||
"select_or_type_value": "Sélectionnez ou saisissez une valeur",
|
"select_or_type_value": "Sélectionnez ou saisissez une valeur",
|
||||||
"select_ordering": "Choisir l'ordre",
|
"select_ordering": "Choisir l'ordre",
|
||||||
"select_saved_action": "Sélectionner une action enregistrée",
|
"select_saved_action": "Sélectionner une action enregistrée",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
|
||||||
"then": "Alors",
|
"then": "Alors",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
|
||||||
"this_extension_is_already_added": "Cette extension est déjà ajoutée.",
|
|
||||||
"this_file_type_is_not_supported": "Ce type de fichier n'est pas pris en charge.",
|
|
||||||
"three_points": "3 points",
|
"three_points": "3 points",
|
||||||
"times": "fois",
|
"times": "fois",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Contient",
|
||||||
|
"does_not_contain": "Ne contient pas",
|
||||||
|
"email": "Est un e-mail valide",
|
||||||
|
"file_extension_is": "L'extension de fichier est",
|
||||||
|
"file_extension_is_not": "L'extension de fichier n'est pas",
|
||||||
|
"is": "Est",
|
||||||
|
"is_between": "Est entre",
|
||||||
|
"is_earlier_than": "Est antérieur à",
|
||||||
|
"is_greater_than": "Est supérieur à",
|
||||||
|
"is_later_than": "Est postérieur à",
|
||||||
|
"is_less_than": "Est inférieur à",
|
||||||
|
"is_not": "N'est pas",
|
||||||
|
"is_not_between": "N'est pas entre",
|
||||||
|
"kb": "Ko",
|
||||||
|
"max_length": "Au maximum",
|
||||||
|
"max_selections": "Au maximum",
|
||||||
|
"max_value": "Au maximum",
|
||||||
|
"mb": "Mo",
|
||||||
|
"min_length": "Au moins",
|
||||||
|
"min_selections": "Au moins",
|
||||||
|
"min_value": "Au moins",
|
||||||
|
"minimum_options_ranked": "Nombre minimum d'options classées",
|
||||||
|
"minimum_rows_answered": "Nombre minimum de lignes répondues",
|
||||||
|
"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",
|
||||||
|
"rank_all_options": "Classer toutes les options",
|
||||||
|
"select_file_extensions": "Sélectionner les extensions de fichier...",
|
||||||
|
"url": "Est une URL valide"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Toutes sont vraies",
|
||||||
|
"validation_logic_or": "au moins une est vraie",
|
||||||
|
"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.",
|
||||||
|
|||||||
+40
-14
@@ -239,7 +239,6 @@
|
|||||||
"imprint": "企業情報",
|
"imprint": "企業情報",
|
||||||
"in_progress": "進行中",
|
"in_progress": "進行中",
|
||||||
"inactive_surveys": "非アクティブなフォーム",
|
"inactive_surveys": "非アクティブなフォーム",
|
||||||
"input_type": "入力タイプ",
|
|
||||||
"integration": "連携",
|
"integration": "連携",
|
||||||
"integrations": "連携",
|
"integrations": "連携",
|
||||||
"invalid_date": "無効な日付です",
|
"invalid_date": "無効な日付です",
|
||||||
@@ -263,13 +262,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": "おっと、 小さな 画面 が 検出されました!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "プレースホルダー",
|
"placeholder": "プレースホルダー",
|
||||||
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
|
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
|
||||||
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
|
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
|
||||||
"please_upgrade_your_plan": "プランをアップグレードしてください。",
|
"please_upgrade_your_plan": "プランをアップグレードしてください",
|
||||||
"preview": "プレビュー",
|
"preview": "プレビュー",
|
||||||
"preview_survey": "フォームをプレビュー",
|
"preview_survey": "フォームをプレビュー",
|
||||||
"privacy": "プライバシーポリシー",
|
"privacy": "プライバシーポリシー",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
|
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
|
||||||
"adjust_the_theme_in_the": "テーマを",
|
"adjust_the_theme_in_the": "テーマを",
|
||||||
"all_other_answers_will_continue_to": "他のすべての回答は引き続き",
|
"all_other_answers_will_continue_to": "他のすべての回答は引き続き",
|
||||||
"allow_file_type": "ファイルタイプを許可",
|
|
||||||
"allow_multi_select": "複数選択を許可",
|
"allow_multi_select": "複数選択を許可",
|
||||||
"allow_multiple_files": "複数のファイルを許可",
|
"allow_multiple_files": "複数のファイルを許可",
|
||||||
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
|
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
|
||||||
@@ -1229,8 +1225,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": "ブロックの最初の質問を選択してください",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "連絡先フィールド",
|
"contact_fields": "連絡先フィールド",
|
||||||
"contains": "を含む",
|
"contains": "を含む",
|
||||||
"continue_to_settings": "設定に進む",
|
"continue_to_settings": "設定に進む",
|
||||||
"control_which_file_types_can_be_uploaded": "アップロードできるファイルの種類を制御します。",
|
|
||||||
"convert_to_multiple_choice": "複数選択に変換",
|
"convert_to_multiple_choice": "複数選択に変換",
|
||||||
"convert_to_single_choice": "単一選択に変換",
|
"convert_to_single_choice": "単一選択に変換",
|
||||||
"country": "国",
|
"country": "国",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "キー",
|
"key": "キー",
|
||||||
"last_name": "姓",
|
"last_name": "姓",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
|
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
|
||||||
"limit_file_types": "ファイルタイプを制限",
|
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
|
||||||
"limit_the_maximum_file_size": "最大ファイルサイズを制限",
|
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
|
||||||
"limit_upload_file_size_to": "アップロードファイルサイズを以下に制限",
|
|
||||||
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
|
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
|
||||||
"load_segment": "セグメントを読み込み",
|
"load_segment": "セグメントを読み込み",
|
||||||
"logic_error_warning": "変更するとロジックエラーが発生します",
|
"logic_error_warning": "変更するとロジックエラーが発生します",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "写真 {idx}",
|
"picture_idx": "写真 {idx}",
|
||||||
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
|
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
|
||||||
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
|
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
|
||||||
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
|
|
||||||
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
|
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
|
||||||
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
|
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
|
||||||
"please_specify": "具体的に指定してください",
|
"please_specify": "具体的に指定してください",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "画像を検索",
|
"search_for_images": "画像を検索",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "トリガーから数秒後に回答がない場合、フォームは閉じられます",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "トリガーから数秒後に回答がない場合、フォームは閉じられます",
|
||||||
"seconds_before_showing_the_survey": "秒後にフォームを表示します。",
|
"seconds_before_showing_the_survey": "秒後にフォームを表示します。",
|
||||||
|
"select_field": "フィールドを選択",
|
||||||
"select_or_type_value": "値を選択または入力",
|
"select_or_type_value": "値を選択または入力",
|
||||||
"select_ordering": "順序を選択",
|
"select_ordering": "順序を選択",
|
||||||
"select_saved_action": "保存済みのアクションを選択",
|
"select_saved_action": "保存済みのアクションを選択",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答がなくても1回だけ表示します。",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答がなくても1回だけ表示します。",
|
||||||
"then": "その後",
|
"then": "その後",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
|
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
|
||||||
"this_extension_is_already_added": "この拡張機能はすでに追加されています。",
|
|
||||||
"this_file_type_is_not_supported": "このファイルタイプはサポートされていません。",
|
|
||||||
"three_points": "3点",
|
"three_points": "3点",
|
||||||
"times": "回",
|
"times": "回",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"upper_label": "上限ラベル",
|
"upper_label": "上限ラベル",
|
||||||
"url_filters": "URLフィルター",
|
"url_filters": "URLフィルター",
|
||||||
"url_not_supported": "URLはサポートされていません",
|
"url_not_supported": "URLはサポートされていません",
|
||||||
|
"validation": {
|
||||||
|
"characters": "文字数",
|
||||||
|
"contains": "を含む",
|
||||||
|
"does_not_contain": "を含まない",
|
||||||
|
"email": "有効なメールアドレスである",
|
||||||
|
"file_extension_is": "ファイル拡張子が次と一致",
|
||||||
|
"file_extension_is_not": "ファイル拡張子が次と一致しない",
|
||||||
|
"is": "である",
|
||||||
|
"is_between": "の間である",
|
||||||
|
"is_earlier_than": "より前である",
|
||||||
|
"is_greater_than": "より大きい",
|
||||||
|
"is_later_than": "より後である",
|
||||||
|
"is_less_than": "より小さい",
|
||||||
|
"is_not": "ではない",
|
||||||
|
"is_not_between": "の間ではない",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "最大",
|
||||||
|
"max_selections": "最大",
|
||||||
|
"max_value": "最大",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "最小",
|
||||||
|
"min_selections": "最小",
|
||||||
|
"min_value": "最小",
|
||||||
|
"minimum_options_ranked": "ランク付けされた最小オプション数",
|
||||||
|
"minimum_rows_answered": "回答された最小行数",
|
||||||
|
"options_selected": "選択されたオプション",
|
||||||
|
"pattern": "正規表現パターンに一致する",
|
||||||
|
"phone": "有効な電話番号である",
|
||||||
|
"rank_all_options": "すべてのオプションをランク付け",
|
||||||
|
"select_file_extensions": "ファイル拡張子を選択...",
|
||||||
|
"url": "有効なURLである"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "すべてが真である",
|
||||||
|
"validation_logic_or": "いずれかが真",
|
||||||
|
"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": "変数名はすでに使用されています。別の名前を選択してください。",
|
||||||
|
|||||||
+42
-16
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Tijdelijke aanduiding",
|
"placeholder": "Tijdelijke aanduiding",
|
||||||
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
|
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
|
||||||
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
|
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
|
||||||
"please_upgrade_your_plan": "Upgrade uw abonnement.",
|
"please_upgrade_your_plan": "Upgrade je abonnement",
|
||||||
"preview": "Voorbeeld",
|
"preview": "Voorbeeld",
|
||||||
"preview_survey": "Voorbeeld van enquête",
|
"preview_survey": "Voorbeeld van enquête",
|
||||||
"privacy": "Privacybeleid",
|
"privacy": "Privacybeleid",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
|
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
|
||||||
"adjust_the_theme_in_the": "Pas het thema aan in de",
|
"adjust_the_theme_in_the": "Pas het thema aan in de",
|
||||||
"all_other_answers_will_continue_to": "Alle andere antwoorden blijven hetzelfde",
|
"all_other_answers_will_continue_to": "Alle andere antwoorden blijven hetzelfde",
|
||||||
"allow_file_type": "Bestandstype toestaan",
|
|
||||||
"allow_multi_select": "Multi-select toestaan",
|
"allow_multi_select": "Multi-select toestaan",
|
||||||
"allow_multiple_files": "Meerdere bestanden toestaan",
|
"allow_multiple_files": "Meerdere bestanden toestaan",
|
||||||
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
|
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Contactvelden",
|
"contact_fields": "Contactvelden",
|
||||||
"contains": "Bevat",
|
"contains": "Bevat",
|
||||||
"continue_to_settings": "Ga verder naar Instellingen",
|
"continue_to_settings": "Ga verder naar Instellingen",
|
||||||
"control_which_file_types_can_be_uploaded": "Bepaal welke bestandstypen kunnen worden geüpload.",
|
|
||||||
"convert_to_multiple_choice": "Converteren naar Multi-select",
|
"convert_to_multiple_choice": "Converteren naar Multi-select",
|
||||||
"convert_to_single_choice": "Converteren naar Enkele selectie",
|
"convert_to_single_choice": "Converteren naar Enkele selectie",
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
@@ -1367,7 +1360,7 @@
|
|||||||
"hide_question_settings": "Vraaginstellingen verbergen",
|
"hide_question_settings": "Vraaginstellingen verbergen",
|
||||||
"hostname": "Hostnaam",
|
"hostname": "Hostnaam",
|
||||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hoe funky wil je je kaarten hebben in {surveyTypeDerived} Enquêtes",
|
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hoe funky wil je je kaarten hebben in {surveyTypeDerived} Enquêtes",
|
||||||
"if_you_need_more_please": "Als u meer nodig heeft, alstublieft",
|
"if_you_need_more_please": "Als je meer nodig hebt,",
|
||||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
|
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
|
||||||
"ignore_global_waiting_time": "Afkoelperiode negeren",
|
"ignore_global_waiting_time": "Afkoelperiode negeren",
|
||||||
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
|
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "Sleutel",
|
"key": "Sleutel",
|
||||||
"last_name": "Achternaam",
|
"last_name": "Achternaam",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
|
||||||
"limit_file_types": "Beperk bestandstypen",
|
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
|
||||||
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte",
|
"limit_upload_file_size_to": "Beperk uploadbestandsgrootte tot",
|
||||||
"limit_upload_file_size_to": "Beperk de uploadbestandsgrootte tot",
|
|
||||||
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
|
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
|
||||||
"load_segment": "Laadsegment",
|
"load_segment": "Laadsegment",
|
||||||
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
|
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
|
||||||
@@ -1419,7 +1411,7 @@
|
|||||||
"matrix_all_fields": "Alle velden",
|
"matrix_all_fields": "Alle velden",
|
||||||
"matrix_rows": "Rijen",
|
"matrix_rows": "Rijen",
|
||||||
"max_file_size": "Maximale bestandsgrootte",
|
"max_file_size": "Maximale bestandsgrootte",
|
||||||
"max_file_size_limit_is": "De maximale bestandsgrootte is",
|
"max_file_size_limit_is": "Maximale bestandsgroottelimiet is",
|
||||||
"move_question_to_block": "Vraag naar blok verplaatsen",
|
"move_question_to_block": "Vraag naar blok verplaatsen",
|
||||||
"multiply": "Vermenigvuldig *",
|
"multiply": "Vermenigvuldig *",
|
||||||
"needed_for_self_hosted_cal_com_instance": "Nodig voor een zelf-gehoste Cal.com-instantie",
|
"needed_for_self_hosted_cal_com_instance": "Nodig voor een zelf-gehoste Cal.com-instantie",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Afbeelding {idx}",
|
"picture_idx": "Afbeelding {idx}",
|
||||||
"pin_can_only_contain_numbers": "De pincode kan alleen cijfers bevatten.",
|
"pin_can_only_contain_numbers": "De pincode kan alleen cijfers bevatten.",
|
||||||
"pin_must_be_a_four_digit_number": "De pincode moet uit vier cijfers bestaan.",
|
"pin_must_be_a_four_digit_number": "De pincode moet uit vier cijfers bestaan.",
|
||||||
"please_enter_a_file_extension": "Voer een bestandsextensie in.",
|
|
||||||
"please_enter_a_valid_url": "Voer een geldige URL in (bijvoorbeeld https://example.com)",
|
"please_enter_a_valid_url": "Voer een geldige URL in (bijvoorbeeld https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Stel een enquêtetrigger in",
|
"please_set_a_survey_trigger": "Stel een enquêtetrigger in",
|
||||||
"please_specify": "Gelieve te specificeren",
|
"please_specify": "Gelieve te specificeren",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Zoek naar afbeeldingen",
|
"search_for_images": "Zoek naar afbeeldingen",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconden na trigger wordt de enquête gesloten als er geen reactie is",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconden na trigger wordt de enquête gesloten als er geen reactie is",
|
||||||
"seconds_before_showing_the_survey": "seconden voordat de enquête wordt weergegeven.",
|
"seconds_before_showing_the_survey": "seconden voordat de enquête wordt weergegeven.",
|
||||||
|
"select_field": "Selecteer veld",
|
||||||
"select_or_type_value": "Selecteer of typ een waarde",
|
"select_or_type_value": "Selecteer of typ een waarde",
|
||||||
"select_ordering": "Selecteer bestellen",
|
"select_ordering": "Selecteer bestellen",
|
||||||
"select_saved_action": "Selecteer opgeslagen actie",
|
"select_saved_action": "Selecteer opgeslagen actie",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Toon één keer, zelfs als ze niet reageren.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Toon één keer, zelfs als ze niet reageren.",
|
||||||
"then": "Dan",
|
"then": "Dan",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Met deze actie worden alle vertalingen uit deze enquête verwijderd.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Met deze actie worden alle vertalingen uit deze enquête verwijderd.",
|
||||||
"this_extension_is_already_added": "Deze extensie is al toegevoegd.",
|
|
||||||
"this_file_type_is_not_supported": "Dit bestandstype wordt niet ondersteund.",
|
|
||||||
"three_points": "3 punten",
|
"three_points": "3 punten",
|
||||||
"times": "keer",
|
"times": "keer",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Om de plaatsing over alle enquêtes consistent te houden, kunt u dat doen",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Om de plaatsing over alle enquêtes consistent te houden, kunt u dat doen",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Bevat",
|
||||||
|
"does_not_contain": "Bevat niet",
|
||||||
|
"email": "Is geldig e-mailadres",
|
||||||
|
"file_extension_is": "Bestandsextensie is",
|
||||||
|
"file_extension_is_not": "Bestandsextensie is niet",
|
||||||
|
"is": "Is",
|
||||||
|
"is_between": "Is tussen",
|
||||||
|
"is_earlier_than": "Is eerder dan",
|
||||||
|
"is_greater_than": "Is groter dan",
|
||||||
|
"is_later_than": "Is later dan",
|
||||||
|
"is_less_than": "Is minder dan",
|
||||||
|
"is_not": "Is niet",
|
||||||
|
"is_not_between": "Is niet tussen",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "Maximaal",
|
||||||
|
"max_selections": "Maximaal",
|
||||||
|
"max_value": "Maximaal",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "Minimaal",
|
||||||
|
"min_selections": "Minimaal",
|
||||||
|
"min_value": "Minimaal",
|
||||||
|
"minimum_options_ranked": "Minimaal aantal gerangschikte opties",
|
||||||
|
"minimum_rows_answered": "Minimaal aantal beantwoorde rijen",
|
||||||
|
"options_selected": "Opties geselecteerd",
|
||||||
|
"pattern": "Komt overeen met regex-patroon",
|
||||||
|
"phone": "Is geldig telefoonnummer",
|
||||||
|
"rank_all_options": "Rangschik alle opties",
|
||||||
|
"select_file_extensions": "Selecteer bestandsextensies...",
|
||||||
|
"url": "Is geldige URL"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Alle zijn waar",
|
||||||
|
"validation_logic_or": "een is waar",
|
||||||
|
"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.",
|
||||||
|
|||||||
+41
-15
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Espaço reservado",
|
"placeholder": "Espaço reservado",
|
||||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
|
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
|
||||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||||
"please_upgrade_your_plan": "Por favor, atualize seu plano.",
|
"please_upgrade_your_plan": "Por favor, atualize seu plano",
|
||||||
"preview": "Prévia",
|
"preview": "Prévia",
|
||||||
"preview_survey": "Prévia da Pesquisa",
|
"preview_survey": "Prévia da Pesquisa",
|
||||||
"privacy": "Política de Privacidade",
|
"privacy": "Política de Privacidade",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
|
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
|
||||||
"adjust_the_theme_in_the": "Ajuste o tema no",
|
"adjust_the_theme_in_the": "Ajuste o tema no",
|
||||||
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
||||||
"allow_file_type": "Permitir tipo de arquivo",
|
|
||||||
"allow_multi_select": "Permitir seleção múltipla",
|
"allow_multi_select": "Permitir seleção múltipla",
|
||||||
"allow_multiple_files": "Permitir vários arquivos",
|
"allow_multiple_files": "Permitir vários arquivos",
|
||||||
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
|
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Campos de Contato",
|
"contact_fields": "Campos de Contato",
|
||||||
"contains": "contém",
|
"contains": "contém",
|
||||||
"continue_to_settings": "Continuar para Configurações",
|
"continue_to_settings": "Continuar para Configurações",
|
||||||
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de arquivos podem ser enviados.",
|
|
||||||
"convert_to_multiple_choice": "Converter para Múltipla Escolha",
|
"convert_to_multiple_choice": "Converter para Múltipla Escolha",
|
||||||
"convert_to_single_choice": "Converter para Escolha Única",
|
"convert_to_single_choice": "Converter para Escolha Única",
|
||||||
"country": "país",
|
"country": "país",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "chave",
|
"key": "chave",
|
||||||
"last_name": "Sobrenome",
|
"last_name": "Sobrenome",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Deixe as pessoas fazerem upload de até 25 arquivos ao mesmo tempo.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Deixe as pessoas fazerem upload de até 25 arquivos ao mesmo tempo.",
|
||||||
"limit_file_types": "Limitar tipos de arquivos",
|
"limit_the_maximum_file_size": "Limitar o tamanho máximo de arquivo para uploads.",
|
||||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo",
|
"limit_upload_file_size_to": "Limitar tamanho de arquivo de upload para",
|
||||||
"limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para",
|
|
||||||
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
||||||
"load_segment": "segmento de carga",
|
"load_segment": "segmento de carga",
|
||||||
"logic_error_warning": "Mudar vai causar erros de lógica",
|
"logic_error_warning": "Mudar vai causar erros de lógica",
|
||||||
@@ -1419,7 +1411,7 @@
|
|||||||
"matrix_all_fields": "Todos os campos",
|
"matrix_all_fields": "Todos os campos",
|
||||||
"matrix_rows": "Linhas",
|
"matrix_rows": "Linhas",
|
||||||
"max_file_size": "Tamanho máximo do arquivo",
|
"max_file_size": "Tamanho máximo do arquivo",
|
||||||
"max_file_size_limit_is": "Tamanho máximo do arquivo é",
|
"max_file_size_limit_is": "O limite de tamanho máximo do arquivo é",
|
||||||
"move_question_to_block": "Mover pergunta para o bloco",
|
"move_question_to_block": "Mover pergunta para o bloco",
|
||||||
"multiply": "Multiplicar *",
|
"multiply": "Multiplicar *",
|
||||||
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Imagem {idx}",
|
"picture_idx": "Imagem {idx}",
|
||||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
|
|
||||||
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
|
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
|
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
|
||||||
"please_specify": "Por favor, especifique",
|
"please_specify": "Por favor, especifique",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Buscar imagens",
|
"search_for_images": "Buscar imagens",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após acionar, a pesquisa será encerrada se não houver resposta",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após acionar, a pesquisa será encerrada se não houver resposta",
|
||||||
"seconds_before_showing_the_survey": "segundos antes de mostrar a pesquisa.",
|
"seconds_before_showing_the_survey": "segundos antes de mostrar a pesquisa.",
|
||||||
|
"select_field": "Selecionar campo",
|
||||||
"select_or_type_value": "Selecionar ou digitar valor",
|
"select_or_type_value": "Selecionar ou digitar valor",
|
||||||
"select_ordering": "Selecionar pedido",
|
"select_ordering": "Selecionar pedido",
|
||||||
"select_saved_action": "Selecionar ação salva",
|
"select_saved_action": "Selecionar ação salva",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
||||||
"then": "Então",
|
"then": "Então",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.",
|
||||||
"this_extension_is_already_added": "Essa extensão já foi adicionada.",
|
|
||||||
"this_file_type_is_not_supported": "Esse tipo de arquivo não é suportado.",
|
|
||||||
"three_points": "3 pontos",
|
"three_points": "3 pontos",
|
||||||
"times": "times",
|
"times": "times",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todas as pesquisas, você pode",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todas as pesquisas, você pode",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Contém",
|
||||||
|
"does_not_contain": "Não contém",
|
||||||
|
"email": "É um e-mail válido",
|
||||||
|
"file_extension_is": "A extensão do arquivo é",
|
||||||
|
"file_extension_is_not": "A extensão do arquivo não é",
|
||||||
|
"is": "É",
|
||||||
|
"is_between": "Está entre",
|
||||||
|
"is_earlier_than": "É anterior a",
|
||||||
|
"is_greater_than": "É maior que",
|
||||||
|
"is_later_than": "É posterior a",
|
||||||
|
"is_less_than": "É menor que",
|
||||||
|
"is_not": "Não é",
|
||||||
|
"is_not_between": "Não está entre",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "No máximo",
|
||||||
|
"max_selections": "No máximo",
|
||||||
|
"max_value": "No máximo",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "No mínimo",
|
||||||
|
"min_selections": "No mínimo",
|
||||||
|
"min_value": "No mínimo",
|
||||||
|
"minimum_options_ranked": "Mínimo de opções classificadas",
|
||||||
|
"minimum_rows_answered": "Mínimo de linhas respondidas",
|
||||||
|
"options_selected": "Opções selecionadas",
|
||||||
|
"pattern": "Corresponde ao padrão regex",
|
||||||
|
"phone": "É um telefone válido",
|
||||||
|
"rank_all_options": "Classificar todas as opções",
|
||||||
|
"select_file_extensions": "Selecionar extensões de arquivo...",
|
||||||
|
"url": "É uma URL válida"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Todas são verdadeiras",
|
||||||
|
"validation_logic_or": "qualquer uma é verdadeira",
|
||||||
|
"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.",
|
||||||
|
|||||||
+42
-16
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Espaço reservado",
|
"placeholder": "Espaço reservado",
|
||||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
|
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
|
||||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||||
"please_upgrade_your_plan": "Por favor, atualize o seu plano.",
|
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
|
||||||
"preview": "Pré-visualização",
|
"preview": "Pré-visualização",
|
||||||
"preview_survey": "Pré-visualização do inquérito",
|
"preview_survey": "Pré-visualização do inquérito",
|
||||||
"privacy": "Política de Privacidade",
|
"privacy": "Política de Privacidade",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
|
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
|
||||||
"adjust_the_theme_in_the": "Ajustar o tema no",
|
"adjust_the_theme_in_the": "Ajustar o tema no",
|
||||||
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
||||||
"allow_file_type": "Permitir tipo de ficheiro",
|
|
||||||
"allow_multi_select": "Permitir seleção múltipla",
|
"allow_multi_select": "Permitir seleção múltipla",
|
||||||
"allow_multiple_files": "Permitir vários ficheiros",
|
"allow_multiple_files": "Permitir vários ficheiros",
|
||||||
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
|
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Campos de Contacto",
|
"contact_fields": "Campos de Contacto",
|
||||||
"contains": "Contém",
|
"contains": "Contém",
|
||||||
"continue_to_settings": "Continuar para Definições",
|
"continue_to_settings": "Continuar para Definições",
|
||||||
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
|
|
||||||
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
|
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
|
||||||
"convert_to_single_choice": "Converter para Seleção Única",
|
"convert_to_single_choice": "Converter para Seleção Única",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "Chave",
|
"key": "Chave",
|
||||||
"last_name": "Apelido",
|
"last_name": "Apelido",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que as pessoas carreguem até 25 ficheiros ao mesmo tempo.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que as pessoas carreguem até 25 ficheiros ao mesmo tempo.",
|
||||||
"limit_file_types": "Limitar tipos de ficheiros",
|
"limit_the_maximum_file_size": "Limitar o tamanho máximo de ficheiro para carregamentos.",
|
||||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro",
|
"limit_upload_file_size_to": "Limitar o tamanho de ficheiro de carregamento para",
|
||||||
"limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a",
|
|
||||||
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
||||||
"load_segment": "Carregar segmento",
|
"load_segment": "Carregar segmento",
|
||||||
"logic_error_warning": "A alteração causará erros de lógica",
|
"logic_error_warning": "A alteração causará erros de lógica",
|
||||||
@@ -1418,8 +1410,8 @@
|
|||||||
"manage_languages": "Gerir Idiomas",
|
"manage_languages": "Gerir Idiomas",
|
||||||
"matrix_all_fields": "Todos os campos",
|
"matrix_all_fields": "Todos os campos",
|
||||||
"matrix_rows": "Linhas",
|
"matrix_rows": "Linhas",
|
||||||
"max_file_size": "Tamanho máximo do ficheiro",
|
"max_file_size": "Tamanho máximo de ficheiro",
|
||||||
"max_file_size_limit_is": "O limite do tamanho máximo do ficheiro é",
|
"max_file_size_limit_is": "O limite de tamanho máximo de ficheiro é",
|
||||||
"move_question_to_block": "Mover pergunta para o bloco",
|
"move_question_to_block": "Mover pergunta para o bloco",
|
||||||
"multiply": "Multiplicar *",
|
"multiply": "Multiplicar *",
|
||||||
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Imagem {idx}",
|
"picture_idx": "Imagem {idx}",
|
||||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
|
|
||||||
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
|
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
|
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
|
||||||
"please_specify": "Por favor, especifique",
|
"please_specify": "Por favor, especifique",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Procurar imagens",
|
"search_for_images": "Procurar imagens",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após o acionamento o inquérito será fechado se não houver resposta",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após o acionamento o inquérito será fechado se não houver resposta",
|
||||||
"seconds_before_showing_the_survey": "segundos antes de mostrar o inquérito.",
|
"seconds_before_showing_the_survey": "segundos antes de mostrar o inquérito.",
|
||||||
|
"select_field": "Selecionar campo",
|
||||||
"select_or_type_value": "Selecionar ou digitar valor",
|
"select_or_type_value": "Selecionar ou digitar valor",
|
||||||
"select_ordering": "Selecionar ordem",
|
"select_ordering": "Selecionar ordem",
|
||||||
"select_saved_action": "Selecionar ação guardada",
|
"select_saved_action": "Selecionar ação guardada",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
||||||
"then": "Então",
|
"then": "Então",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.",
|
||||||
"this_extension_is_already_added": "Esta extensão já está adicionada.",
|
|
||||||
"this_file_type_is_not_supported": "Este tipo de ficheiro não é suportado.",
|
|
||||||
"three_points": "3 pontos",
|
"three_points": "3 pontos",
|
||||||
"times": "tempos",
|
"times": "tempos",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Contém",
|
||||||
|
"does_not_contain": "Não contém",
|
||||||
|
"email": "É um email válido",
|
||||||
|
"file_extension_is": "A extensão do ficheiro é",
|
||||||
|
"file_extension_is_not": "A extensão do ficheiro não é",
|
||||||
|
"is": "É",
|
||||||
|
"is_between": "Está entre",
|
||||||
|
"is_earlier_than": "É anterior a",
|
||||||
|
"is_greater_than": "É maior que",
|
||||||
|
"is_later_than": "É posterior a",
|
||||||
|
"is_less_than": "É menor que",
|
||||||
|
"is_not": "Não é",
|
||||||
|
"is_not_between": "Não está entre",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "No máximo",
|
||||||
|
"max_selections": "No máximo",
|
||||||
|
"max_value": "No máximo",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "Pelo menos",
|
||||||
|
"min_selections": "Pelo menos",
|
||||||
|
"min_value": "Pelo menos",
|
||||||
|
"minimum_options_ranked": "Opções mínimas classificadas",
|
||||||
|
"minimum_rows_answered": "Linhas mínimas respondidas",
|
||||||
|
"options_selected": "Opções selecionadas",
|
||||||
|
"pattern": "Coincide com o padrão regex",
|
||||||
|
"phone": "É um telefone válido",
|
||||||
|
"rank_all_options": "Classificar todas as opções",
|
||||||
|
"select_file_extensions": "Selecionar extensões de ficheiro...",
|
||||||
|
"url": "É um URL válido"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Todas são verdadeiras",
|
||||||
|
"validation_logic_or": "qualquer uma é verdadeira",
|
||||||
|
"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.",
|
||||||
|
|||||||
+42
-16
@@ -239,7 +239,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ă",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Marcaj substituent",
|
"placeholder": "Marcaj substituent",
|
||||||
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
|
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
|
||||||
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
|
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
|
||||||
"please_upgrade_your_plan": "Vă rugăm să vă actualizați planul.",
|
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
|
||||||
"preview": "Previzualizare",
|
"preview": "Previzualizare",
|
||||||
"preview_survey": "Previzualizare Chestionar",
|
"preview_survey": "Previzualizare Chestionar",
|
||||||
"privacy": "Politica de Confidențialitate",
|
"privacy": "Politica de Confidențialitate",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
|
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
|
||||||
"adjust_the_theme_in_the": "Ajustați tema în",
|
"adjust_the_theme_in_the": "Ajustați tema în",
|
||||||
"all_other_answers_will_continue_to": "Toate celelalte răspunsuri vor continua să",
|
"all_other_answers_will_continue_to": "Toate celelalte răspunsuri vor continua să",
|
||||||
"allow_file_type": "Permite tipul de fișier",
|
|
||||||
"allow_multi_select": "Permite selectare multiplă",
|
"allow_multi_select": "Permite selectare multiplă",
|
||||||
"allow_multiple_files": "Permite fișiere multiple",
|
"allow_multiple_files": "Permite fișiere multiple",
|
||||||
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
|
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Câmpuri de contact",
|
"contact_fields": "Câmpuri de contact",
|
||||||
"contains": "Conține",
|
"contains": "Conține",
|
||||||
"continue_to_settings": "Continuă către Setări",
|
"continue_to_settings": "Continuă către Setări",
|
||||||
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
|
|
||||||
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
|
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
|
||||||
"convert_to_single_choice": "Convertiți la selectare unică",
|
"convert_to_single_choice": "Convertiți la selectare unică",
|
||||||
"country": "Țară",
|
"country": "Țară",
|
||||||
@@ -1367,7 +1360,7 @@
|
|||||||
"hide_question_settings": "Ascunde setările întrebării",
|
"hide_question_settings": "Ascunde setările întrebării",
|
||||||
"hostname": "Nume gazdă",
|
"hostname": "Nume gazdă",
|
||||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
|
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
|
||||||
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să",
|
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
|
||||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
|
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
|
||||||
"ignore_global_waiting_time": "Ignoră perioada de răcire",
|
"ignore_global_waiting_time": "Ignoră perioada de răcire",
|
||||||
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "Cheie",
|
"key": "Cheie",
|
||||||
"last_name": "Nume de familie",
|
"last_name": "Nume de familie",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permiteți utilizatorilor să încarce până la 25 de fișiere simultan.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Permiteți utilizatorilor să încarce până la 25 de fișiere simultan.",
|
||||||
"limit_file_types": "Limitare tipuri de fișiere",
|
"limit_the_maximum_file_size": "Limitați dimensiunea maximă a fișierului pentru încărcări.",
|
||||||
"limit_the_maximum_file_size": "Limitează dimensiunea maximă a fișierului",
|
"limit_upload_file_size_to": "Limitați dimensiunea fișierului încărcat la",
|
||||||
"limit_upload_file_size_to": "Limitați dimensiunea fișierului de încărcare la",
|
|
||||||
"link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.",
|
"link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.",
|
||||||
"load_segment": "Încarcă segment",
|
"load_segment": "Încarcă segment",
|
||||||
"logic_error_warning": "Schimbarea va provoca erori de logică",
|
"logic_error_warning": "Schimbarea va provoca erori de logică",
|
||||||
@@ -1419,7 +1411,7 @@
|
|||||||
"matrix_all_fields": "Toate câmpurile",
|
"matrix_all_fields": "Toate câmpurile",
|
||||||
"matrix_rows": "Rânduri",
|
"matrix_rows": "Rânduri",
|
||||||
"max_file_size": "Dimensiune maximă fișier",
|
"max_file_size": "Dimensiune maximă fișier",
|
||||||
"max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este",
|
"max_file_size_limit_is": "Limita maximă pentru dimensiunea fișierului este",
|
||||||
"move_question_to_block": "Mută întrebarea în bloc",
|
"move_question_to_block": "Mută întrebarea în bloc",
|
||||||
"multiply": "Multiplicare",
|
"multiply": "Multiplicare",
|
||||||
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
|
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Poză {idx}",
|
"picture_idx": "Poză {idx}",
|
||||||
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
|
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
|
||||||
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
|
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
|
||||||
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
|
|
||||||
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
|
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
|
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
|
||||||
"please_specify": "Vă rugăm să specificați",
|
"please_specify": "Vă rugăm să specificați",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Căutare de imagini",
|
"search_for_images": "Căutare de imagini",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "secunde după declanșare sondajul va fi închis dacă nu există niciun răspuns",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "secunde după declanșare sondajul va fi închis dacă nu există niciun răspuns",
|
||||||
"seconds_before_showing_the_survey": "secunde înainte de afișarea sondajului",
|
"seconds_before_showing_the_survey": "secunde înainte de afișarea sondajului",
|
||||||
|
"select_field": "Selectează câmpul",
|
||||||
"select_or_type_value": "Selectați sau introduceți valoarea",
|
"select_or_type_value": "Selectați sau introduceți valoarea",
|
||||||
"select_ordering": "Selectează ordonarea",
|
"select_ordering": "Selectează ordonarea",
|
||||||
"select_saved_action": "Selectați acțiunea salvată",
|
"select_saved_action": "Selectați acțiunea salvată",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afișează o singură dată, chiar dacă persoana nu răspunde.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afișează o singură dată, chiar dacă persoana nu răspunde.",
|
||||||
"then": "Apoi",
|
"then": "Apoi",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.",
|
||||||
"this_extension_is_already_added": "Această extensie este deja adăugată.",
|
|
||||||
"this_file_type_is_not_supported": "Acest tip de fișier nu este acceptat.",
|
|
||||||
"three_points": "3 puncte",
|
"three_points": "3 puncte",
|
||||||
"times": "ori",
|
"times": "ori",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Conține",
|
||||||
|
"does_not_contain": "Nu conține",
|
||||||
|
"email": "Este un email valid",
|
||||||
|
"file_extension_is": "Extensia fișierului este",
|
||||||
|
"file_extension_is_not": "Extensia fișierului nu este",
|
||||||
|
"is": "Este",
|
||||||
|
"is_between": "Este între",
|
||||||
|
"is_earlier_than": "Este mai devreme decât",
|
||||||
|
"is_greater_than": "Este mai mare decât",
|
||||||
|
"is_later_than": "Este mai târziu decât",
|
||||||
|
"is_less_than": "Este mai mic decât",
|
||||||
|
"is_not": "Nu este",
|
||||||
|
"is_not_between": "Nu este între",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "Cel mult",
|
||||||
|
"max_selections": "Cel mult",
|
||||||
|
"max_value": "Cel mult",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "Cel puțin",
|
||||||
|
"min_selections": "Cel puțin",
|
||||||
|
"min_value": "Cel puțin",
|
||||||
|
"minimum_options_ranked": "Număr minim de opțiuni ordonate",
|
||||||
|
"minimum_rows_answered": "Număr minim de rânduri completate",
|
||||||
|
"options_selected": "Opțiuni selectate",
|
||||||
|
"pattern": "Se potrivește cu un șablon regex",
|
||||||
|
"phone": "Este un număr de telefon valid",
|
||||||
|
"rank_all_options": "Ordonați toate opțiunile",
|
||||||
|
"select_file_extensions": "Selectați extensiile de fișier...",
|
||||||
|
"url": "Este un URL valid"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Toate sunt adevărate",
|
||||||
|
"validation_logic_or": "oricare este adevărată",
|
||||||
|
"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.",
|
||||||
|
|||||||
+40
-14
@@ -239,7 +239,6 @@
|
|||||||
"imprint": "Выходные данные",
|
"imprint": "Выходные данные",
|
||||||
"in_progress": "В процессе",
|
"in_progress": "В процессе",
|
||||||
"inactive_surveys": "Неактивные опросы",
|
"inactive_surveys": "Неактивные опросы",
|
||||||
"input_type": "Тип ввода",
|
|
||||||
"integration": "интеграция",
|
"integration": "интеграция",
|
||||||
"integrations": "Интеграции",
|
"integrations": "Интеграции",
|
||||||
"invalid_date": "Неверная дата",
|
"invalid_date": "Неверная дата",
|
||||||
@@ -263,13 +262,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": "Ой, обнаружен маленький экран!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Заполнитель",
|
"placeholder": "Заполнитель",
|
||||||
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
|
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
|
||||||
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
|
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
|
||||||
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план.",
|
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
|
||||||
"preview": "Предпросмотр",
|
"preview": "Предпросмотр",
|
||||||
"preview_survey": "Предпросмотр опроса",
|
"preview_survey": "Предпросмотр опроса",
|
||||||
"privacy": "Политика конфиденциальности",
|
"privacy": "Политика конфиденциальности",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
|
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
|
||||||
"adjust_the_theme_in_the": "Настройте тему в",
|
"adjust_the_theme_in_the": "Настройте тему в",
|
||||||
"all_other_answers_will_continue_to": "Все остальные ответы будут продолжать",
|
"all_other_answers_will_continue_to": "Все остальные ответы будут продолжать",
|
||||||
"allow_file_type": "Разрешить тип файла",
|
|
||||||
"allow_multi_select": "Разрешить множественный выбор",
|
"allow_multi_select": "Разрешить множественный выбор",
|
||||||
"allow_multiple_files": "Разрешить несколько файлов",
|
"allow_multiple_files": "Разрешить несколько файлов",
|
||||||
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
|
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
|
||||||
@@ -1229,8 +1225,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": "Выберите первый вопрос в вашем блоке",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Поля контакта",
|
"contact_fields": "Поля контакта",
|
||||||
"contains": "Содержит",
|
"contains": "Содержит",
|
||||||
"continue_to_settings": "Перейти к настройкам",
|
"continue_to_settings": "Перейти к настройкам",
|
||||||
"control_which_file_types_can_be_uploaded": "Управляйте типами файлов, которые можно загружать.",
|
|
||||||
"convert_to_multiple_choice": "Преобразовать в мультивыбор",
|
"convert_to_multiple_choice": "Преобразовать в мультивыбор",
|
||||||
"convert_to_single_choice": "Преобразовать в одиночный выбор",
|
"convert_to_single_choice": "Преобразовать в одиночный выбор",
|
||||||
"country": "Страна",
|
"country": "Страна",
|
||||||
@@ -1367,7 +1360,7 @@
|
|||||||
"hide_question_settings": "Скрыть настройки вопроса",
|
"hide_question_settings": "Скрыть настройки вопроса",
|
||||||
"hostname": "Имя хоста",
|
"hostname": "Имя хоста",
|
||||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Насколько необычными вы хотите сделать карточки в опросах типа {surveyTypeDerived}",
|
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Насколько необычными вы хотите сделать карточки в опросах типа {surveyTypeDerived}",
|
||||||
"if_you_need_more_please": "Если нужно больше, пожалуйста",
|
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
|
||||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Показывать каждый раз при срабатывании, пока не будет получен ответ.",
|
"if_you_really_want_that_answer_ask_until_you_get_it": "Показывать каждый раз при срабатывании, пока не будет получен ответ.",
|
||||||
"ignore_global_waiting_time": "Игнорировать период ожидания",
|
"ignore_global_waiting_time": "Игнорировать период ожидания",
|
||||||
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
|
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
|
||||||
@@ -1404,8 +1397,7 @@
|
|||||||
"key": "Ключ",
|
"key": "Ключ",
|
||||||
"last_name": "Фамилия",
|
"last_name": "Фамилия",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Разрешить загружать до 25 файлов одновременно.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Разрешить загружать до 25 файлов одновременно.",
|
||||||
"limit_file_types": "Ограничить типы файлов",
|
"limit_the_maximum_file_size": "Ограничьте максимальный размер загружаемых файлов.",
|
||||||
"limit_the_maximum_file_size": "Ограничить максимальный размер файла",
|
|
||||||
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
|
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
|
||||||
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
|
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
|
||||||
"load_segment": "Загрузить сегмент",
|
"load_segment": "Загрузить сегмент",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Изображение {idx}",
|
"picture_idx": "Изображение {idx}",
|
||||||
"pin_can_only_contain_numbers": "PIN-код может содержать только цифры.",
|
"pin_can_only_contain_numbers": "PIN-код может содержать только цифры.",
|
||||||
"pin_must_be_a_four_digit_number": "PIN-код должен состоять из четырёх цифр.",
|
"pin_must_be_a_four_digit_number": "PIN-код должен состоять из четырёх цифр.",
|
||||||
"please_enter_a_file_extension": "Пожалуйста, введите расширение файла.",
|
|
||||||
"please_enter_a_valid_url": "Пожалуйста, введите корректный URL (например, https://example.com)",
|
"please_enter_a_valid_url": "Пожалуйста, введите корректный URL (например, https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Пожалуйста, установите триггер опроса",
|
"please_set_a_survey_trigger": "Пожалуйста, установите триггер опроса",
|
||||||
"please_specify": "Пожалуйста, уточните",
|
"please_specify": "Пожалуйста, уточните",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Поиск изображений",
|
"search_for_images": "Поиск изображений",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "секунд после запуска — опрос будет закрыт, если не будет ответа",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "секунд после запуска — опрос будет закрыт, если не будет ответа",
|
||||||
"seconds_before_showing_the_survey": "секунд до показа опроса.",
|
"seconds_before_showing_the_survey": "секунд до показа опроса.",
|
||||||
|
"select_field": "Выберите поле",
|
||||||
"select_or_type_value": "Выберите или введите значение",
|
"select_or_type_value": "Выберите или введите значение",
|
||||||
"select_ordering": "Выберите порядок",
|
"select_ordering": "Выберите порядок",
|
||||||
"select_saved_action": "Выберите сохранённое действие",
|
"select_saved_action": "Выберите сохранённое действие",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Показать один раз, даже если не будет ответа.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Показать один раз, даже если не будет ответа.",
|
||||||
"then": "Затем",
|
"then": "Затем",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Это действие удалит все переводы из этого опроса.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Это действие удалит все переводы из этого опроса.",
|
||||||
"this_extension_is_already_added": "Это расширение уже добавлено.",
|
|
||||||
"this_file_type_is_not_supported": "Этот тип файла не поддерживается.",
|
|
||||||
"three_points": "3 балла",
|
"three_points": "3 балла",
|
||||||
"times": "раз",
|
"times": "раз",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Чтобы сохранить единое расположение во всех опросах, вы можете",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Чтобы сохранить единое расположение во всех опросах, вы можете",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"upper_label": "Верхняя метка",
|
"upper_label": "Верхняя метка",
|
||||||
"url_filters": "Фильтры URL",
|
"url_filters": "Фильтры URL",
|
||||||
"url_not_supported": "URL не поддерживается",
|
"url_not_supported": "URL не поддерживается",
|
||||||
|
"validation": {
|
||||||
|
"characters": "Символы",
|
||||||
|
"contains": "Содержит",
|
||||||
|
"does_not_contain": "Не содержит",
|
||||||
|
"email": "Корректный email",
|
||||||
|
"file_extension_is": "Расширение файла —",
|
||||||
|
"file_extension_is_not": "Расширение файла не является",
|
||||||
|
"is": "Является",
|
||||||
|
"is_between": "Находится между",
|
||||||
|
"is_earlier_than": "Ранее чем",
|
||||||
|
"is_greater_than": "Больше чем",
|
||||||
|
"is_later_than": "Позже чем",
|
||||||
|
"is_less_than": "Меньше чем",
|
||||||
|
"is_not": "Не является",
|
||||||
|
"is_not_between": "Не находится между",
|
||||||
|
"kb": "КБ",
|
||||||
|
"max_length": "Не более",
|
||||||
|
"max_selections": "Не более",
|
||||||
|
"max_value": "Не более",
|
||||||
|
"mb": "МБ",
|
||||||
|
"min_length": "Не менее",
|
||||||
|
"min_selections": "Не менее",
|
||||||
|
"min_value": "Не менее",
|
||||||
|
"minimum_options_ranked": "Минимальное количество ранжированных вариантов",
|
||||||
|
"minimum_rows_answered": "Минимальное количество заполненных строк",
|
||||||
|
"options_selected": "Выбранные опции",
|
||||||
|
"pattern": "Соответствует шаблону regex",
|
||||||
|
"phone": "Корректный телефон",
|
||||||
|
"rank_all_options": "Ранжируйте все опции",
|
||||||
|
"select_file_extensions": "Выберите расширения файлов...",
|
||||||
|
"url": "Корректный URL"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Все условия выполняются",
|
||||||
|
"validation_logic_or": "выполняется хотя бы одно условие",
|
||||||
|
"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": "Это имя переменной уже занято, выберите другое.",
|
||||||
|
|||||||
+42
-16
@@ -239,7 +239,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",
|
||||||
@@ -263,13 +262,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!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "Platshållare",
|
"placeholder": "Platshållare",
|
||||||
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
|
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
|
||||||
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
|
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
|
||||||
"please_upgrade_your_plan": "Vänligen uppgradera din plan.",
|
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
|
||||||
"preview": "Förhandsgranska",
|
"preview": "Förhandsgranska",
|
||||||
"preview_survey": "Förhandsgranska enkät",
|
"preview_survey": "Förhandsgranska enkät",
|
||||||
"privacy": "Integritetspolicy",
|
"privacy": "Integritetspolicy",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
|
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
|
||||||
"adjust_the_theme_in_the": "Justera temat i",
|
"adjust_the_theme_in_the": "Justera temat i",
|
||||||
"all_other_answers_will_continue_to": "Alla andra svar fortsätter till",
|
"all_other_answers_will_continue_to": "Alla andra svar fortsätter till",
|
||||||
"allow_file_type": "Tillåt filtyp",
|
|
||||||
"allow_multi_select": "Tillåt flerval",
|
"allow_multi_select": "Tillåt flerval",
|
||||||
"allow_multiple_files": "Tillåt flera filer",
|
"allow_multiple_files": "Tillåt flera filer",
|
||||||
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
|
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
|
||||||
@@ -1229,8 +1225,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",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "Kontaktfält",
|
"contact_fields": "Kontaktfält",
|
||||||
"contains": "Innehåller",
|
"contains": "Innehåller",
|
||||||
"continue_to_settings": "Fortsätt till inställningar",
|
"continue_to_settings": "Fortsätt till inställningar",
|
||||||
"control_which_file_types_can_be_uploaded": "Kontrollera vilka filtyper som kan laddas upp.",
|
|
||||||
"convert_to_multiple_choice": "Konvertera till flerval",
|
"convert_to_multiple_choice": "Konvertera till flerval",
|
||||||
"convert_to_single_choice": "Konvertera till enkelval",
|
"convert_to_single_choice": "Konvertera till enkelval",
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
@@ -1367,7 +1360,7 @@
|
|||||||
"hide_question_settings": "Dölj frågeinställningar",
|
"hide_question_settings": "Dölj frågeinställningar",
|
||||||
"hostname": "Värdnamn",
|
"hostname": "Värdnamn",
|
||||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hur coola vill du att dina kort ska vara i {surveyTypeDerived}-enkäter",
|
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hur coola vill du att dina kort ska vara i {surveyTypeDerived}-enkäter",
|
||||||
"if_you_need_more_please": "Om du behöver fler, vänligen",
|
"if_you_need_more_please": "Om du behöver mer, vänligen",
|
||||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
|
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
|
||||||
"ignore_global_waiting_time": "Ignorera väntetid",
|
"ignore_global_waiting_time": "Ignorera väntetid",
|
||||||
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
|
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "Nyckel",
|
"key": "Nyckel",
|
||||||
"last_name": "Efternamn",
|
"last_name": "Efternamn",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "Låt personer ladda upp upp till 25 filer samtidigt.",
|
"let_people_upload_up_to_25_files_at_the_same_time": "Låt personer ladda upp upp till 25 filer samtidigt.",
|
||||||
"limit_file_types": "Begränsa filtyper",
|
"limit_the_maximum_file_size": "Begränsa den maximala filstorleken för uppladdningar.",
|
||||||
"limit_the_maximum_file_size": "Begränsa maximal filstorlek",
|
"limit_upload_file_size_to": "Begränsa uppladdad filstorlek till",
|
||||||
"limit_upload_file_size_to": "Begränsa uppladdningsfilstorlek till",
|
|
||||||
"link_survey_description": "Dela en länk till en enkätsida eller bädda in den på en webbsida eller i e-post.",
|
"link_survey_description": "Dela en länk till en enkätsida eller bädda in den på en webbsida eller i e-post.",
|
||||||
"load_segment": "Ladda segment",
|
"load_segment": "Ladda segment",
|
||||||
"logic_error_warning": "Ändring kommer att orsaka logikfel",
|
"logic_error_warning": "Ändring kommer att orsaka logikfel",
|
||||||
@@ -1419,7 +1411,7 @@
|
|||||||
"matrix_all_fields": "Alla fält",
|
"matrix_all_fields": "Alla fält",
|
||||||
"matrix_rows": "Rader",
|
"matrix_rows": "Rader",
|
||||||
"max_file_size": "Max filstorlek",
|
"max_file_size": "Max filstorlek",
|
||||||
"max_file_size_limit_is": "Maxgräns för filstorlek är",
|
"max_file_size_limit_is": "Maximal filstorleksgräns är",
|
||||||
"move_question_to_block": "Flytta fråga till block",
|
"move_question_to_block": "Flytta fråga till block",
|
||||||
"multiply": "Multiplicera *",
|
"multiply": "Multiplicera *",
|
||||||
"needed_for_self_hosted_cal_com_instance": "Behövs för en självhostad Cal.com-instans",
|
"needed_for_self_hosted_cal_com_instance": "Behövs för en självhostad Cal.com-instans",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "Bild {idx}",
|
"picture_idx": "Bild {idx}",
|
||||||
"pin_can_only_contain_numbers": "PIN kan endast innehålla siffror.",
|
"pin_can_only_contain_numbers": "PIN kan endast innehålla siffror.",
|
||||||
"pin_must_be_a_four_digit_number": "PIN måste vara ett fyrsiffrigt nummer.",
|
"pin_must_be_a_four_digit_number": "PIN måste vara ett fyrsiffrigt nummer.",
|
||||||
"please_enter_a_file_extension": "Vänligen ange en filändelse.",
|
|
||||||
"please_enter_a_valid_url": "Vänligen ange en giltig URL (t.ex. https://example.com)",
|
"please_enter_a_valid_url": "Vänligen ange en giltig URL (t.ex. https://example.com)",
|
||||||
"please_set_a_survey_trigger": "Vänligen ställ in en enkätutlösare",
|
"please_set_a_survey_trigger": "Vänligen ställ in en enkätutlösare",
|
||||||
"please_specify": "Vänligen specificera",
|
"please_specify": "Vänligen specificera",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "Sök efter bilder",
|
"search_for_images": "Sök efter bilder",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "sekunder efter utlösning stängs enkäten om inget svar",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "sekunder efter utlösning stängs enkäten om inget svar",
|
||||||
"seconds_before_showing_the_survey": "sekunder innan enkäten visas.",
|
"seconds_before_showing_the_survey": "sekunder innan enkäten visas.",
|
||||||
|
"select_field": "Välj fält",
|
||||||
"select_or_type_value": "Välj eller skriv värde",
|
"select_or_type_value": "Välj eller skriv värde",
|
||||||
"select_ordering": "Välj ordning",
|
"select_ordering": "Välj ordning",
|
||||||
"select_saved_action": "Välj sparad åtgärd",
|
"select_saved_action": "Välj sparad åtgärd",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Visa en enda gång, även om de inte svarar.",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Visa en enda gång, även om de inte svarar.",
|
||||||
"then": "Sedan",
|
"then": "Sedan",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "Denna åtgärd kommer att ta bort alla översättningar från denna enkät.",
|
"this_action_will_remove_all_the_translations_from_this_survey": "Denna åtgärd kommer att ta bort alla översättningar från denna enkät.",
|
||||||
"this_extension_is_already_added": "Denna filändelse är redan tillagd.",
|
|
||||||
"this_file_type_is_not_supported": "Denna filtyp stöds inte.",
|
|
||||||
"three_points": "3 poäng",
|
"three_points": "3 poäng",
|
||||||
"times": "gånger",
|
"times": "gånger",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "För att hålla placeringen konsekvent över alla enkäter kan du",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "För att hålla placeringen konsekvent över alla enkäter kan du",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"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",
|
||||||
|
"contains": "Innehåller",
|
||||||
|
"does_not_contain": "Innehåller inte",
|
||||||
|
"email": "Är en giltig e-postadress",
|
||||||
|
"file_extension_is": "Filändelsen är",
|
||||||
|
"file_extension_is_not": "Filändelsen är inte",
|
||||||
|
"is": "Är",
|
||||||
|
"is_between": "Är mellan",
|
||||||
|
"is_earlier_than": "Är tidigare än",
|
||||||
|
"is_greater_than": "Är större än",
|
||||||
|
"is_later_than": "Är senare än",
|
||||||
|
"is_less_than": "Är mindre än",
|
||||||
|
"is_not": "Är inte",
|
||||||
|
"is_not_between": "Är inte mellan",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "Högst",
|
||||||
|
"max_selections": "Högst",
|
||||||
|
"max_value": "Högst",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "Minst",
|
||||||
|
"min_selections": "Minst",
|
||||||
|
"min_value": "Minst",
|
||||||
|
"minimum_options_ranked": "Minsta antal rangordnade alternativ",
|
||||||
|
"minimum_rows_answered": "Minsta antal besvarade rader",
|
||||||
|
"options_selected": "Valda alternativ",
|
||||||
|
"pattern": "Matchar regexmönster",
|
||||||
|
"phone": "Är ett giltigt telefonnummer",
|
||||||
|
"rank_all_options": "Rangordna alla alternativ",
|
||||||
|
"select_file_extensions": "Välj filändelser...",
|
||||||
|
"url": "Är en giltig URL"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "Alla är sanna",
|
||||||
|
"validation_logic_or": "någon är sann",
|
||||||
|
"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.",
|
||||||
|
|||||||
@@ -239,7 +239,6 @@
|
|||||||
"imprint": "印记",
|
"imprint": "印记",
|
||||||
"in_progress": "进行中",
|
"in_progress": "进行中",
|
||||||
"inactive_surveys": "不 活跃 调查",
|
"inactive_surveys": "不 活跃 调查",
|
||||||
"input_type": "输入类型",
|
|
||||||
"integration": "集成",
|
"integration": "集成",
|
||||||
"integrations": "集成",
|
"integrations": "集成",
|
||||||
"invalid_date": "无效 日期",
|
"invalid_date": "无效 日期",
|
||||||
@@ -263,13 +262,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": "噢, 检测 到 小 屏幕!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "占位符",
|
"placeholder": "占位符",
|
||||||
"please_select_at_least_one_survey": "请选择至少 一个调查",
|
"please_select_at_least_one_survey": "请选择至少 一个调查",
|
||||||
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
|
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
|
||||||
"please_upgrade_your_plan": "请 升级 您的 计划。",
|
"please_upgrade_your_plan": "请升级您的计划",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"preview_survey": "预览 Survey",
|
"preview_survey": "预览 Survey",
|
||||||
"privacy": "隐私政策",
|
"privacy": "隐私政策",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
|
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
|
||||||
"adjust_the_theme_in_the": "调整主题在",
|
"adjust_the_theme_in_the": "调整主题在",
|
||||||
"all_other_answers_will_continue_to": "所有其他答案将继续",
|
"all_other_answers_will_continue_to": "所有其他答案将继续",
|
||||||
"allow_file_type": "允许 文件类型",
|
|
||||||
"allow_multi_select": "允许 多选",
|
"allow_multi_select": "允许 多选",
|
||||||
"allow_multiple_files": "允许 多 个 文件",
|
"allow_multiple_files": "允许 多 个 文件",
|
||||||
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
|
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
|
||||||
@@ -1229,8 +1225,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": "选择区块中的第一个问题",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "联络字段",
|
"contact_fields": "联络字段",
|
||||||
"contains": "包含",
|
"contains": "包含",
|
||||||
"continue_to_settings": "继续 到 设置",
|
"continue_to_settings": "继续 到 设置",
|
||||||
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
|
|
||||||
"convert_to_multiple_choice": "转换为 多选",
|
"convert_to_multiple_choice": "转换为 多选",
|
||||||
"convert_to_single_choice": "转换为 单选",
|
"convert_to_single_choice": "转换为 单选",
|
||||||
"country": "国家",
|
"country": "国家",
|
||||||
@@ -1367,7 +1360,7 @@
|
|||||||
"hide_question_settings": "隐藏问题设置",
|
"hide_question_settings": "隐藏问题设置",
|
||||||
"hostname": "主 机 名",
|
"hostname": "主 机 名",
|
||||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
|
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
|
||||||
"if_you_need_more_please": "如果你需要更多,请",
|
"if_you_need_more_please": "如果您需要更多,请",
|
||||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
|
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
|
||||||
"ignore_global_waiting_time": "忽略冷却期",
|
"ignore_global_waiting_time": "忽略冷却期",
|
||||||
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "键",
|
"key": "键",
|
||||||
"last_name": "姓",
|
"last_name": "姓",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "允许 人们 同时 上传 最多 25 个 文件",
|
"let_people_upload_up_to_25_files_at_the_same_time": "允许 人们 同时 上传 最多 25 个 文件",
|
||||||
"limit_file_types": "限制 文件 类型",
|
"limit_the_maximum_file_size": "限制上传文件的最大大小。",
|
||||||
"limit_the_maximum_file_size": "限制 最大 文件 大小",
|
"limit_upload_file_size_to": "将上传文件大小限制为",
|
||||||
"limit_upload_file_size_to": "将 上传 文件 大小 限制 为",
|
|
||||||
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
|
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
|
||||||
"load_segment": "载入 段落",
|
"load_segment": "载入 段落",
|
||||||
"logic_error_warning": "更改 将 导致 逻辑 错误",
|
"logic_error_warning": "更改 将 导致 逻辑 错误",
|
||||||
@@ -1418,8 +1410,8 @@
|
|||||||
"manage_languages": "管理 语言",
|
"manage_languages": "管理 语言",
|
||||||
"matrix_all_fields": "所有字段",
|
"matrix_all_fields": "所有字段",
|
||||||
"matrix_rows": "行",
|
"matrix_rows": "行",
|
||||||
"max_file_size": "最大 文件 大小",
|
"max_file_size": "最大文件大小",
|
||||||
"max_file_size_limit_is": "最大 文件 大小 限制 是",
|
"max_file_size_limit_is": "最大文件大小限制为",
|
||||||
"move_question_to_block": "将问题移动到区块",
|
"move_question_to_block": "将问题移动到区块",
|
||||||
"multiply": "乘 *",
|
"multiply": "乘 *",
|
||||||
"needed_for_self_hosted_cal_com_instance": "需要用于 自建 Cal.com 实例",
|
"needed_for_self_hosted_cal_com_instance": "需要用于 自建 Cal.com 实例",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "图片 {idx}",
|
"picture_idx": "图片 {idx}",
|
||||||
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
|
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
|
||||||
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
|
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
|
||||||
"please_enter_a_file_extension": "请输入 文件 扩展名。",
|
|
||||||
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com )",
|
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com )",
|
||||||
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
|
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
|
||||||
"please_specify": "请 指定",
|
"please_specify": "请 指定",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "搜索 图片",
|
"search_for_images": "搜索 图片",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "触发后 如果 没有 应答 将 在 几秒 后 关闭 调查",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "触发后 如果 没有 应答 将 在 几秒 后 关闭 调查",
|
||||||
"seconds_before_showing_the_survey": "显示问卷前 几秒",
|
"seconds_before_showing_the_survey": "显示问卷前 几秒",
|
||||||
|
"select_field": "选择字段",
|
||||||
"select_or_type_value": "选择 或 输入 值",
|
"select_or_type_value": "选择 或 输入 值",
|
||||||
"select_ordering": "选择排序",
|
"select_ordering": "选择排序",
|
||||||
"select_saved_action": "选择 保存的 操作",
|
"select_saved_action": "选择 保存的 操作",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "仅显示一次,即使他们未回应。",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "仅显示一次,即使他们未回应。",
|
||||||
"then": "然后",
|
"then": "然后",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
|
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
|
||||||
"this_extension_is_already_added": "此扩展已经添加。",
|
|
||||||
"this_file_type_is_not_supported": "此 文件 类型 不 支持。",
|
|
||||||
"three_points": "3 分",
|
"three_points": "3 分",
|
||||||
"times": "次数",
|
"times": "次数",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"upper_label": "上限标签",
|
"upper_label": "上限标签",
|
||||||
"url_filters": "URL 过滤器",
|
"url_filters": "URL 过滤器",
|
||||||
"url_not_supported": "URL 不支持",
|
"url_not_supported": "URL 不支持",
|
||||||
|
"validation": {
|
||||||
|
"characters": "字符",
|
||||||
|
"contains": "包含",
|
||||||
|
"does_not_contain": "不包含",
|
||||||
|
"email": "是有效的邮箱地址",
|
||||||
|
"file_extension_is": "文件扩展名为",
|
||||||
|
"file_extension_is_not": "文件扩展名不是",
|
||||||
|
"is": "等于",
|
||||||
|
"is_between": "介于",
|
||||||
|
"is_earlier_than": "早于",
|
||||||
|
"is_greater_than": "大于",
|
||||||
|
"is_later_than": "晚于",
|
||||||
|
"is_less_than": "小于",
|
||||||
|
"is_not": "不等于",
|
||||||
|
"is_not_between": "不介于",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "最多",
|
||||||
|
"max_selections": "最多",
|
||||||
|
"max_value": "最多",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "至少",
|
||||||
|
"min_selections": "至少",
|
||||||
|
"min_value": "至少",
|
||||||
|
"minimum_options_ranked": "最少排序选项数",
|
||||||
|
"minimum_rows_answered": "最少回答行数",
|
||||||
|
"options_selected": "已选择的选项",
|
||||||
|
"pattern": "匹配正则表达式模式",
|
||||||
|
"phone": "是有效的手机号",
|
||||||
|
"rank_all_options": "对所有选项进行排序",
|
||||||
|
"select_file_extensions": "选择文件扩展名...",
|
||||||
|
"url": "是有效的URL"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "全部为真",
|
||||||
|
"validation_logic_or": "任一为真",
|
||||||
|
"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": "变量名已被占用,请选择其他。",
|
||||||
|
|||||||
@@ -239,7 +239,6 @@
|
|||||||
"imprint": "版本訊息",
|
"imprint": "版本訊息",
|
||||||
"in_progress": "進行中",
|
"in_progress": "進行中",
|
||||||
"inactive_surveys": "停用中的問卷",
|
"inactive_surveys": "停用中的問卷",
|
||||||
"input_type": "輸入類型",
|
|
||||||
"integration": "整合",
|
"integration": "整合",
|
||||||
"integrations": "整合",
|
"integrations": "整合",
|
||||||
"invalid_date": "無效日期",
|
"invalid_date": "無效日期",
|
||||||
@@ -263,13 +262,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": "糟糕 ,偵測到小螢幕!",
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
"placeholder": "提示文字",
|
"placeholder": "提示文字",
|
||||||
"please_select_at_least_one_survey": "請選擇至少一個問卷",
|
"please_select_at_least_one_survey": "請選擇至少一個問卷",
|
||||||
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
|
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
|
||||||
"please_upgrade_your_plan": "請升級您的方案。",
|
"please_upgrade_your_plan": "請升級您的方案",
|
||||||
"preview": "預覽",
|
"preview": "預覽",
|
||||||
"preview_survey": "預覽問卷",
|
"preview_survey": "預覽問卷",
|
||||||
"privacy": "隱私權政策",
|
"privacy": "隱私權政策",
|
||||||
@@ -1166,7 +1163,6 @@
|
|||||||
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
|
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
|
||||||
"adjust_the_theme_in_the": "在",
|
"adjust_the_theme_in_the": "在",
|
||||||
"all_other_answers_will_continue_to": "所有其他答案將繼續",
|
"all_other_answers_will_continue_to": "所有其他答案將繼續",
|
||||||
"allow_file_type": "允許檔案類型",
|
|
||||||
"allow_multi_select": "允許多重選取",
|
"allow_multi_select": "允許多重選取",
|
||||||
"allow_multiple_files": "允許上傳多個檔案",
|
"allow_multiple_files": "允許上傳多個檔案",
|
||||||
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
|
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
|
||||||
@@ -1229,8 +1225,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": "選擇此區塊的第一個問題",
|
||||||
@@ -1250,7 +1244,6 @@
|
|||||||
"contact_fields": "聯絡人欄位",
|
"contact_fields": "聯絡人欄位",
|
||||||
"contains": "包含",
|
"contains": "包含",
|
||||||
"continue_to_settings": "繼續設定",
|
"continue_to_settings": "繼續設定",
|
||||||
"control_which_file_types_can_be_uploaded": "控制可以上傳哪些檔案類型。",
|
|
||||||
"convert_to_multiple_choice": "轉換為多選",
|
"convert_to_multiple_choice": "轉換為多選",
|
||||||
"convert_to_single_choice": "轉換為單選",
|
"convert_to_single_choice": "轉換為單選",
|
||||||
"country": "國家/地區",
|
"country": "國家/地區",
|
||||||
@@ -1404,9 +1397,8 @@
|
|||||||
"key": "金鑰",
|
"key": "金鑰",
|
||||||
"last_name": "姓氏",
|
"last_name": "姓氏",
|
||||||
"let_people_upload_up_to_25_files_at_the_same_time": "允許使用者同時上傳最多 25 個檔案。",
|
"let_people_upload_up_to_25_files_at_the_same_time": "允許使用者同時上傳最多 25 個檔案。",
|
||||||
"limit_file_types": "限制檔案類型",
|
"limit_the_maximum_file_size": "限制上傳檔案的最大大小。",
|
||||||
"limit_the_maximum_file_size": "限制最大檔案大小",
|
"limit_upload_file_size_to": "將上傳檔案大小限制為",
|
||||||
"limit_upload_file_size_to": "限制上傳檔案大小為",
|
|
||||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||||
"load_segment": "載入區隔",
|
"load_segment": "載入區隔",
|
||||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||||
@@ -1451,7 +1443,6 @@
|
|||||||
"picture_idx": "圖片 '{'idx'}'",
|
"picture_idx": "圖片 '{'idx'}'",
|
||||||
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
|
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
|
||||||
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
|
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
|
||||||
"please_enter_a_file_extension": "請輸入檔案副檔名。",
|
|
||||||
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com)",
|
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com)",
|
||||||
"please_set_a_survey_trigger": "請設定問卷觸發器",
|
"please_set_a_survey_trigger": "請設定問卷觸發器",
|
||||||
"please_specify": "請指定",
|
"please_specify": "請指定",
|
||||||
@@ -1529,6 +1520,7 @@
|
|||||||
"search_for_images": "搜尋圖片",
|
"search_for_images": "搜尋圖片",
|
||||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "如果沒有回應,則在觸發後幾秒關閉問卷",
|
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "如果沒有回應,則在觸發後幾秒關閉問卷",
|
||||||
"seconds_before_showing_the_survey": "秒後顯示問卷。",
|
"seconds_before_showing_the_survey": "秒後顯示問卷。",
|
||||||
|
"select_field": "選擇欄位",
|
||||||
"select_or_type_value": "選取或輸入值",
|
"select_or_type_value": "選取或輸入值",
|
||||||
"select_ordering": "選取排序",
|
"select_ordering": "選取排序",
|
||||||
"select_saved_action": "選取已儲存的操作",
|
"select_saved_action": "選取已儲存的操作",
|
||||||
@@ -1576,8 +1568,6 @@
|
|||||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "僅顯示一次,即使他們未回應。",
|
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "僅顯示一次,即使他們未回應。",
|
||||||
"then": "然後",
|
"then": "然後",
|
||||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
|
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
|
||||||
"this_extension_is_already_added": "已新增此擴充功能。",
|
|
||||||
"this_file_type_is_not_supported": "不支援此檔案類型。",
|
|
||||||
"three_points": "3 分",
|
"three_points": "3 分",
|
||||||
"times": "次",
|
"times": "次",
|
||||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
|
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
|
||||||
@@ -1598,6 +1588,42 @@
|
|||||||
"upper_label": "上標籤",
|
"upper_label": "上標籤",
|
||||||
"url_filters": "網址篩選器",
|
"url_filters": "網址篩選器",
|
||||||
"url_not_supported": "不支援網址",
|
"url_not_supported": "不支援網址",
|
||||||
|
"validation": {
|
||||||
|
"characters": "字元",
|
||||||
|
"contains": "包含",
|
||||||
|
"does_not_contain": "不包含",
|
||||||
|
"email": "是有效的電子郵件",
|
||||||
|
"file_extension_is": "檔案副檔名為",
|
||||||
|
"file_extension_is_not": "檔案副檔名不是",
|
||||||
|
"is": "等於",
|
||||||
|
"is_between": "介於",
|
||||||
|
"is_earlier_than": "早於",
|
||||||
|
"is_greater_than": "大於",
|
||||||
|
"is_later_than": "晚於",
|
||||||
|
"is_less_than": "小於",
|
||||||
|
"is_not": "不等於",
|
||||||
|
"is_not_between": "不介於",
|
||||||
|
"kb": "KB",
|
||||||
|
"max_length": "最多",
|
||||||
|
"max_selections": "最多",
|
||||||
|
"max_value": "最多",
|
||||||
|
"mb": "MB",
|
||||||
|
"min_length": "至少",
|
||||||
|
"min_selections": "至少",
|
||||||
|
"min_value": "至少",
|
||||||
|
"minimum_options_ranked": "最少排序選項數",
|
||||||
|
"minimum_rows_answered": "最少作答列數",
|
||||||
|
"options_selected": "已選擇的選項",
|
||||||
|
"pattern": "符合正則表達式樣式",
|
||||||
|
"phone": "是有效的電話號碼",
|
||||||
|
"rank_all_options": "請為所有選項排序",
|
||||||
|
"select_file_extensions": "請選擇檔案副檔名...",
|
||||||
|
"url": "是有效的 URL"
|
||||||
|
},
|
||||||
|
"validation_logic_and": "全部為真",
|
||||||
|
"validation_logic_or": "任一為真",
|
||||||
|
"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": "已使用此變數名稱,請選擇另一個名稱。",
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ export const ElementFormInput = ({
|
|||||||
(currentElement &&
|
(currentElement &&
|
||||||
(id.includes(".")
|
(id.includes(".")
|
||||||
? // Handle nested properties
|
? // Handle nested properties
|
||||||
(currentElement[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
|
(currentElement[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
|
||||||
: // Original behavior
|
: // Original behavior
|
||||||
(currentElement[id as keyof TSurveyElement] as TI18nString))) ||
|
(currentElement[id as keyof TSurveyElement] as TI18nString))) ||
|
||||||
createI18nString("", surveyLanguageCodes)
|
createI18nString("", surveyLanguageCodes)
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -391,7 +391,7 @@ export const ElementFormInput = ({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<div className="mb-2 mt-3 flex items-center justify-between">
|
<div className="mt-3 mb-2 flex items-center justify-between">
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<Label htmlFor={id}>{label}</Label>
|
||||||
{id === "headline" && currentElement && updateElement && (
|
{id === "headline" && currentElement && updateElement && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -521,23 +521,8 @@ export const ElementFormInput = ({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<div className="mb-2 mt-3 flex items-center justify-between">
|
<div className="mt-3 mb-2 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
|
||||||
@@ -583,8 +568,9 @@ export const ElementFormInput = ({
|
|||||||
<div className="h-10 w-full"></div>
|
<div className="h-10 w-full"></div>
|
||||||
<div
|
<div
|
||||||
ref={highlightContainerRef}
|
ref={highlightContainerRef}
|
||||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${localSurvey.languages?.length > 1 ? "pr-24" : ""
|
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
|
||||||
}`}
|
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||||
|
}`}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
key={highlightedJSX.toString()}>
|
key={highlightedJSX.toString()}>
|
||||||
{highlightedJSX}
|
{highlightedJSX}
|
||||||
@@ -611,8 +597,9 @@ export const ElementFormInput = ({
|
|||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
className={`absolute top-0 text-black caret-black ${localSurvey.languages?.length > 1 ? "pr-24" : ""
|
className={`absolute top-0 text-black caret-black ${
|
||||||
} ${className}`}
|
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||||
|
} ${className}`}
|
||||||
isInvalid={
|
isInvalid={
|
||||||
isInvalid &&
|
isInvalid &&
|
||||||
text[usedLanguageCode]?.trim() === "" &&
|
text[usedLanguageCode]?.trim() === "" &&
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ 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 { 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 +160,16 @@ export const AddressElementForm = ({
|
|||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.Address}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ 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 { 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 +157,16 @@ export const ContactInfoElementForm = ({
|
|||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.ContactInfo}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ 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 { 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 +127,16 @@ export const DateElementForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.Date}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export const EditorCardMenu = ({
|
|||||||
choices: card.choices,
|
choices: card.choices,
|
||||||
type,
|
type,
|
||||||
logic: undefined,
|
logic: undefined,
|
||||||
|
validation: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -128,6 +129,7 @@ export const EditorCardMenu = ({
|
|||||||
buttonLabel,
|
buttonLabel,
|
||||||
backButtonLabel,
|
backButtonLabel,
|
||||||
logic: undefined,
|
logic: undefined,
|
||||||
|
validation: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { Project } from "@prisma/client";
|
import { Project } from "@prisma/client";
|
||||||
import { PlusIcon, XCircleIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { type JSX, useMemo, useState } from "react";
|
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 { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
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";
|
||||||
@@ -47,9 +47,8 @@ export const FileUploadElementForm = ({
|
|||||||
isStorageConfigured = true,
|
isStorageConfigured = true,
|
||||||
isExternalUrlsAllowed,
|
isExternalUrlsAllowed,
|
||||||
}: FileUploadFormProps): JSX.Element => {
|
}: FileUploadFormProps): JSX.Element => {
|
||||||
const [extension, setExtension] = useState("");
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isMaxSizeError, setMaxSizeError] = useState(false);
|
const [isMaxSizeError, setIsMaxSizeError] = useState(false);
|
||||||
const {
|
const {
|
||||||
billingInfo,
|
billingInfo,
|
||||||
error: billingInfoError,
|
error: billingInfoError,
|
||||||
@@ -57,62 +56,6 @@ export const FileUploadElementForm = ({
|
|||||||
} = useGetBillingInfo(project?.organizationId ?? "");
|
} = useGetBillingInfo(project?.organizationId ?? "");
|
||||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||||
|
|
||||||
const handleInputChange = (event) => {
|
|
||||||
setExtension(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addExtension = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
let rawExtension = extension.trim();
|
|
||||||
|
|
||||||
// Remove the dot at the start if it exists
|
|
||||||
if (rawExtension.startsWith(".")) {
|
|
||||||
rawExtension = rawExtension.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rawExtension) {
|
|
||||||
toast.error(t("environments.surveys.edit.please_enter_a_file_extension"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to lowercase before validation and adding
|
|
||||||
const modifiedExtension = rawExtension.toLowerCase() as TAllowedFileExtension;
|
|
||||||
|
|
||||||
const parsedExtensionResult = ZAllowedFileExtension.safeParse(modifiedExtension);
|
|
||||||
|
|
||||||
if (!parsedExtensionResult.success) {
|
|
||||||
// This error should now be less likely unless the extension itself is invalid (e.g., "exe")
|
|
||||||
toast.error(t("environments.surveys.edit.this_file_type_is_not_supported"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentExtensions = element.allowedFileExtensions || [];
|
|
||||||
|
|
||||||
// Check if the lowercase extension already exists
|
|
||||||
if (!currentExtensions.includes(modifiedExtension)) {
|
|
||||||
updateElement(elementIdx, {
|
|
||||||
allowedFileExtensions: [...currentExtensions, modifiedExtension],
|
|
||||||
});
|
|
||||||
setExtension(""); // Clear the input field
|
|
||||||
} else {
|
|
||||||
toast.error(t("environments.surveys.edit.this_extension_is_already_added"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeExtension = (event, index: number) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (element.allowedFileExtensions) {
|
|
||||||
const updatedExtensions = [...(element.allowedFileExtensions || [])];
|
|
||||||
updatedExtensions.splice(index, 1);
|
|
||||||
// Ensure array is set to undefined if empty, matching toggle behavior
|
|
||||||
updateElement(elementIdx, {
|
|
||||||
allowedFileExtensions: updatedExtensions.length > 0 ? updatedExtensions : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxSizeInMBLimit = useMemo(() => {
|
const maxSizeInMBLimit = useMemo(() => {
|
||||||
if (billingInfoError || billingInfoLoading || !billingInfo) {
|
if (billingInfoError || billingInfoLoading || !billingInfo) {
|
||||||
return 10;
|
return 10;
|
||||||
@@ -216,20 +159,20 @@ export const FileUploadElementForm = ({
|
|||||||
id="fileSizeLimit"
|
id="fileSizeLimit"
|
||||||
value={element.maxSizeInMB}
|
value={element.maxSizeInMB}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const parsedValue = parseInt(e.target.value, 10);
|
const parsedValue = Number.parseInt(e.target.value, 10);
|
||||||
|
|
||||||
if (isFormbricksCloud && parsedValue > maxSizeInMBLimit) {
|
if (isFormbricksCloud && parsedValue > maxSizeInMBLimit) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("environments.surveys.edit.max_file_size_limit_is")} ${maxSizeInMBLimit} MB`
|
`${t("environments.surveys.edit.max_file_size_limit_is")} ${maxSizeInMBLimit} MB`
|
||||||
);
|
);
|
||||||
setMaxSizeError(true);
|
setIsMaxSizeError(true);
|
||||||
updateElement(elementIdx, { maxSizeInMB: maxSizeInMBLimit });
|
updateElement(elementIdx, { maxSizeInMB: maxSizeInMBLimit });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateElement(elementIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
|
updateElement(elementIdx, { maxSizeInMB: Number.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>
|
||||||
@@ -247,49 +190,18 @@ export const FileUploadElementForm = ({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</AdvancedOptionToggle>
|
</AdvancedOptionToggle>
|
||||||
|
|
||||||
<AdvancedOptionToggle
|
|
||||||
isChecked={!!element.allowedFileExtensions}
|
|
||||||
onToggle={(checked) =>
|
|
||||||
updateElement(elementIdx, { allowedFileExtensions: checked ? [] : undefined })
|
|
||||||
}
|
|
||||||
htmlId="limitFileType"
|
|
||||||
title={t("environments.surveys.edit.limit_file_types")}
|
|
||||||
description={t("environments.surveys.edit.control_which_file_types_can_be_uploaded")}
|
|
||||||
childBorder
|
|
||||||
customContainerClass="p-0">
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex flex-row flex-wrap gap-2">
|
|
||||||
{element.allowedFileExtensions?.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item}
|
|
||||||
className="mb-2 flex h-8 items-center space-x-2 rounded-full bg-slate-200 px-2">
|
|
||||||
<p className="text-sm text-slate-800">{item}</p>
|
|
||||||
<Button
|
|
||||||
className="inline-flex px-0"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => removeExtension(e, index)}>
|
|
||||||
<XCircleIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
className="mr-2 w-20 rounded-md bg-white placeholder:text-sm"
|
|
||||||
placeholder=".pdf"
|
|
||||||
value={extension}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<Button size="sm" variant="secondary" onClick={(e) => addExtension(e)}>
|
|
||||||
{t("environments.surveys.edit.allow_file_type")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AdvancedOptionToggle>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.FileUpload}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
element={element}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ 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 { 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 +348,19 @@ export const MatrixElementForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!element.required && (
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.Matrix}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
element={element}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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 +399,19 @@ export const MultipleChoiceElementForm = ({
|
|||||||
surveyLanguageCodes={surveyLanguageCodes}
|
surveyLanguageCodes={surveyLanguageCodes}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && (
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.MultipleChoiceMulti}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
element={element}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
"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 { 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 +44,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 +125,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 +141,27 @@ export const OpenElementForm = ({
|
|||||||
customContainerClass="p-0"
|
customContainerClass="p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.OpenText}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
inputType={element.inputType ?? "text"}
|
||||||
|
onUpdateInputType={(newInputType) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
inputType: newInputType,
|
||||||
|
// Update placeholder if not already set
|
||||||
|
placeholder:
|
||||||
|
element.placeholder ||
|
||||||
|
createI18nString(getPlaceholderByInputType(newInputType), surveyLanguageCodes),
|
||||||
|
longAnswer: newInputType === "text",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ 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 { 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 +247,17 @@ export const RankingElementForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.Ranking}
|
||||||
|
validation={element.validation}
|
||||||
|
onUpdateValidation={(validation) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validation,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
element={element}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TValidationLogic } from "@formbricks/types/surveys/elements";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
|
||||||
|
interface ValidationLogicSelectorProps {
|
||||||
|
value: TValidationLogic;
|
||||||
|
onChange: (value: TValidationLogic) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationLogicSelector = ({ value, onChange }: ValidationLogicSelectorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<Select value={value} onValueChange={(val) => onChange(val as TValidationLogic)}>
|
||||||
|
<SelectTrigger className="h-8 w-fit bg-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="and">{t("environments.surveys.edit.validation_logic_and")}</SelectItem>
|
||||||
|
<SelectItem value="or">{t("environments.surveys.edit.validation_logic_or")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TAddressField, TContactInfoField } from "@formbricks/types/surveys/validation-rules";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
|
||||||
|
interface ValidationRuleFieldSelectorProps {
|
||||||
|
value: TAddressField | TContactInfoField | undefined;
|
||||||
|
onChange: (value: TAddressField | TContactInfoField | undefined) => void;
|
||||||
|
fieldOptions: { value: TAddressField | TContactInfoField; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationRuleFieldSelector = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
fieldOptions,
|
||||||
|
}: ValidationRuleFieldSelectorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value ?? ""}
|
||||||
|
onValueChange={(val) => onChange(val ? (val as TAddressField | TContactInfoField) : undefined)}>
|
||||||
|
<SelectTrigger className="h-9 min-w-[140px] bg-white">
|
||||||
|
<SelectValue placeholder={t("environments.surveys.edit.select_field")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions.map((field) => (
|
||||||
|
<SelectItem key={field.value} value={field.value}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { cn } from "@/modules/ui/lib/utils";
|
||||||
|
|
||||||
|
interface ValidationRuleInputTypeSelectorProps {
|
||||||
|
value: TSurveyOpenTextElementInputType;
|
||||||
|
onChange?: (value: TSurveyOpenTextElementInputType) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationRuleInputTypeSelector = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: ValidationRuleInputTypeSelectorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange ? (val) => onChange(val as TSurveyOpenTextElementInputType) : undefined}
|
||||||
|
disabled={disabled}>
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn("h-9 min-w-[120px]", disabled ? "cursor-not-allowed bg-slate-100" : "bg-white")}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">{t("common.text")}</SelectItem>
|
||||||
|
<SelectItem value="email">{t("common.email")}</SelectItem>
|
||||||
|
<SelectItem value="url">{t("common.url")}</SelectItem>
|
||||||
|
<SelectItem value="phone">{t("common.phone")}</SelectItem>
|
||||||
|
<SelectItem value="number">{t("common.number")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||||
|
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||||
|
import {
|
||||||
|
TSurveyElement,
|
||||||
|
TSurveyElementTypeEnum,
|
||||||
|
TSurveyOpenTextElementInputType,
|
||||||
|
} from "@formbricks/types/surveys/elements";
|
||||||
|
import {
|
||||||
|
TAddressField,
|
||||||
|
TContactInfoField,
|
||||||
|
TValidationRule,
|
||||||
|
TValidationRuleType,
|
||||||
|
} from "@formbricks/types/surveys/validation-rules";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
|
||||||
|
import { getAvailableRuleTypes, getRuleValue } from "../lib/validation-rules-utils";
|
||||||
|
import { ValidationRuleFieldSelector } from "./validation-rule-field-selector";
|
||||||
|
import { ValidationRuleInputTypeSelector } from "./validation-rule-input-type-selector";
|
||||||
|
import { ValidationRuleTypeSelector } from "./validation-rule-type-selector";
|
||||||
|
import { ValidationRuleUnitSelector } from "./validation-rule-unit-selector";
|
||||||
|
import { ValidationRuleValueInput } from "./validation-rule-value-input";
|
||||||
|
|
||||||
|
interface ValidationRuleRowProps {
|
||||||
|
rule: TValidationRule;
|
||||||
|
index: number;
|
||||||
|
elementType: TSurveyElementTypeEnum;
|
||||||
|
element?: TSurveyElement;
|
||||||
|
inputType?: TSurveyOpenTextElementInputType;
|
||||||
|
onInputTypeChange?: (inputType: TSurveyOpenTextElementInputType) => void;
|
||||||
|
fieldOptions: { value: TAddressField | TContactInfoField; label: string }[];
|
||||||
|
needsFieldSelector: boolean;
|
||||||
|
validationRules: TValidationRule[];
|
||||||
|
ruleLabels: Record<string, string>;
|
||||||
|
onFieldChange: (ruleId: string, field: TAddressField | TContactInfoField | undefined) => void;
|
||||||
|
onRuleTypeChange: (ruleId: string, newType: TValidationRuleType) => void;
|
||||||
|
onRuleValueChange: (ruleId: string, value: string) => void;
|
||||||
|
onFileExtensionChange: (ruleId: string, extensions: TAllowedFileExtension[]) => void;
|
||||||
|
onDelete: (ruleId: string) => void;
|
||||||
|
onAdd: (insertAfterIndex: number) => void;
|
||||||
|
canAddMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationRuleRow = ({
|
||||||
|
rule,
|
||||||
|
index,
|
||||||
|
elementType,
|
||||||
|
element,
|
||||||
|
inputType,
|
||||||
|
onInputTypeChange,
|
||||||
|
fieldOptions,
|
||||||
|
needsFieldSelector,
|
||||||
|
validationRules,
|
||||||
|
ruleLabels,
|
||||||
|
onFieldChange,
|
||||||
|
onRuleTypeChange,
|
||||||
|
onRuleValueChange,
|
||||||
|
onFileExtensionChange,
|
||||||
|
onDelete,
|
||||||
|
onAdd,
|
||||||
|
canAddMore,
|
||||||
|
}: ValidationRuleRowProps) => {
|
||||||
|
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)
|
||||||
|
// For address/contact info, filter by selected field
|
||||||
|
const ruleField = rule.field;
|
||||||
|
const otherAvailableTypes = getAvailableRuleTypes(
|
||||||
|
elementType,
|
||||||
|
validationRules.filter((r) => r.id !== rule.id),
|
||||||
|
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||||
|
ruleField
|
||||||
|
).filter((t) => t !== ruleType);
|
||||||
|
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
|
||||||
|
|
||||||
|
// Check if this is OpenText and first rule - show input type selector
|
||||||
|
const isOpenText = elementType === TSurveyElementTypeEnum.OpenText;
|
||||||
|
const isFirstRule = index === 0;
|
||||||
|
const showInputTypeSelector = isOpenText && isFirstRule;
|
||||||
|
|
||||||
|
const handleFileExtensionChange = (extensions: TAllowedFileExtension[]) => {
|
||||||
|
onFileExtensionChange(rule.id, extensions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
{/* Field Selector (for Address and Contact Info elements) */}
|
||||||
|
{needsFieldSelector && (
|
||||||
|
<ValidationRuleFieldSelector
|
||||||
|
value={rule.field}
|
||||||
|
onChange={(value) => onFieldChange(rule.id, value)}
|
||||||
|
fieldOptions={fieldOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Type Selector (only for OpenText, first rule) */}
|
||||||
|
{showInputTypeSelector && inputType !== undefined && onInputTypeChange && (
|
||||||
|
<ValidationRuleInputTypeSelector value={inputType} onChange={onInputTypeChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Type Display (disabled, for subsequent rules) */}
|
||||||
|
{isOpenText && !isFirstRule && inputType !== undefined && (
|
||||||
|
<ValidationRuleInputTypeSelector value={inputType} disabled />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rule Type Selector */}
|
||||||
|
<ValidationRuleTypeSelector
|
||||||
|
value={ruleType}
|
||||||
|
onChange={(value) => onRuleTypeChange(rule.id, value)}
|
||||||
|
availableTypes={availableTypesForSelect}
|
||||||
|
ruleLabels={ruleLabels}
|
||||||
|
needsValue={config.needsValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Value Input (if needed) */}
|
||||||
|
{config.needsValue && (
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<ValidationRuleValueInput
|
||||||
|
rule={rule}
|
||||||
|
ruleType={ruleType}
|
||||||
|
config={config}
|
||||||
|
currentValue={currentValue}
|
||||||
|
onChange={(value) => onRuleValueChange(rule.id, value)}
|
||||||
|
onFileExtensionChange={handleFileExtensionChange}
|
||||||
|
element={element}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Unit selector (if applicable) */}
|
||||||
|
{config.unitOptions && config.unitOptions.length > 0 && (
|
||||||
|
<ValidationRuleUnitSelector
|
||||||
|
value={config.unitOptions[0].value}
|
||||||
|
unitOptions={config.unitOptions}
|
||||||
|
ruleLabels={ruleLabels}
|
||||||
|
disabled={config.unitOptions.length === 1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(rule.id)}
|
||||||
|
className="shrink-0 bg-white"
|
||||||
|
aria-label="Delete validation rule">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
{canAddMore && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAdd(index)}
|
||||||
|
className="shrink-0 bg-white"
|
||||||
|
aria-label="Add validation rule">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { capitalize } from "lodash";
|
||||||
|
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface ValidationRuleTypeSelectorProps {
|
||||||
|
value: TValidationRuleType;
|
||||||
|
onChange: (value: TValidationRuleType) => void;
|
||||||
|
availableTypes: TValidationRuleType[];
|
||||||
|
ruleLabels: Record<string, string>;
|
||||||
|
needsValue: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationRuleTypeSelector = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
availableTypes,
|
||||||
|
ruleLabels,
|
||||||
|
needsValue,
|
||||||
|
}: ValidationRuleTypeSelectorProps) => {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={(val) => onChange(val as TValidationRuleType)}>
|
||||||
|
<SelectTrigger className={cn("bg-white", needsValue ? "min-w-[200px]" : "flex-1")}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTypes.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{capitalize(ruleLabels[RULE_TYPE_CONFIG[type].labelKey])}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { cn } from "@/modules/ui/lib/utils";
|
||||||
|
|
||||||
|
interface UnitOption {
|
||||||
|
value: string;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidationRuleUnitSelectorProps {
|
||||||
|
value: string;
|
||||||
|
unitOptions: UnitOption[];
|
||||||
|
ruleLabels: Record<string, string>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationRuleUnitSelector = ({
|
||||||
|
value,
|
||||||
|
unitOptions,
|
||||||
|
ruleLabels,
|
||||||
|
disabled = false,
|
||||||
|
}: ValidationRuleUnitSelectorProps) => {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={() => {}} disabled={disabled || unitOptions.length === 1}>
|
||||||
|
<SelectTrigger className={cn("h-9 min-w-[180px] flex-1 bg-white", disabled && "cursor-not-allowed")}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{unitOptions.map((unit) => (
|
||||||
|
<SelectItem key={unit.value} value={unit.value} className="truncate">
|
||||||
|
{ruleLabels[unit.labelKey]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ALLOWED_FILE_EXTENSIONS, TAllowedFileExtension } from "@formbricks/types/storage";
|
||||||
|
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import { TValidationRule, TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
|
||||||
|
|
||||||
|
interface ValidationRuleValueInputProps {
|
||||||
|
rule: TValidationRule;
|
||||||
|
ruleType: TValidationRuleType;
|
||||||
|
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType];
|
||||||
|
currentValue: number | string | undefined;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onFileExtensionChange: (extensions: TAllowedFileExtension[]) => void;
|
||||||
|
element?: TSurveyElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationRuleValueInput = ({
|
||||||
|
rule,
|
||||||
|
ruleType,
|
||||||
|
config,
|
||||||
|
currentValue,
|
||||||
|
onChange,
|
||||||
|
onFileExtensionChange,
|
||||||
|
element,
|
||||||
|
}: ValidationRuleValueInputProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Determine HTML input type for value inputs
|
||||||
|
let htmlInputType: "number" | "date" | "text" = "text";
|
||||||
|
if (config.valueType === "number") {
|
||||||
|
htmlInputType = "number";
|
||||||
|
} else if (
|
||||||
|
ruleType.startsWith("is") &&
|
||||||
|
(ruleType.includes("Later") || ruleType.includes("Earlier") || ruleType.includes("On"))
|
||||||
|
) {
|
||||||
|
htmlInputType = "date";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for date range inputs
|
||||||
|
if (ruleType === "isBetween" || ruleType === "isNotBetween") {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={(currentValue as string)?.split(",")?.[0] ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentEndDate = (currentValue as string)?.split(",")?.[1] ?? "";
|
||||||
|
onChange(`${e.target.value},${currentEndDate}`);
|
||||||
|
}}
|
||||||
|
placeholder="Start date"
|
||||||
|
className="h-9 flex-1 bg-white"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-500">and</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={(currentValue as string)?.split(",")?.[1] ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentStartDate = (currentValue as string)?.split(",")?.[0] ?? "";
|
||||||
|
onChange(`${currentStartDate},${e.target.value}`);
|
||||||
|
}}
|
||||||
|
placeholder="End date"
|
||||||
|
className="h-9 flex-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option selector for single select validation rules
|
||||||
|
if (config.valueType === "option") {
|
||||||
|
const optionValue = typeof currentValue === "string" ? currentValue : "";
|
||||||
|
return (
|
||||||
|
<Select value={optionValue} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="h-9 min-w-[200px] bg-white">
|
||||||
|
<SelectValue placeholder="Select option" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{element &&
|
||||||
|
"choices" in element &&
|
||||||
|
element.choices
|
||||||
|
.filter((choice) => choice.id !== "other" && choice.id !== "none" && "label" in choice)
|
||||||
|
.map((choice) => {
|
||||||
|
const choiceLabel =
|
||||||
|
"label" in choice
|
||||||
|
? choice.label.default || Object.values(choice.label)[0] || choice.id
|
||||||
|
: choice.id;
|
||||||
|
return (
|
||||||
|
<SelectItem key={choice.id} value={choice.id}>
|
||||||
|
{choiceLabel}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File extension MultiSelect
|
||||||
|
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
|
||||||
|
const extensionOptions = ALLOWED_FILE_EXTENSIONS.map((ext) => ({
|
||||||
|
value: ext,
|
||||||
|
label: `.${ext}`,
|
||||||
|
}));
|
||||||
|
const selectedExtensions = (rule.params as { extensions: string[] })?.extensions || [];
|
||||||
|
return (
|
||||||
|
<MultiSelect
|
||||||
|
options={extensionOptions}
|
||||||
|
value={selectedExtensions as TAllowedFileExtension[]}
|
||||||
|
onChange={onFileExtensionChange}
|
||||||
|
placeholder={t("environments.surveys.edit.validation.select_file_extensions")}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default text/number input
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type={htmlInputType}
|
||||||
|
value={currentValue ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={config.valuePlaceholder}
|
||||||
|
className="h-9 min-w-[80px] bg-white"
|
||||||
|
min={config.valueType === "number" ? 0 : ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { v7 as uuidv7 } from "uuid";
|
||||||
|
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||||
|
import {
|
||||||
|
TSurveyElement,
|
||||||
|
TSurveyElementTypeEnum,
|
||||||
|
TSurveyOpenTextElementInputType,
|
||||||
|
TValidationLogic,
|
||||||
|
} from "@formbricks/types/surveys/elements";
|
||||||
|
import {
|
||||||
|
TAddressField,
|
||||||
|
TContactInfoField,
|
||||||
|
TValidationRule,
|
||||||
|
TValidationRuleType,
|
||||||
|
} from "@formbricks/types/surveys/validation-rules";
|
||||||
|
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||||
|
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
|
||||||
|
import {
|
||||||
|
getAddressFields,
|
||||||
|
getContactInfoFields,
|
||||||
|
getDefaultRuleValue,
|
||||||
|
getRuleLabels,
|
||||||
|
parseRuleValue,
|
||||||
|
} from "../lib/validation-rules-helpers";
|
||||||
|
import { RULES_BY_INPUT_TYPE, createRuleParams, getAvailableRuleTypes } from "../lib/validation-rules-utils";
|
||||||
|
import { ValidationLogicSelector } from "./validation-logic-selector";
|
||||||
|
import { ValidationRuleRow } from "./validation-rule-row";
|
||||||
|
|
||||||
|
type TValidationField = TAddressField | TContactInfoField | undefined;
|
||||||
|
|
||||||
|
interface ValidationRulesEditorProps {
|
||||||
|
elementType: TSurveyElementTypeEnum;
|
||||||
|
validation?: { rules: TValidationRule[]; logic?: TValidationLogic };
|
||||||
|
onUpdateValidation: (validation: { rules: TValidationRule[]; logic: TValidationLogic }) => void;
|
||||||
|
element?: TSurveyElement;
|
||||||
|
// For OpenText: input type and callback to update it
|
||||||
|
inputType?: TSurveyOpenTextElementInputType;
|
||||||
|
onUpdateInputType?: (inputType: TSurveyOpenTextElementInputType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidationRulesEditor = ({
|
||||||
|
elementType,
|
||||||
|
validation,
|
||||||
|
onUpdateValidation,
|
||||||
|
element,
|
||||||
|
inputType,
|
||||||
|
onUpdateInputType,
|
||||||
|
}: ValidationRulesEditorProps) => {
|
||||||
|
const validationRules = validation?.rules ?? [];
|
||||||
|
const validationLogic = validation?.logic ?? "and";
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Field options for address and contact info elements
|
||||||
|
const isAddress = elementType === TSurveyElementTypeEnum.Address;
|
||||||
|
const isContactInfo = elementType === TSurveyElementTypeEnum.ContactInfo;
|
||||||
|
const needsFieldSelector = isAddress || isContactInfo;
|
||||||
|
|
||||||
|
let fieldOptions: { value: TAddressField | TContactInfoField; label: string }[] = [];
|
||||||
|
if (isAddress) {
|
||||||
|
fieldOptions = getAddressFields(t);
|
||||||
|
} else if (isContactInfo) {
|
||||||
|
fieldOptions = getContactInfoFields(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleLabels = getRuleLabels(t);
|
||||||
|
|
||||||
|
const isEnabled = validationRules.length > 0;
|
||||||
|
|
||||||
|
// For matrix elements, only show validation rules when element is not required
|
||||||
|
const shouldShowValidationRules =
|
||||||
|
elementType !== TSurveyElementTypeEnum.Matrix || (element && !element.required);
|
||||||
|
|
||||||
|
const handleEnable = () => {
|
||||||
|
// For address/contact info, get rules for first field
|
||||||
|
const defaultField = needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined;
|
||||||
|
const availableRules = getAvailableRuleTypes(
|
||||||
|
elementType,
|
||||||
|
[],
|
||||||
|
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||||
|
defaultField
|
||||||
|
);
|
||||||
|
if (availableRules.length > 0) {
|
||||||
|
const defaultRuleType = availableRules[0];
|
||||||
|
const config = RULE_TYPE_CONFIG[defaultRuleType];
|
||||||
|
const defaultValue = getDefaultRuleValue(config, element);
|
||||||
|
const newRule: TValidationRule = {
|
||||||
|
id: uuidv7(),
|
||||||
|
type: defaultRuleType,
|
||||||
|
params: createRuleParams(defaultRuleType, defaultValue),
|
||||||
|
// For address/contact info, set field to first available field if not set
|
||||||
|
field: needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined,
|
||||||
|
} as TValidationRule;
|
||||||
|
onUpdateValidation({ rules: [newRule], logic: validationLogic });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisable = () => {
|
||||||
|
onUpdateValidation({ rules: [], logic: validationLogic });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRule = (insertAfterIndex: number) => {
|
||||||
|
// For address/contact info, get rules for the field of the rule we're inserting after (or first field)
|
||||||
|
const insertAfterRule = validationRules[insertAfterIndex];
|
||||||
|
let fieldForNewRule: TValidationField;
|
||||||
|
if (insertAfterRule?.field) {
|
||||||
|
fieldForNewRule = insertAfterRule.field;
|
||||||
|
} else if (needsFieldSelector && fieldOptions.length > 0) {
|
||||||
|
fieldForNewRule = fieldOptions[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRules = getAvailableRuleTypes(
|
||||||
|
elementType,
|
||||||
|
validationRules,
|
||||||
|
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||||
|
fieldForNewRule
|
||||||
|
);
|
||||||
|
if (availableRules.length === 0) return;
|
||||||
|
|
||||||
|
const newRuleType = availableRules[0];
|
||||||
|
const config = RULE_TYPE_CONFIG[newRuleType];
|
||||||
|
const defaultValue = getDefaultRuleValue(config, element);
|
||||||
|
|
||||||
|
let defaultField: TValidationField;
|
||||||
|
if (needsFieldSelector && fieldOptions.length > 0) {
|
||||||
|
defaultField = fieldOptions[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRule: TValidationRule = {
|
||||||
|
id: uuidv7(),
|
||||||
|
type: newRuleType,
|
||||||
|
params: createRuleParams(newRuleType, defaultValue),
|
||||||
|
field: defaultField,
|
||||||
|
} as TValidationRule;
|
||||||
|
const newRules = [...validationRules];
|
||||||
|
newRules.splice(insertAfterIndex + 1, 0, newRule);
|
||||||
|
onUpdateValidation({ rules: newRules, logic: validationLogic });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRule = (ruleId: string) => {
|
||||||
|
const updated = validationRules.filter((r) => r.id !== ruleId);
|
||||||
|
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
|
||||||
|
const ruleToUpdate = validationRules.find((r) => r.id === ruleId);
|
||||||
|
if (!ruleToUpdate) return;
|
||||||
|
|
||||||
|
// For address/contact info, verify the new rule type is valid for the selected field
|
||||||
|
if (needsFieldSelector && ruleToUpdate.field) {
|
||||||
|
const availableRulesForField = getAvailableRuleTypes(
|
||||||
|
elementType,
|
||||||
|
validationRules.filter((r) => r.id !== ruleId),
|
||||||
|
undefined,
|
||||||
|
ruleToUpdate.field
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the new rule type is not available for this field, don't change it
|
||||||
|
if (!availableRulesForField.includes(newType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = validationRules.map((rule) => {
|
||||||
|
if (rule.id !== ruleId) return rule;
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
type: newType,
|
||||||
|
params: createRuleParams(newType),
|
||||||
|
} as TValidationRule;
|
||||||
|
});
|
||||||
|
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldChange = (ruleId: string, field: TValidationField) => {
|
||||||
|
const ruleToUpdate = validationRules.find((r) => r.id === ruleId);
|
||||||
|
if (!ruleToUpdate) return;
|
||||||
|
|
||||||
|
// If changing field, check if current rule type is still valid for the new field
|
||||||
|
// If not, change to first available rule type for that field
|
||||||
|
let updatedRule = { ...ruleToUpdate, field } as TValidationRule;
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
const availableRulesForField = getAvailableRuleTypes(
|
||||||
|
elementType,
|
||||||
|
validationRules.filter((r) => r.id !== ruleId),
|
||||||
|
undefined,
|
||||||
|
field
|
||||||
|
);
|
||||||
|
|
||||||
|
// If current rule type is not available for the new field, change it
|
||||||
|
if (!availableRulesForField.includes(ruleToUpdate.type) && availableRulesForField.length > 0) {
|
||||||
|
updatedRule = {
|
||||||
|
...updatedRule,
|
||||||
|
type: availableRulesForField[0],
|
||||||
|
params: createRuleParams(availableRulesForField[0]),
|
||||||
|
} as TValidationRule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = validationRules.map((rule) => {
|
||||||
|
if (rule.id !== ruleId) return rule;
|
||||||
|
return updatedRule;
|
||||||
|
});
|
||||||
|
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = parseRuleValue(ruleType, value, config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
params: createRuleParams(ruleType, parsedValue),
|
||||||
|
} as TValidationRule;
|
||||||
|
});
|
||||||
|
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileExtensionChange = (ruleId: string, extensions: TAllowedFileExtension[]) => {
|
||||||
|
const updated = validationRules.map((r) => {
|
||||||
|
if (r.id !== ruleId) return r;
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
params: {
|
||||||
|
extensions,
|
||||||
|
},
|
||||||
|
} as TValidationRule;
|
||||||
|
});
|
||||||
|
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input type change for OpenText
|
||||||
|
const handleInputTypeChange = (newInputType: TSurveyOpenTextElementInputType) => {
|
||||||
|
if (!onUpdateInputType) return;
|
||||||
|
|
||||||
|
// Update element input type
|
||||||
|
onUpdateInputType(newInputType);
|
||||||
|
|
||||||
|
// Filter out incompatible rules based on new input type
|
||||||
|
// Also remove redundant "email"/"url"/"phone" rules when inputType matches
|
||||||
|
const compatibleRules = RULES_BY_INPUT_TYPE[newInputType] ?? [];
|
||||||
|
const filteredRules = validationRules.filter((rule) => {
|
||||||
|
// Remove rules that aren't compatible with the new input type
|
||||||
|
if (!compatibleRules.includes(rule.type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Remove redundant validation rules when inputType matches
|
||||||
|
if (newInputType === "email" && rule.type === "email") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newInputType === "url" && rule.type === "url") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newInputType === "phone" && rule.type === "phone") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no compatible rules remain, add a default rule
|
||||||
|
if (filteredRules.length === 0 && compatibleRules.length > 0) {
|
||||||
|
const defaultRuleType = compatibleRules[0];
|
||||||
|
const config = RULE_TYPE_CONFIG[defaultRuleType];
|
||||||
|
let defaultValue: number | string | undefined = undefined;
|
||||||
|
if (config.needsValue && config.valueType === "number") {
|
||||||
|
defaultValue = 0;
|
||||||
|
} else if (config.needsValue && config.valueType === "text") {
|
||||||
|
defaultValue = "";
|
||||||
|
}
|
||||||
|
const defaultRule: TValidationRule = {
|
||||||
|
id: uuidv7(),
|
||||||
|
type: defaultRuleType,
|
||||||
|
params: createRuleParams(defaultRuleType, defaultValue),
|
||||||
|
} as TValidationRule;
|
||||||
|
onUpdateValidation({ rules: [defaultRule], logic: validationLogic });
|
||||||
|
} else if (filteredRules.length !== validationRules.length) {
|
||||||
|
onUpdateValidation({ rules: filteredRules, logic: validationLogic });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// For address/contact info, use first field if no rules exist, or use the field from last rule
|
||||||
|
let defaultField: TValidationField;
|
||||||
|
if (needsFieldSelector && validationRules.length > 0) {
|
||||||
|
defaultField = validationRules.at(-1)?.field;
|
||||||
|
} else if (needsFieldSelector && fieldOptions.length > 0) {
|
||||||
|
defaultField = fieldOptions[0].value;
|
||||||
|
} else {
|
||||||
|
defaultField = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRulesForAdd = getAvailableRuleTypes(
|
||||||
|
elementType,
|
||||||
|
validationRules,
|
||||||
|
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||||
|
defaultField
|
||||||
|
);
|
||||||
|
const canAddMore = availableRulesForAdd.length > 0;
|
||||||
|
|
||||||
|
// Don't show validation rules for required matrix elements
|
||||||
|
if (!shouldShowValidationRules) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* Validation Logic Selector - only show when there are 2+ rules */}
|
||||||
|
{validationRules.length >= 2 && (
|
||||||
|
<ValidationLogicSelector
|
||||||
|
value={validationLogic}
|
||||||
|
onChange={(value) => onUpdateValidation({ rules: validationRules, logic: value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{validationRules.map((rule, index) => (
|
||||||
|
<ValidationRuleRow
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
index={index}
|
||||||
|
elementType={elementType}
|
||||||
|
element={element}
|
||||||
|
inputType={inputType}
|
||||||
|
onInputTypeChange={handleInputTypeChange}
|
||||||
|
fieldOptions={fieldOptions}
|
||||||
|
needsFieldSelector={needsFieldSelector}
|
||||||
|
validationRules={validationRules}
|
||||||
|
ruleLabels={ruleLabels}
|
||||||
|
onFieldChange={handleFieldChange}
|
||||||
|
onRuleTypeChange={handleRuleTypeChange}
|
||||||
|
onRuleValueChange={handleRuleValueChange}
|
||||||
|
onFileExtensionChange={handleFileExtensionChange}
|
||||||
|
onDelete={handleDeleteRule}
|
||||||
|
onAdd={handleAddRule}
|
||||||
|
canAddMore={canAddMore}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AdvancedOptionToggle>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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[] = [
|
||||||
|
"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("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.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.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,160 @@
|
|||||||
|
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" | "option" | "ranking";
|
||||||
|
valuePlaceholder?: string;
|
||||||
|
unitOptions?: { value: string; labelKey: string }[];
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
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" }],
|
||||||
|
},
|
||||||
|
equals: {
|
||||||
|
labelKey: "is",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "Value",
|
||||||
|
},
|
||||||
|
doesNotEqual: {
|
||||||
|
labelKey: "is_not",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "Value",
|
||||||
|
},
|
||||||
|
contains: {
|
||||||
|
labelKey: "contains",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "Text",
|
||||||
|
},
|
||||||
|
doesNotContain: {
|
||||||
|
labelKey: "does_not_contain",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "Text",
|
||||||
|
},
|
||||||
|
isGreaterThan: {
|
||||||
|
labelKey: "is_greater_than",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "number",
|
||||||
|
valuePlaceholder: "0",
|
||||||
|
},
|
||||||
|
isLessThan: {
|
||||||
|
labelKey: "is_less_than",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "number",
|
||||||
|
valuePlaceholder: "100",
|
||||||
|
},
|
||||||
|
isLaterThan: {
|
||||||
|
labelKey: "is_later_than",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
isEarlierThan: {
|
||||||
|
labelKey: "is_earlier_than",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
isBetween: {
|
||||||
|
labelKey: "is_between",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "YYYY-MM-DD,YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
isNotBetween: {
|
||||||
|
labelKey: "is_not_between",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "YYYY-MM-DD,YYYY-MM-DD",
|
||||||
|
},
|
||||||
|
minRanked: {
|
||||||
|
labelKey: "minimum_options_ranked",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "number",
|
||||||
|
valuePlaceholder: "1",
|
||||||
|
},
|
||||||
|
rankAll: {
|
||||||
|
labelKey: "rank_all_options",
|
||||||
|
needsValue: false,
|
||||||
|
},
|
||||||
|
minRowsAnswered: {
|
||||||
|
labelKey: "minimum_rows_answered",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "number",
|
||||||
|
valuePlaceholder: "1",
|
||||||
|
},
|
||||||
|
fileExtensionIs: {
|
||||||
|
labelKey: "file_extension_is",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "Select extensions...",
|
||||||
|
},
|
||||||
|
fileExtensionIsNot: {
|
||||||
|
labelKey: "file_extension_is_not",
|
||||||
|
needsValue: true,
|
||||||
|
valueType: "text",
|
||||||
|
valuePlaceholder: "Select extensions...",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
|
import type {
|
||||||
|
TSurveyElement,
|
||||||
|
TSurveyMultipleChoiceElement,
|
||||||
|
TSurveyRankingElement,
|
||||||
|
} from "@formbricks/types/surveys/elements";
|
||||||
|
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
|
||||||
|
import {
|
||||||
|
getAddressFields,
|
||||||
|
getContactInfoFields,
|
||||||
|
getDefaultRuleValue,
|
||||||
|
getRuleLabels,
|
||||||
|
normalizeFileExtension,
|
||||||
|
parseRuleValue,
|
||||||
|
} from "./validation-rules-helpers";
|
||||||
|
|
||||||
|
// Mock translation function
|
||||||
|
const mockT = (key: string): string => key;
|
||||||
|
|
||||||
|
describe("getAddressFields", () => {
|
||||||
|
test("should return all address fields with correct labels", () => {
|
||||||
|
const fields = getAddressFields(mockT);
|
||||||
|
expect(fields).toHaveLength(6);
|
||||||
|
expect(fields.map((f) => f.value)).toEqual([
|
||||||
|
"addressLine1",
|
||||||
|
"addressLine2",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip",
|
||||||
|
"country",
|
||||||
|
]);
|
||||||
|
expect(fields[0].label).toBe("environments.surveys.edit.address_line_1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getContactInfoFields", () => {
|
||||||
|
test("should return all contact info fields with correct labels", () => {
|
||||||
|
const fields = getContactInfoFields(mockT);
|
||||||
|
expect(fields).toHaveLength(5);
|
||||||
|
expect(fields.map((f) => f.value)).toEqual(["firstName", "lastName", "email", "phone", "company"]);
|
||||||
|
expect(fields[0].label).toBe("environments.surveys.edit.first_name");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRuleLabels", () => {
|
||||||
|
test("should return all rule labels", () => {
|
||||||
|
const labels = getRuleLabels(mockT);
|
||||||
|
expect(labels).toHaveProperty("min_length");
|
||||||
|
expect(labels).toHaveProperty("max_length");
|
||||||
|
expect(labels).toHaveProperty("pattern");
|
||||||
|
expect(labels).toHaveProperty("email");
|
||||||
|
expect(labels).toHaveProperty("url");
|
||||||
|
expect(labels).toHaveProperty("phone");
|
||||||
|
expect(labels).toHaveProperty("min_value");
|
||||||
|
expect(labels).toHaveProperty("max_value");
|
||||||
|
expect(labels).toHaveProperty("min_selections");
|
||||||
|
expect(labels).toHaveProperty("max_selections");
|
||||||
|
expect(labels).toHaveProperty("characters");
|
||||||
|
expect(labels).toHaveProperty("options_selected");
|
||||||
|
expect(labels).toHaveProperty("is");
|
||||||
|
expect(labels).toHaveProperty("is_not");
|
||||||
|
expect(labels).toHaveProperty("contains");
|
||||||
|
expect(labels).toHaveProperty("does_not_contain");
|
||||||
|
expect(labels).toHaveProperty("is_greater_than");
|
||||||
|
expect(labels).toHaveProperty("is_less_than");
|
||||||
|
expect(labels).toHaveProperty("is_later_than");
|
||||||
|
expect(labels).toHaveProperty("is_earlier_than");
|
||||||
|
expect(labels).toHaveProperty("is_between");
|
||||||
|
expect(labels).toHaveProperty("is_not_between");
|
||||||
|
expect(labels).toHaveProperty("minimum_options_ranked");
|
||||||
|
expect(labels).toHaveProperty("rank_all_options");
|
||||||
|
expect(labels).toHaveProperty("minimum_rows_answered");
|
||||||
|
expect(labels).toHaveProperty("file_extension_is");
|
||||||
|
expect(labels).toHaveProperty("file_extension_is_not");
|
||||||
|
expect(labels).toHaveProperty("kb");
|
||||||
|
expect(labels).toHaveProperty("mb");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translation keys", () => {
|
||||||
|
const labels = getRuleLabels(mockT);
|
||||||
|
expect(labels.min_length).toBe("environments.surveys.edit.validation.min_length");
|
||||||
|
expect(labels.email).toBe("environments.surveys.edit.validation.email");
|
||||||
|
expect(labels.rank_all_options).toBe("environments.surveys.edit.validation.rank_all_options");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultRuleValue", () => {
|
||||||
|
test("should return undefined when config does not need value", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.email;
|
||||||
|
const value = getDefaultRuleValue(config);
|
||||||
|
expect(value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty string for text value type", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.pattern;
|
||||||
|
const value = getDefaultRuleValue(config);
|
||||||
|
expect(value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty string for equals rule (has valueType: text, not option)", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "multi1",
|
||||||
|
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||||
|
choices: [
|
||||||
|
{ id: "opt1", label: { default: "Option 1" } },
|
||||||
|
{ id: "opt2", label: { default: "Option 2" } },
|
||||||
|
{ id: "other", label: { default: "Other" } },
|
||||||
|
],
|
||||||
|
} as TSurveyMultipleChoiceElement;
|
||||||
|
|
||||||
|
const config = RULE_TYPE_CONFIG.equals;
|
||||||
|
const value = getDefaultRuleValue(config, element);
|
||||||
|
// equals has valueType: "text", not "option", so it returns "" (empty string for text type)
|
||||||
|
expect(value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty string when config valueType is text (not option)", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "multi1",
|
||||||
|
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||||
|
choices: [
|
||||||
|
{ id: "other", label: { default: "Other" } },
|
||||||
|
{ id: "none", label: { default: "None" } },
|
||||||
|
{ id: "opt1", label: { default: "Option 1" } },
|
||||||
|
],
|
||||||
|
} as TSurveyMultipleChoiceElement;
|
||||||
|
|
||||||
|
const config = RULE_TYPE_CONFIG.equals;
|
||||||
|
const value = getDefaultRuleValue(config, element);
|
||||||
|
// equals has valueType: "text", so it returns "" regardless of element choices
|
||||||
|
expect(value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty string when no valid choices found for option value type", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "multi1",
|
||||||
|
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||||
|
choices: [
|
||||||
|
{ id: "other", label: { default: "Other" } },
|
||||||
|
{ id: "none", label: { default: "None" } },
|
||||||
|
],
|
||||||
|
} as TSurveyMultipleChoiceElement;
|
||||||
|
|
||||||
|
const config = RULE_TYPE_CONFIG.equals;
|
||||||
|
const value = getDefaultRuleValue(config, element);
|
||||||
|
expect(value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty string for option value type when element is not provided", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.equals;
|
||||||
|
const value = getDefaultRuleValue(config);
|
||||||
|
expect(value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined for number value type (minRanked uses number, not ranking)", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "rank1",
|
||||||
|
type: TSurveyElementTypeEnum.Ranking,
|
||||||
|
choices: [
|
||||||
|
{ id: "opt1", label: { default: "Option 1" } },
|
||||||
|
{ id: "opt2", label: { default: "Option 2" } },
|
||||||
|
],
|
||||||
|
} as TSurveyRankingElement;
|
||||||
|
|
||||||
|
const config = RULE_TYPE_CONFIG.minRanked;
|
||||||
|
const value = getDefaultRuleValue(config, element);
|
||||||
|
// minRanked has valueType: "number", not "ranking", so it returns undefined
|
||||||
|
expect(value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined for number value type when element is not provided", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.minRanked;
|
||||||
|
const value = getDefaultRuleValue(config);
|
||||||
|
expect(value).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeFileExtension", () => {
|
||||||
|
test("should add dot prefix when missing", () => {
|
||||||
|
expect(normalizeFileExtension("pdf")).toBe(".pdf");
|
||||||
|
expect(normalizeFileExtension("jpg")).toBe(".jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not add dot prefix when already present", () => {
|
||||||
|
expect(normalizeFileExtension(".pdf")).toBe(".pdf");
|
||||||
|
expect(normalizeFileExtension(".jpg")).toBe(".jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle empty string", () => {
|
||||||
|
expect(normalizeFileExtension("")).toBe(".");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseRuleValue", () => {
|
||||||
|
test("should normalize file extension for fileExtensionIs", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.fileExtensionIs;
|
||||||
|
const value = parseRuleValue("fileExtensionIs", "pdf", config);
|
||||||
|
expect(value).toBe(".pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should normalize file extension for fileExtensionIsNot", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.fileExtensionIsNot;
|
||||||
|
const value = parseRuleValue("fileExtensionIsNot", "jpg", config);
|
||||||
|
expect(value).toBe(".jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not add dot if already present for file extension", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.fileExtensionIs;
|
||||||
|
const value = parseRuleValue("fileExtensionIs", ".pdf", config);
|
||||||
|
expect(value).toBe(".pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse number for number value type", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.minLength;
|
||||||
|
const value = parseRuleValue("minLength", "10", config);
|
||||||
|
expect(value).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 0 for invalid number string", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.minLength;
|
||||||
|
const value = parseRuleValue("minLength", "invalid", config);
|
||||||
|
expect(value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return string as-is for text value type", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.pattern;
|
||||||
|
const value = parseRuleValue("pattern", "test-pattern", config);
|
||||||
|
expect(value).toBe("test-pattern");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return string as-is for equals rule", () => {
|
||||||
|
const config = RULE_TYPE_CONFIG.equals;
|
||||||
|
const value = parseRuleValue("equals", "test-value", config);
|
||||||
|
expect(value).toBe("test-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import {
|
||||||
|
TAddressField,
|
||||||
|
TContactInfoField,
|
||||||
|
TValidationRuleType,
|
||||||
|
} from "@formbricks/types/surveys/validation-rules";
|
||||||
|
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
|
||||||
|
|
||||||
|
// Field options for address elements
|
||||||
|
export const getAddressFields = (t: (key: string) => string): { value: TAddressField; label: string }[] => [
|
||||||
|
{ value: "addressLine1", label: t("environments.surveys.edit.address_line_1") },
|
||||||
|
{ value: "addressLine2", label: t("environments.surveys.edit.address_line_2") },
|
||||||
|
{ value: "city", label: t("environments.surveys.edit.city") },
|
||||||
|
{ value: "state", label: t("environments.surveys.edit.state") },
|
||||||
|
{ value: "zip", label: t("environments.surveys.edit.zip") },
|
||||||
|
{ value: "country", label: t("environments.surveys.edit.country") },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Field options for contact info elements
|
||||||
|
export const getContactInfoFields = (
|
||||||
|
t: (key: string) => string
|
||||||
|
): { value: TContactInfoField; label: string }[] => [
|
||||||
|
{ value: "firstName", label: t("environments.surveys.edit.first_name") },
|
||||||
|
{ value: "lastName", label: t("environments.surveys.edit.last_name") },
|
||||||
|
{ value: "email", label: t("common.email") },
|
||||||
|
{ value: "phone", label: t("common.phone") },
|
||||||
|
{ value: "company", label: t("environments.surveys.edit.company") },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rule labels mapping
|
||||||
|
export const getRuleLabels = (t: (key: string) => string): Record<string, string> => ({
|
||||||
|
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"),
|
||||||
|
is: t("environments.surveys.edit.validation.is"),
|
||||||
|
is_not: t("environments.surveys.edit.validation.is_not"),
|
||||||
|
contains: t("environments.surveys.edit.validation.contains"),
|
||||||
|
does_not_contain: t("environments.surveys.edit.validation.does_not_contain"),
|
||||||
|
is_greater_than: t("environments.surveys.edit.validation.is_greater_than"),
|
||||||
|
is_less_than: t("environments.surveys.edit.validation.is_less_than"),
|
||||||
|
is_later_than: t("environments.surveys.edit.validation.is_later_than"),
|
||||||
|
is_earlier_than: t("environments.surveys.edit.validation.is_earlier_than"),
|
||||||
|
is_between: t("environments.surveys.edit.validation.is_between"),
|
||||||
|
is_not_between: t("environments.surveys.edit.validation.is_not_between"),
|
||||||
|
minimum_options_ranked: t("environments.surveys.edit.validation.minimum_options_ranked"),
|
||||||
|
rank_all_options: t("environments.surveys.edit.validation.rank_all_options"),
|
||||||
|
minimum_rows_answered: t("environments.surveys.edit.validation.minimum_rows_answered"),
|
||||||
|
file_extension_is: t("environments.surveys.edit.validation.file_extension_is"),
|
||||||
|
file_extension_is_not: t("environments.surveys.edit.validation.file_extension_is_not"),
|
||||||
|
kb: t("environments.surveys.edit.validation.kb"),
|
||||||
|
mb: t("environments.surveys.edit.validation.mb"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to get default value for a validation rule based on its config and element
|
||||||
|
export const getDefaultRuleValue = (
|
||||||
|
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType],
|
||||||
|
element?: TSurveyElement
|
||||||
|
): number | string | undefined => {
|
||||||
|
if (!config.needsValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.valueType === "text") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.valueType === "option") {
|
||||||
|
if (element && "choices" in element) {
|
||||||
|
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
|
||||||
|
return firstChoice?.id ?? "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.valueType === "ranking") {
|
||||||
|
if (element && "choices" in element) {
|
||||||
|
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
|
||||||
|
return firstChoice ? `${firstChoice.id},1` : ",1";
|
||||||
|
}
|
||||||
|
return ",1";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to normalize file extension format
|
||||||
|
export const normalizeFileExtension = (value: string): string => {
|
||||||
|
return value.startsWith(".") ? value : `.${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to parse and validate rule value based on rule type
|
||||||
|
export const parseRuleValue = (
|
||||||
|
ruleType: TValidationRuleType,
|
||||||
|
value: string,
|
||||||
|
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType]
|
||||||
|
): string | number => {
|
||||||
|
// Handle file extension formatting: auto-add dot if missing
|
||||||
|
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
|
||||||
|
return normalizeFileExtension(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.valueType === "number") {
|
||||||
|
return Number(value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
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 text rules for openText element with text inputType when no rules exist", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules, "text");
|
||||||
|
|
||||||
|
expect(available).toContain("minLength");
|
||||||
|
expect(available).toContain("maxLength");
|
||||||
|
expect(available).toContain("pattern");
|
||||||
|
expect(available).not.toContain("email"); // Excluded - redundant
|
||||||
|
expect(available).not.toContain("url"); // Excluded - redundant
|
||||||
|
expect(available).not.toContain("phone"); // Excluded - redundant
|
||||||
|
expect(available).not.toContain("minValue"); // Only for number inputType
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return text rules for openText element with email inputType", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules, "email");
|
||||||
|
|
||||||
|
expect(available).toContain("minLength");
|
||||||
|
expect(available).toContain("maxLength");
|
||||||
|
expect(available).not.toContain("email"); // Excluded - redundant when inputType=email
|
||||||
|
expect(available).not.toContain("minValue"); // Only for number inputType
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return numeric rules for openText element with number inputType", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules, "number");
|
||||||
|
|
||||||
|
expect(available).toContain("minValue");
|
||||||
|
expect(available).toContain("maxValue");
|
||||||
|
expect(available).not.toContain("isGreaterThan"); // Removed - redundant with minValue
|
||||||
|
expect(available).not.toContain("isLessThan"); // Removed - redundant with maxValue
|
||||||
|
expect(available).not.toContain("minLength"); // Only for text inputType
|
||||||
|
expect(available).not.toContain("email"); // Excluded
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter out already added rules", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||||
|
const existingRules: TValidationRule[] = [
|
||||||
|
{
|
||||||
|
id: "rule2",
|
||||||
|
type: "minLength",
|
||||||
|
params: { min: 10 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules, "text");
|
||||||
|
|
||||||
|
expect(available).not.toContain("minLength");
|
||||||
|
expect(available).toContain("maxLength");
|
||||||
|
expect(available).toContain("pattern");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for multipleChoiceSingle element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return minSelections, maxSelections for multipleChoiceMulti element", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.MultipleChoiceMulti;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toContain("minSelections");
|
||||||
|
expect(available).toContain("maxSelections");
|
||||||
|
expect(available.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for rating element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.Rating;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for nps element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.NPS;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return date validation rules for date element", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.Date;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toContain("isLaterThan");
|
||||||
|
expect(available).toContain("isEarlierThan");
|
||||||
|
expect(available).toContain("isBetween");
|
||||||
|
expect(available).toContain("isNotBetween");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for consent element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.Consent;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return matrix validation rules for matrix element", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.Matrix;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toContain("minRowsAnswered");
|
||||||
|
expect(available.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return ranking validation rules for ranking element", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.Ranking;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toContain("minRanked");
|
||||||
|
expect(available).toContain("rankAll");
|
||||||
|
expect(available.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return file validation rules for fileUpload element", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.FileUpload;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toContain("fileExtensionIs");
|
||||||
|
expect(available).toContain("fileExtensionIsNot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for pictureSelection element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.PictureSelection;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for address element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.Address;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for contactInfo element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.ContactInfo;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array for cal element (no validation rules)", () => {
|
||||||
|
const elementType = TSurveyElementTypeEnum.Cal;
|
||||||
|
const existingRules: TValidationRule[] = [];
|
||||||
|
|
||||||
|
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||||
|
|
||||||
|
expect(available).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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 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,276 @@
|
|||||||
|
import { TSurveyElementTypeEnum, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
|
||||||
|
import {
|
||||||
|
APPLICABLE_RULES,
|
||||||
|
TAddressField,
|
||||||
|
TContactInfoField,
|
||||||
|
TValidationRule,
|
||||||
|
TValidationRuleType,
|
||||||
|
} from "@formbricks/types/surveys/validation-rules";
|
||||||
|
|
||||||
|
const stringRules: TValidationRuleType[] = [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rules applicable per field for Address elements
|
||||||
|
// General text fields don't support format-specific validators (email, url, phone)
|
||||||
|
export const RULES_BY_ADDRESS_FIELD: Record<TAddressField, TValidationRuleType[]> = {
|
||||||
|
addressLine1: stringRules,
|
||||||
|
addressLine2: stringRules,
|
||||||
|
city: stringRules,
|
||||||
|
state: stringRules,
|
||||||
|
zip: stringRules,
|
||||||
|
country: stringRules,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rules applicable per field for Contact Info elements
|
||||||
|
// Note: "email" and "phone" validation are automatically enforced for their respective fields
|
||||||
|
// and should not appear as selectable options in the UI
|
||||||
|
export const RULES_BY_CONTACT_INFO_FIELD: Record<TContactInfoField, TValidationRuleType[]> = {
|
||||||
|
firstName: stringRules,
|
||||||
|
lastName: stringRules,
|
||||||
|
email: stringRules,
|
||||||
|
phone: ["equals", "doesNotEqual", "contains", "doesNotContain"],
|
||||||
|
company: stringRules,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rules applicable per input type for OpenText
|
||||||
|
export const RULES_BY_INPUT_TYPE: Record<TSurveyOpenTextElementInputType, TValidationRuleType[]> = {
|
||||||
|
text: [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
// "email", "url", "phone" excluded - redundant for text inputType
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
// "email" rule excluded - redundant when inputType=email (HTML5 already validates)
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
],
|
||||||
|
url: [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
// "url" rule excluded - redundant when inputType=url (HTML5 already validates)
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
// "phone" rule excluded - redundant when inputType=phone (HTML5 already validates)
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
],
|
||||||
|
number: ["minValue", "maxValue", "equals", "doesNotEqual"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available rule types for an element type, excluding already added rules
|
||||||
|
* For OpenText elements, filters rules based on inputType
|
||||||
|
* For Address/ContactInfo elements, filters rules based on field
|
||||||
|
*/
|
||||||
|
export const getAvailableRuleTypes = (
|
||||||
|
elementType: TSurveyElementTypeEnum,
|
||||||
|
existingRules: TValidationRule[],
|
||||||
|
inputType?: TSurveyOpenTextElementInputType,
|
||||||
|
field?: TAddressField | TContactInfoField
|
||||||
|
): TValidationRuleType[] => {
|
||||||
|
const elementTypeKey = elementType.toString();
|
||||||
|
|
||||||
|
// For OpenText, use input-type-based filtering
|
||||||
|
if (elementType === TSurveyElementTypeEnum.OpenText && inputType) {
|
||||||
|
const applicable = RULES_BY_INPUT_TYPE[inputType] ?? [];
|
||||||
|
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||||
|
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Address elements, use field-based filtering
|
||||||
|
if (elementType === TSurveyElementTypeEnum.Address) {
|
||||||
|
if (!field) {
|
||||||
|
// Address elements require a field to be specified for validation rules
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const applicable = RULES_BY_ADDRESS_FIELD[field as TAddressField] ?? [];
|
||||||
|
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||||
|
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Contact Info elements, use field-based filtering
|
||||||
|
if (elementType === TSurveyElementTypeEnum.ContactInfo) {
|
||||||
|
if (!field) {
|
||||||
|
// Contact Info elements require a field to be specified for validation rules
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const applicable = RULES_BY_CONTACT_INFO_FIELD[field as TContactInfoField] ?? [];
|
||||||
|
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||||
|
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other element types, use standard filtering
|
||||||
|
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
|
||||||
|
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;
|
||||||
|
if ("min" in params) return params.min;
|
||||||
|
if ("max" in params) return params.max;
|
||||||
|
if ("pattern" in params) {
|
||||||
|
const pattern = params.pattern;
|
||||||
|
return pattern ?? "";
|
||||||
|
}
|
||||||
|
if ("value" in params) {
|
||||||
|
return params.value;
|
||||||
|
}
|
||||||
|
if ("date" in params) {
|
||||||
|
return params.date;
|
||||||
|
}
|
||||||
|
if ("startDate" in params && "endDate" in params) {
|
||||||
|
return `${params.startDate},${params.endDate}`;
|
||||||
|
}
|
||||||
|
if ("extensions" in params) {
|
||||||
|
// For file extension rules, return extensions array as comma-separated string for display
|
||||||
|
const extensions = params.extensions;
|
||||||
|
return extensions.length > 0 ? extensions.join(", ") : "";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper functions to create params for different rule types
|
||||||
|
*/
|
||||||
|
const createStringValueParams = (value?: number | string) => ({
|
||||||
|
value: value === undefined || value === null ? "" : String(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMinParams = (value?: number | string, defaultValue = 0) => ({
|
||||||
|
min: Number(value) || defaultValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMaxParams = (value?: number | string, defaultValue = 100) => ({
|
||||||
|
max: Number(value) || defaultValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDateParams = (value?: number | string) => ({
|
||||||
|
date: value === undefined || value === null ? "" : String(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDateRangeParams = (value?: number | string) => {
|
||||||
|
if (typeof value === "string" && value.includes(",")) {
|
||||||
|
const [startDate, endDate] = value.split(",");
|
||||||
|
return {
|
||||||
|
startDate: startDate?.trim() || "",
|
||||||
|
endDate: endDate?.trim() || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { startDate: "", endDate: "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFileExtensionParams = (value?: number | string) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return { extensions: value };
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.includes(",")) {
|
||||||
|
return { extensions: value.split(",").map((ext) => ext.trim()) };
|
||||||
|
}
|
||||||
|
const extensionValue = value === undefined || value === null ? "" : String(value);
|
||||||
|
return { extensions: extensionValue ? [extensionValue] : [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create params object from rule type and value (without type field)
|
||||||
|
*/
|
||||||
|
export const createRuleParams = (
|
||||||
|
ruleType: TValidationRuleType,
|
||||||
|
value?: number | string
|
||||||
|
): TValidationRule["params"] => {
|
||||||
|
// Rules that return empty params
|
||||||
|
if (ruleType === "email" || ruleType === "url" || ruleType === "phone" || ruleType === "rankAll") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules that use string value params
|
||||||
|
if (
|
||||||
|
ruleType === "equals" ||
|
||||||
|
ruleType === "doesNotEqual" ||
|
||||||
|
ruleType === "contains" ||
|
||||||
|
ruleType === "doesNotContain"
|
||||||
|
) {
|
||||||
|
return createStringValueParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules that use min params
|
||||||
|
if (
|
||||||
|
ruleType === "minLength" ||
|
||||||
|
ruleType === "minValue" ||
|
||||||
|
ruleType === "isGreaterThan" ||
|
||||||
|
ruleType === "minSelections" ||
|
||||||
|
ruleType === "minRanked" ||
|
||||||
|
ruleType === "minRowsAnswered"
|
||||||
|
) {
|
||||||
|
const defaultValue =
|
||||||
|
ruleType === "minSelections" || ruleType === "minRanked" || ruleType === "minRowsAnswered" ? 1 : 0;
|
||||||
|
return createMinParams(value, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules that use max params
|
||||||
|
if (
|
||||||
|
ruleType === "maxLength" ||
|
||||||
|
ruleType === "maxValue" ||
|
||||||
|
ruleType === "isLessThan" ||
|
||||||
|
ruleType === "maxSelections"
|
||||||
|
) {
|
||||||
|
const defaultValue = ruleType === "maxSelections" ? 3 : 100;
|
||||||
|
return createMaxParams(value, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules that use date params
|
||||||
|
if (ruleType === "isLaterThan" || ruleType === "isEarlierThan") {
|
||||||
|
return createDateParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules that use date range params
|
||||||
|
if (ruleType === "isBetween" || ruleType === "isNotBetween") {
|
||||||
|
return createDateRangeParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules that use file extension params
|
||||||
|
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
|
||||||
|
return createFileExtensionParams(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern rule
|
||||||
|
if (ruleType === "pattern") {
|
||||||
|
return { pattern: value === undefined || value === null ? "" : String(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Command as CommandPrimitive } from "cmdk";
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Command, CommandGroup, CommandItem, CommandList } from "@/modules/ui/components/command";
|
import { Command, CommandGroup, CommandItem, CommandList } from "@/modules/ui/components/command";
|
||||||
import { Badge } from "@/modules/ui/components/multi-select/badge";
|
import { Badge } from "@/modules/ui/components/multi-select/badge";
|
||||||
|
|
||||||
@@ -35,25 +36,62 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
|||||||
|
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [inputValue, setInputValue] = React.useState("");
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
|
const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Track if changes are user-initiated (not from value prop)
|
||||||
|
const isUserInitiatedRef = React.useRef(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setSelected(
|
const newSelected = value
|
||||||
value.map((val) => options.find((o) => o.value === val)).filter((o): o is TOption<T> => !!o)
|
.map((val) => options.find((o) => o.value === val))
|
||||||
);
|
.filter((o): o is TOption<T> => !!o);
|
||||||
|
// Only update if different (avoid unnecessary updates)
|
||||||
|
const currentValues = selected.map((s) => s.value);
|
||||||
|
const newValues = newSelected.map((s) => s.value);
|
||||||
|
if (
|
||||||
|
currentValues.length !== newValues.length ||
|
||||||
|
currentValues.some((val, idx) => val !== newValues[idx])
|
||||||
|
) {
|
||||||
|
isUserInitiatedRef.current = false; // Mark as prop-initiated
|
||||||
|
setSelected(newSelected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [value, options]);
|
}, [value, options]);
|
||||||
|
|
||||||
|
// Sync user-initiated selected changes to parent via onChange (deferred to avoid render issues)
|
||||||
|
const prevSelectedRef = React.useRef(selected);
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Only call onChange if change was user-initiated and selected actually changed
|
||||||
|
if (isUserInitiatedRef.current && prevSelectedRef.current !== selected) {
|
||||||
|
const selectedValues = selected.map((s) => s.value) as K;
|
||||||
|
const prevValues = prevSelectedRef.current.map((s) => s.value) as K;
|
||||||
|
// Check if values actually changed
|
||||||
|
if (
|
||||||
|
selectedValues.length !== prevValues.length ||
|
||||||
|
selectedValues.some((val, idx) => val !== prevValues[idx])
|
||||||
|
) {
|
||||||
|
// Use queueMicrotask to defer the onChange call after render
|
||||||
|
queueMicrotask(() => {
|
||||||
|
onChange?.(selectedValues);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevSelectedRef.current = selected;
|
||||||
|
isUserInitiatedRef.current = false; // Reset flag
|
||||||
|
} else if (!isUserInitiatedRef.current) {
|
||||||
|
// Update ref even if not user-initiated to track state
|
||||||
|
prevSelectedRef.current = selected;
|
||||||
|
}
|
||||||
|
}, [selected, onChange]);
|
||||||
|
|
||||||
const handleUnselect = React.useCallback(
|
const handleUnselect = React.useCallback(
|
||||||
(option: TOption<T>) => {
|
(option: TOption<T>) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
setSelected((prev) => {
|
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||||
const newSelected = prev.filter((s) => s.value !== option.value);
|
setSelected((prev) => prev.filter((s) => s.value !== option.value));
|
||||||
onChange?.(newSelected.map((s) => s.value) as K);
|
|
||||||
return newSelected;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[onChange, disabled]
|
[disabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = React.useCallback(
|
||||||
@@ -62,10 +100,10 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
|||||||
if (!input || disabled) return;
|
if (!input || disabled) return;
|
||||||
|
|
||||||
if ((e.key === "Delete" || e.key === "Backspace") && input.value === "") {
|
if ((e.key === "Delete" || e.key === "Backspace") && input.value === "") {
|
||||||
|
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const newSelected = [...prev];
|
const newSelected = [...prev];
|
||||||
newSelected.pop();
|
newSelected.pop();
|
||||||
onChange?.(newSelected.map((s) => s.value) as K);
|
|
||||||
return newSelected;
|
return newSelected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,11 +124,26 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
|||||||
});
|
});
|
||||||
}, [options, selected, inputValue]);
|
}, [options, selected, inputValue]);
|
||||||
|
|
||||||
|
// Calculate position for dropdown when opening
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open && containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
setPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 6,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: rect.width,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPosition(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command
|
<Command
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={`overflow-visible bg-white ${disabled ? "cursor-not-allowed opacity-50" : ""}`}>
|
className={`relative overflow-visible bg-white ${disabled ? "cursor-not-allowed opacity-50" : ""}`}>
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${
|
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${
|
||||||
disabled ? "pointer-events-none" : "focus-within:ring-ring"
|
disabled ? "pointer-events-none" : "focus-within:ring-ring"
|
||||||
}`}>
|
}`}>
|
||||||
@@ -126,34 +179,45 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{open && selectableOptions.length > 0 && !disabled && (
|
{open &&
|
||||||
<div className="relative mt-2">
|
selectableOptions.length > 0 &&
|
||||||
<CommandList className="border-0">
|
!disabled &&
|
||||||
<div className="text-popover-foreground animate-in absolute top-0 z-10 max-h-32 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
position &&
|
||||||
<CommandGroup className="h-full overflow-auto">
|
globalThis.window !== undefined &&
|
||||||
{selectableOptions.map((option) => (
|
createPortal(
|
||||||
<CommandItem
|
<div
|
||||||
key={option.value}
|
className="fixed z-[100]"
|
||||||
onMouseDown={(e) => {
|
style={{
|
||||||
e.preventDefault();
|
top: `${position.top}px`,
|
||||||
e.stopPropagation();
|
left: `${position.left}px`,
|
||||||
}}
|
width: `${position.width}px`,
|
||||||
onSelect={() => {
|
}}>
|
||||||
if (disabled) return;
|
<CommandList className="border-0">
|
||||||
const newSelected = [...selected, option];
|
<div className="text-popover-foreground animate-in max-h-32 w-full overflow-auto rounded-md border border-slate-300 bg-white shadow-md outline-none">
|
||||||
setSelected(newSelected);
|
<CommandGroup className="h-full overflow-auto">
|
||||||
onChange?.(newSelected.map((o) => o.value) as K);
|
{selectableOptions.map((option) => (
|
||||||
setInputValue("");
|
<CommandItem
|
||||||
}}
|
key={option.value}
|
||||||
className="cursor-pointer">
|
onMouseDown={(e) => {
|
||||||
{option.label}
|
e.preventDefault();
|
||||||
</CommandItem>
|
e.stopPropagation();
|
||||||
))}
|
}}
|
||||||
</CommandGroup>
|
onSelect={() => {
|
||||||
</div>
|
if (disabled) return;
|
||||||
</CommandList>
|
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||||
</div>
|
setSelected((prev) => [...prev, option]);
|
||||||
)}
|
setInputValue("");
|
||||||
|
}}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</div>
|
||||||
|
</CommandList>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</Command>
|
</Command>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import { useEffect, useState } from "react";
|
|||||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||||
import { getOrganizationBillingInfoAction } from "./actions";
|
import { getOrganizationBillingInfoAction } from "./actions";
|
||||||
|
|
||||||
export const useGetBillingInfo = (organizationId: string) => {
|
export const useGetBillingInfo = (organizationId: string | undefined) => {
|
||||||
const [billingInfo, setBillingInfo] = useState<TOrganizationBilling>();
|
const [billingInfo, setBillingInfo] = useState<TOrganizationBilling>();
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip fetching if organizationId is not provided
|
||||||
|
if (!organizationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const getBillingInfo = async () => {
|
const getBillingInfo = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import type { MigrationScript } from "../../src/scripts/migration-runner";
|
||||||
|
import type { MigrationStats, SurveyRecord } from "./types";
|
||||||
|
import { migrateSurveyBlocks } from "./utils";
|
||||||
|
|
||||||
|
export const migrateLegacyValidationToRules: MigrationScript = {
|
||||||
|
type: "data",
|
||||||
|
id: "clx8k9m2n0001l508xyz12345",
|
||||||
|
name: "20260113123112_migrate_legacy_validation_to_rules",
|
||||||
|
run: async ({ tx }) => {
|
||||||
|
// Initialize migration statistics
|
||||||
|
const stats: MigrationStats = {
|
||||||
|
totalSurveys: 0,
|
||||||
|
surveysProcessed: 0,
|
||||||
|
surveysSkipped: 0,
|
||||||
|
openTextElementsMigrated: 0,
|
||||||
|
fileUploadElementsMigrated: 0,
|
||||||
|
errors: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query to find surveys with elements that need migration
|
||||||
|
// Open Text: charLimit.enabled === true with min or max defined
|
||||||
|
// File Upload: allowedFileExtensions array with items
|
||||||
|
const surveysFindQuery = `
|
||||||
|
SELECT s.id, s.blocks
|
||||||
|
FROM "Survey" AS s
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM unnest(s.blocks) AS block
|
||||||
|
CROSS JOIN jsonb_array_elements(block->'elements') AS element
|
||||||
|
WHERE (
|
||||||
|
-- Open Text elements with charLimit.enabled = true
|
||||||
|
(element->>'type' = 'openText'
|
||||||
|
AND element->'charLimit'->>'enabled' = 'true'
|
||||||
|
AND (
|
||||||
|
(element->'charLimit'->>'min') IS NOT NULL
|
||||||
|
OR (element->'charLimit'->>'max') IS NOT NULL
|
||||||
|
))
|
||||||
|
OR
|
||||||
|
-- File Upload elements with allowedFileExtensions array
|
||||||
|
(element->>'type' = 'fileUpload'
|
||||||
|
AND element->'allowedFileExtensions' IS NOT NULL
|
||||||
|
AND jsonb_array_length(element->'allowedFileExtensions') > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const surveysNeedingMigration: SurveyRecord[] = await tx.$queryRaw`${Prisma.raw(surveysFindQuery)}`;
|
||||||
|
|
||||||
|
stats.totalSurveys = surveysNeedingMigration.length;
|
||||||
|
|
||||||
|
if (surveysNeedingMigration.length === 0) {
|
||||||
|
logger.info("No surveys found that need migration");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${surveysNeedingMigration.length.toString()} surveys to migrate`);
|
||||||
|
|
||||||
|
// Process surveys in batches
|
||||||
|
const SURVEY_BATCH_SIZE = 150;
|
||||||
|
const updates: { id: string; blocks: SurveyRecord["blocks"] }[] = [];
|
||||||
|
|
||||||
|
for (const survey of surveysNeedingMigration) {
|
||||||
|
// Deep clone blocks to avoid mutating original
|
||||||
|
const blocksCopy = JSON.parse(JSON.stringify(survey.blocks)) as SurveyRecord["blocks"];
|
||||||
|
|
||||||
|
// Migrate blocks - if this fails, the entire transaction will roll back
|
||||||
|
try {
|
||||||
|
migrateSurveyBlocks(blocksCopy);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `Failed to migrate survey ${survey.id}`);
|
||||||
|
throw new Error(
|
||||||
|
`Migration failed for survey ${survey.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
id: survey.id,
|
||||||
|
blocks: blocksCopy,
|
||||||
|
});
|
||||||
|
stats.surveysProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Processed ${updates.length.toString()} surveys: ${stats.surveysProcessed.toString()} modified`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update surveys in batches using UNNEST for performance
|
||||||
|
if (updates.length === 0) {
|
||||||
|
logger.info("No surveys needed updating (all already migrated)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < updates.length; i += SURVEY_BATCH_SIZE) {
|
||||||
|
const batch = updates.slice(i, i + SURVEY_BATCH_SIZE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build arrays for batch update
|
||||||
|
const ids = batch.map((u) => u.id);
|
||||||
|
const blocksJsonStrings = batch.map((u) => JSON.stringify(u.blocks));
|
||||||
|
|
||||||
|
// Use UNNEST to update multiple surveys in a single query
|
||||||
|
await tx.$executeRawUnsafe(
|
||||||
|
`UPDATE "Survey" AS s
|
||||||
|
SET
|
||||||
|
blocks = (
|
||||||
|
SELECT array_agg(elem)
|
||||||
|
FROM jsonb_array_elements(data.blocks_json::jsonb) AS elem
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
unnest($1::text[]) AS id,
|
||||||
|
unnest($2::text[]) AS blocks_json
|
||||||
|
) AS data
|
||||||
|
WHERE s.id = data.id`,
|
||||||
|
ids,
|
||||||
|
blocksJsonStrings
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedCount += batch.length;
|
||||||
|
|
||||||
|
// Log progress
|
||||||
|
logger.info(`Progress: ${updatedCount.toString()}/${updates.length.toString()} surveys updated`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `Failed to update survey batch starting at index ${i.toString()}`);
|
||||||
|
throw new Error(
|
||||||
|
`Database batch update failed at index ${i.toString()}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Migration complete: ${updatedCount.toString()} surveys migrated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log final statistics
|
||||||
|
logger.info(
|
||||||
|
`Migration complete: ${stats.totalSurveys.toString()} total surveys, ${stats.surveysProcessed.toString()} processed`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
export interface SurveyElement {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
charLimit?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
};
|
||||||
|
allowedFileExtensions?: string[];
|
||||||
|
validation?: {
|
||||||
|
rules: ValidationRule[];
|
||||||
|
logic?: "and" | "or";
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationRule {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
params: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
extensions?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
id: string;
|
||||||
|
elements: SurveyElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurveyRecord {
|
||||||
|
id: string;
|
||||||
|
blocks: Block[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationStats {
|
||||||
|
totalSurveys: number;
|
||||||
|
surveysProcessed: number;
|
||||||
|
surveysSkipped: number;
|
||||||
|
openTextElementsMigrated: number;
|
||||||
|
fileUploadElementsMigrated: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
import { v7 as uuidv7 } from "uuid";
|
||||||
|
import type { SurveyElement, ValidationRule } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a validation rule with matching type and params already exists
|
||||||
|
*/
|
||||||
|
export function hasMatchingRule(
|
||||||
|
rules: ValidationRule[],
|
||||||
|
type: string,
|
||||||
|
params: Record<string, unknown>
|
||||||
|
): boolean {
|
||||||
|
return rules.some((rule) => rule.type === type && JSON.stringify(rule.params) === JSON.stringify(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize validation object if it doesn't exist
|
||||||
|
*/
|
||||||
|
export function ensureValidationObject(element: SurveyElement): void {
|
||||||
|
if (!element.validation) {
|
||||||
|
element.validation = {
|
||||||
|
rules: [],
|
||||||
|
logic: "and",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!element.validation.rules) {
|
||||||
|
element.validation.rules = [];
|
||||||
|
}
|
||||||
|
if (!element.validation.logic) {
|
||||||
|
element.validation.logic = "and";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate Open Text element charLimit to validation rules
|
||||||
|
* Always removes legacy charLimit field after processing
|
||||||
|
* Throws error if transformation fails
|
||||||
|
*/
|
||||||
|
export function migrateOpenTextCharLimit(element: SurveyElement): void {
|
||||||
|
// Skip if charLimit is missing or not enabled (already migrated)
|
||||||
|
if (
|
||||||
|
!element.charLimit ||
|
||||||
|
element.charLimit.enabled !== true ||
|
||||||
|
(element.charLimit.min === undefined && element.charLimit.max === undefined)
|
||||||
|
) {
|
||||||
|
// Still remove legacy field if it exists but is disabled
|
||||||
|
if (element.charLimit) {
|
||||||
|
delete element.charLimit;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure validation object exists
|
||||||
|
ensureValidationObject(element);
|
||||||
|
|
||||||
|
const existingRules = element.validation!.rules;
|
||||||
|
|
||||||
|
// Migrate minLength if min is defined and valid
|
||||||
|
if (
|
||||||
|
element.charLimit.min !== undefined &&
|
||||||
|
element.charLimit.min >= 0 &&
|
||||||
|
!hasMatchingRule(existingRules, "minLength", { min: element.charLimit.min })
|
||||||
|
) {
|
||||||
|
const newRule: ValidationRule = {
|
||||||
|
id: uuidv7(),
|
||||||
|
type: "minLength",
|
||||||
|
params: { min: element.charLimit.min },
|
||||||
|
};
|
||||||
|
existingRules.push(newRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate maxLength if max is defined and valid
|
||||||
|
if (
|
||||||
|
element.charLimit.max !== undefined &&
|
||||||
|
element.charLimit.max >= 0 &&
|
||||||
|
!hasMatchingRule(existingRules, "maxLength", {
|
||||||
|
max: element.charLimit.max,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
const newRule: ValidationRule = {
|
||||||
|
id: uuidv7(),
|
||||||
|
type: "maxLength",
|
||||||
|
params: { max: element.charLimit.max },
|
||||||
|
};
|
||||||
|
existingRules.push(newRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always remove legacy charLimit field after processing
|
||||||
|
delete element.charLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two arrays contain the same extensions (order-independent)
|
||||||
|
*/
|
||||||
|
function extensionsMatch(extensions1: string[], extensions2: string[]): boolean {
|
||||||
|
if (extensions1.length !== extensions2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sorted1 = [...extensions1].sort();
|
||||||
|
const sorted2 = [...extensions2].sort();
|
||||||
|
return JSON.stringify(sorted1) === JSON.stringify(sorted2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate File Upload element allowedFileExtensions to validation rules
|
||||||
|
* Always removes legacy allowedFileExtensions field after processing
|
||||||
|
* Throws error if transformation fails
|
||||||
|
*/
|
||||||
|
export function migrateFileUploadExtensions(element: SurveyElement): void {
|
||||||
|
// Skip if allowedFileExtensions is missing or empty (already migrated)
|
||||||
|
if (
|
||||||
|
!element.allowedFileExtensions ||
|
||||||
|
!Array.isArray(element.allowedFileExtensions) ||
|
||||||
|
element.allowedFileExtensions.length === 0
|
||||||
|
) {
|
||||||
|
// Still remove legacy field if it exists but is empty
|
||||||
|
if (element.allowedFileExtensions) {
|
||||||
|
delete element.allowedFileExtensions;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure validation object exists
|
||||||
|
ensureValidationObject(element);
|
||||||
|
|
||||||
|
const existingRules = element.validation!.rules;
|
||||||
|
const extensions = element.allowedFileExtensions;
|
||||||
|
|
||||||
|
// Check if a matching fileExtensionIs rule already exists
|
||||||
|
const hasMatchingExtensionRule = existingRules.some(
|
||||||
|
(rule) =>
|
||||||
|
rule.type === "fileExtensionIs" &&
|
||||||
|
rule.params.extensions &&
|
||||||
|
Array.isArray(rule.params.extensions) &&
|
||||||
|
extensionsMatch(rule.params.extensions, extensions)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasMatchingExtensionRule) {
|
||||||
|
// Create new fileExtensionIs rule
|
||||||
|
const newRule: ValidationRule = {
|
||||||
|
id: uuidv7(),
|
||||||
|
type: "fileExtensionIs",
|
||||||
|
params: { extensions: [...extensions] },
|
||||||
|
};
|
||||||
|
existingRules.push(newRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always remove legacy allowedFileExtensions field after processing
|
||||||
|
delete element.allowedFileExtensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a single survey's blocks
|
||||||
|
* Throws error if any element fails to transform
|
||||||
|
*/
|
||||||
|
export function migrateSurveyBlocks(blocks: { id: string; elements: SurveyElement[] }[]): void {
|
||||||
|
for (const block of blocks) {
|
||||||
|
for (const element of block.elements) {
|
||||||
|
// Skip if element type is not OpenText or FileUpload
|
||||||
|
if (element.type !== "openText" && element.type !== "fileUpload") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate Open Text elements - throws if transformation fails
|
||||||
|
if (element.type === "openText") {
|
||||||
|
migrateOpenTextCharLimit(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate File Upload elements - throws if transformation fails
|
||||||
|
if (element.type === "fileUpload") {
|
||||||
|
migrateFileUploadExtensions(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
"@paralleldrive/cuid2": "2.2.2",
|
"@paralleldrive/cuid2": "2.2.2",
|
||||||
"@prisma/client": "6.14.0",
|
"@prisma/client": "6.14.0",
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
|
"uuid": "11.1.0",
|
||||||
"zod": "3.24.4",
|
"zod": "3.24.4",
|
||||||
"zod-openapi": "4.2.4"
|
"zod-openapi": "4.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function CTA({
|
|||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||||
|
|
||||||
{buttonExternal && (
|
{buttonExternal ? (
|
||||||
<div className="flex w-full justify-start">
|
<div className="flex w-full justify-start">
|
||||||
<Button
|
<Button
|
||||||
id={inputId}
|
id={inputId}
|
||||||
@@ -95,7 +95,7 @@ function CTA({
|
|||||||
<SquareArrowOutUpRightIcon className="size-4" />
|
<SquareArrowOutUpRightIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,41 +108,43 @@ function FormField({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Form Fields */}
|
{/* Form Fields */}
|
||||||
<div className="relative space-y-3">
|
<div className="relative">
|
||||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||||
{visibleFields.map((field) => {
|
<div className="space-y-3">
|
||||||
const fieldRequired = isFieldRequired(field);
|
{visibleFields.map((field) => {
|
||||||
const fieldValue = currentValues[field.id] ?? "";
|
const fieldRequired = isFieldRequired(field);
|
||||||
const fieldInputId = `${elementId}-${field.id}`;
|
const fieldValue = currentValues[field.id] ?? "";
|
||||||
|
const fieldInputId = `${elementId}-${field.id}`;
|
||||||
|
|
||||||
// Determine input type
|
// Determine input type
|
||||||
let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
|
let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
|
||||||
if (field.id === "email" && !field.type) {
|
if (field.id === "email" && !field.type) {
|
||||||
inputType = "email";
|
inputType = "email";
|
||||||
} else if (field.id === "phone" && !field.type) {
|
} else if (field.id === "phone" && !field.type) {
|
||||||
inputType = "tel";
|
inputType = "tel";
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.id} className="space-y-2">
|
<div key={field.id} className="space-y-2">
|
||||||
<Label htmlFor={fieldInputId} variant="default">
|
<Label htmlFor={fieldInputId} variant="default">
|
||||||
{fieldRequired ? `${field.label}*` : field.label}
|
{fieldRequired ? `${field.label}*` : field.label}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={fieldInputId}
|
id={fieldInputId}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
value={fieldValue}
|
value={fieldValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleFieldChange(field.id, e.target.value);
|
handleFieldChange(field.id, e.target.value);
|
||||||
}}
|
}}
|
||||||
required={fieldRequired}
|
required={fieldRequired}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
aria-invalid={Boolean(errorMessage) || undefined}
|
aria-invalid={Boolean(errorMessage) || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ checksums:
|
|||||||
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
|
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
|
||||||
common/people_responded: b685fb877090d8658db724ad07a0dbd8
|
common/people_responded: b685fb877090d8658db724ad07a0dbd8
|
||||||
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
|
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
|
||||||
common/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
|
||||||
common/powered_by: 6b6f88e2fa5a1ecec6cebf813abaeebb
|
common/powered_by: 6b6f88e2fa5a1ecec6cebf813abaeebb
|
||||||
common/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
common/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
||||||
common/protected_by_reCAPTCHA_and_the_Google: 32de026bff5d52e9edf5410d7d7b835f
|
common/protected_by_reCAPTCHA_and_the_Google: 32de026bff5d52e9edf5410d7d7b835f
|
||||||
@@ -31,6 +30,9 @@ checksums:
|
|||||||
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
||||||
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
||||||
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
||||||
|
errors/all_options_must_be_ranked: 360a2edff623496f7047907bad115ea1
|
||||||
|
errors/file_extension_must_be: 3102dc81a482f1b05ee490767b1c3c97
|
||||||
|
errors/file_extension_must_not_be: bd21065a4201d9a29b126586aecd8f29
|
||||||
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
|
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
|
||||||
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
|
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
|
||||||
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
|
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
|
||||||
@@ -40,14 +42,28 @@ checksums:
|
|||||||
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
|
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
|
||||||
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
||||||
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
|
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
|
||||||
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
|
errors/invalid_format: 66df570c79b420d66f3badaf5867e4ad
|
||||||
|
errors/is_between: 2e8e3d11ac315aed727fa3b9e0bc435b
|
||||||
|
errors/is_earlier_than: 377af5c84d09083f57580ddd8199cda8
|
||||||
|
errors/is_greater_than: ab6ff5c676478d66cd64930deb4f0fd1
|
||||||
|
errors/is_later_than: dc64e8fe79ade4b53843270e00957163
|
||||||
|
errors/is_less_than: e1d0b7e6292e8c775e9eee349fee6269
|
||||||
|
errors/is_not_between: 887b4ece0ad58c027fc35ef6b3f2d5e8
|
||||||
|
errors/max_length: cbaef9675c55b3229e6306eb2d78dbfd
|
||||||
|
errors/max_selections: b2682a6ce820681074d4a319b96f8dbd
|
||||||
|
errors/max_value: edfe2d387a283c2b2a48da251fcf6ade
|
||||||
|
errors/min_length: d08ea43d2cc9d6b2aee87ddba60dd2b3
|
||||||
|
errors/min_selections: 23a75f21b4cb0530e5cfd02650d3e3d7
|
||||||
|
errors/min_value: f52e2d54c7fc0e96d5e3f5d32b2618c7
|
||||||
|
errors/minimum_options_ranked: 7c06caceff53b590159d0e0a9a8bd812
|
||||||
|
errors/minimum_rows_answered: ca132ce930516cf5ce97c34c7a7ef3d3
|
||||||
errors/please_enter_a_valid_email_address: 8de4bc8832b11b380bc4cbcedc16e48b
|
errors/please_enter_a_valid_email_address: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||||
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
|
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
|
||||||
errors/please_enter_a_valid_url: e3bcfb605be4ee32aa19d9ac32bb11a4
|
errors/please_enter_a_valid_url: e3bcfb605be4ee32aa19d9ac32bb11a4
|
||||||
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
||||||
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
|
|
||||||
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
|
|
||||||
errors/please_select_an_option: 9fede3bb9ded29301e89b98616e3583a
|
|
||||||
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
|
|
||||||
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
|
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
|
||||||
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
|
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
|
||||||
|
errors/value_must_contain: cd555796372f06b42f42f6b304f98e92
|
||||||
|
errors/value_must_equal: 227b5c87bb7761714ab9bc5a8adc6607
|
||||||
|
errors/value_must_not_contain: 5349a544135c7d7fc6e01c4221004236
|
||||||
|
errors/value_must_not_equal: cb25ed72166baeeaaa263bd5cf1c0e1c
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "فتح في علامة تبويب جديدة",
|
"open_in_new_tab": "فتح في علامة تبويب جديدة",
|
||||||
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
|
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
|
||||||
"please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.",
|
"please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.",
|
||||||
"please_specify": "يرجى التحديد",
|
|
||||||
"powered_by": "مشغل بواسطة",
|
"powered_by": "مشغل بواسطة",
|
||||||
"privacy_policy": "سياسة الخصوصية",
|
"privacy_policy": "سياسة الخصوصية",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "محمي بواسطة reCAPTCHA و Google",
|
"protected_by_reCAPTCHA_and_the_Google": "محمي بواسطة reCAPTCHA و Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "تعليقك عالق :("
|
"your_feedback_is_stuck": "تعليقك عالق :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "يرجى ترتيب جميع الخيارات",
|
||||||
|
"file_extension_must_be": "يجب أن يكون امتداد الملف {extension}",
|
||||||
|
"file_extension_must_not_be": "يجب ألا يكون امتداد الملف {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "الملفات التالية تم تحميلها بالفعل: {duplicateNames}. لا يُسمح بالملفات المكررة.",
|
"duplicate_files": "الملفات التالية تم تحميلها بالفعل: {duplicateNames}. لا يُسمح بالملفات المكررة.",
|
||||||
"file_size_exceeded": "الملف (الملفات) التالية تتجاوز الحجم الأقصى البالغ {maxSizeInMB} ميجابايت وتم إزالتها: {fileNames}",
|
"file_size_exceeded": "الملف (الملفات) التالية تتجاوز الحجم الأقصى البالغ {maxSizeInMB} ميجابايت وتم إزالتها: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "يرجى تعطيل حماية البريد المزعج في إعدادات الاستبيان للاستمرار في استخدام هذا الجهاز.",
|
"message": "يرجى تعطيل حماية البريد المزعج في إعدادات الاستبيان للاستمرار في استخدام هذا الجهاز.",
|
||||||
"title": "هذا الجهاز لا يدعم حماية البريد المزعج."
|
"title": "هذا الجهاز لا يدعم حماية البريد المزعج."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "يرجى حجز موعد",
|
"invalid_format": "يرجى إدخال تنسيق صالح",
|
||||||
|
"is_between": "يرجى اختيار تاريخ بين {startDate} و {endDate}",
|
||||||
|
"is_earlier_than": "يرجى اختيار تاريخ أقدم من {date}",
|
||||||
|
"is_greater_than": "يرجى إدخال قيمة أكبر من {min}",
|
||||||
|
"is_later_than": "يرجى اختيار تاريخ أحدث من {date}",
|
||||||
|
"is_less_than": "يرجى إدخال قيمة أقل من {max}",
|
||||||
|
"is_not_between": "يرجى اختيار تاريخ ليس بين {startDate} و {endDate}",
|
||||||
|
"max_length": "يرجى إدخال {max} حرفًا كحد أقصى",
|
||||||
|
"max_selections": "يرجى اختيار {max} خيارات كحد أقصى",
|
||||||
|
"max_value": "يرجى إدخال قيمة لا تزيد عن {max}",
|
||||||
|
"min_length": "يرجى إدخال {min} أحرف على الأقل",
|
||||||
|
"min_selections": "يرجى اختيار {min} خيارات على الأقل",
|
||||||
|
"min_value": "يرجى إدخال قيمة لا تقل عن {min}",
|
||||||
|
"minimum_options_ranked": "يرجى ترتيب {min} خيارات على الأقل",
|
||||||
|
"minimum_rows_answered": "يرجى الإجابة على {min} صفوف على الأقل",
|
||||||
"please_enter_a_valid_email_address": "الرجاء إدخال عنوان بريد إلكتروني صالح",
|
"please_enter_a_valid_email_address": "الرجاء إدخال عنوان بريد إلكتروني صالح",
|
||||||
"please_enter_a_valid_phone_number": "يرجى إدخال رقم هاتف صحيح",
|
"please_enter_a_valid_phone_number": "يرجى إدخال رقم هاتف صحيح",
|
||||||
"please_enter_a_valid_url": "الرجاء إدخال عنوان URL صالح",
|
"please_enter_a_valid_url": "الرجاء إدخال عنوان URL صالح",
|
||||||
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
|
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
|
||||||
"please_rank_all_items_before_submitting": "يرجى ترتيب جميع العناصر قبل الإرسال",
|
|
||||||
"please_select_a_date": "يرجى اختيار تاريخ",
|
|
||||||
"please_select_an_option": "يرجى اختيار خيار",
|
|
||||||
"please_upload_a_file": "يرجى تحميل ملف",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "تعذر إرسال ردك لأنه تم تصنيفه كنشاط آلي. إذا كنت تتنفس، يرجى المحاولة مرة أخرى.",
|
"message": "تعذر إرسال ردك لأنه تم تصنيفه كنشاط آلي. إذا كنت تتنفس، يرجى المحاولة مرة أخرى.",
|
||||||
"title": "لم نتمكن من التحقق من أنك إنسان."
|
"title": "لم نتمكن من التحقق من أنك إنسان."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "يجب أن تحتوي القيمة على {value}",
|
||||||
|
"value_must_equal": "يجب أن تساوي القيمة {value}",
|
||||||
|
"value_must_not_contain": "يجب ألا تحتوي القيمة على {value}",
|
||||||
|
"value_must_not_equal": "يجب ألا تساوي القيمة {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "In neuem Tab öffnen",
|
"open_in_new_tab": "In neuem Tab öffnen",
|
||||||
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
|
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
|
||||||
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
|
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
|
||||||
"please_specify": "Bitte angeben",
|
|
||||||
"powered_by": "Bereitgestellt von",
|
"powered_by": "Bereitgestellt von",
|
||||||
"privacy_policy": "Datenschutzrichtlinie",
|
"privacy_policy": "Datenschutzrichtlinie",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Geschützt durch reCAPTCHA und die Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Geschützt durch reCAPTCHA und die Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
|
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Bitte ordnen Sie alle Optionen ein",
|
||||||
|
"file_extension_must_be": "Die Dateierweiterung muss {extension} sein",
|
||||||
|
"file_extension_must_not_be": "Die Dateierweiterung darf nicht {extension} sein",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Die folgenden Dateien sind bereits hochgeladen: {duplicateNames}. Doppelte Dateien sind nicht erlaubt.",
|
"duplicate_files": "Die folgenden Dateien sind bereits hochgeladen: {duplicateNames}. Doppelte Dateien sind nicht erlaubt.",
|
||||||
"file_size_exceeded": "Die folgenden Dateien überschreiten die maximale Größe von {maxSizeInMB} MB und wurden entfernt: {fileNames}",
|
"file_size_exceeded": "Die folgenden Dateien überschreiten die maximale Größe von {maxSizeInMB} MB und wurden entfernt: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Bitte deaktivieren Sie den Spam-Schutz in den Umfrageeinstellungen, um dieses Gerät weiterhin zu verwenden.",
|
"message": "Bitte deaktivieren Sie den Spam-Schutz in den Umfrageeinstellungen, um dieses Gerät weiterhin zu verwenden.",
|
||||||
"title": "Dieses Gerät unterstützt keinen Spam-Schutz."
|
"title": "Dieses Gerät unterstützt keinen Spam-Schutz."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Bitte vereinbaren Sie einen Termin",
|
"invalid_format": "Bitte geben Sie ein gültiges Format ein",
|
||||||
|
"is_between": "Bitte wählen Sie ein Datum zwischen {startDate} und {endDate}",
|
||||||
|
"is_earlier_than": "Bitte wählen Sie ein Datum vor dem {date}",
|
||||||
|
"is_greater_than": "Bitte geben Sie einen Wert größer als {min} ein",
|
||||||
|
"is_later_than": "Bitte wählen Sie ein Datum nach dem {date}",
|
||||||
|
"is_less_than": "Bitte geben Sie einen Wert kleiner als {max} ein",
|
||||||
|
"is_not_between": "Bitte wählen Sie ein Datum, das nicht zwischen {startDate} und {endDate} liegt",
|
||||||
|
"max_length": "Bitte geben Sie nicht mehr als {max} Zeichen ein",
|
||||||
|
"max_selections": "Bitte wählen Sie nicht mehr als {max} Optionen aus",
|
||||||
|
"max_value": "Bitte geben Sie einen Wert ein, der nicht größer als {max} ist",
|
||||||
|
"min_length": "Bitte geben Sie mindestens {min} Zeichen ein",
|
||||||
|
"min_selections": "Bitte wählen Sie mindestens {min} Optionen aus",
|
||||||
|
"min_value": "Bitte geben Sie einen Wert von mindestens {min} ein",
|
||||||
|
"minimum_options_ranked": "Bitte ordnen Sie mindestens {min} Optionen",
|
||||||
|
"minimum_rows_answered": "Bitte beantworten Sie mindestens {min} Zeilen",
|
||||||
"please_enter_a_valid_email_address": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
"please_enter_a_valid_email_address": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||||
"please_enter_a_valid_phone_number": "Bitte geben Sie eine gültige Telefonnummer ein",
|
"please_enter_a_valid_phone_number": "Bitte geben Sie eine gültige Telefonnummer ein",
|
||||||
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein",
|
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein",
|
||||||
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
|
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
|
||||||
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
|
|
||||||
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
|
|
||||||
"please_select_an_option": "Bitte wählen Sie eine Option",
|
|
||||||
"please_upload_a_file": "Bitte laden Sie eine Datei hoch",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Ihre Antwort konnte nicht übermittelt werden, da sie als automatisierte Aktivität eingestuft wurde. Wenn Sie atmen, versuchen Sie es bitte erneut.",
|
"message": "Ihre Antwort konnte nicht übermittelt werden, da sie als automatisierte Aktivität eingestuft wurde. Wenn Sie atmen, versuchen Sie es bitte erneut.",
|
||||||
"title": "Wir konnten nicht verifizieren, dass Sie ein Mensch sind."
|
"title": "Wir konnten nicht verifizieren, dass Sie ein Mensch sind."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Wert muss {value} enthalten",
|
||||||
|
"value_must_equal": "Wert muss {value} entsprechen",
|
||||||
|
"value_must_not_contain": "Wert darf {value} nicht enthalten",
|
||||||
|
"value_must_not_equal": "Wert darf nicht {value} entsprechen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Open in new tab",
|
"open_in_new_tab": "Open in new tab",
|
||||||
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
|
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
|
||||||
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
|
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
|
||||||
"please_specify": "Please specify",
|
|
||||||
"powered_by": "Powered by",
|
"powered_by": "Powered by",
|
||||||
"privacy_policy": "Privacy Policy",
|
"privacy_policy": "Privacy Policy",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Protected by reCAPTCHA and the Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Protected by reCAPTCHA and the Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Your feedback is stuck :("
|
"your_feedback_is_stuck": "Your feedback is stuck :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Please rank all options",
|
||||||
|
"file_extension_must_be": "File extension must be {extension}",
|
||||||
|
"file_extension_must_not_be": "File extension must not be {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "The following files are already uploaded: {duplicateNames}. Duplicate files are not allowed.",
|
"duplicate_files": "The following files are already uploaded: {duplicateNames}. Duplicate files are not allowed.",
|
||||||
"file_size_exceeded": "The following file(s) exceed the maximum size of {maxSizeInMB} MB and were removed: {fileNames}",
|
"file_size_exceeded": "The following file(s) exceed the maximum size of {maxSizeInMB} MB and were removed: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
||||||
"title": "This device doesn’t support spam protection."
|
"title": "This device doesn’t support spam protection."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Please book an appointment",
|
"invalid_format": "Please enter a valid format",
|
||||||
|
"is_between": "Please select a date between {startDate} and {endDate}",
|
||||||
|
"is_earlier_than": "Please select a date earlier than {date}",
|
||||||
|
"is_greater_than": "Please enter a value greater than {min}",
|
||||||
|
"is_later_than": "Please select a date later than {date}",
|
||||||
|
"is_less_than": "Please enter a value less than {max}",
|
||||||
|
"is_not_between": "Please select a date not between {startDate} and {endDate}",
|
||||||
|
"max_length": "Please enter no more than {max} characters",
|
||||||
|
"max_selections": "Please select no more than {max} options",
|
||||||
|
"max_value": "Please enter a value no greater than {max}",
|
||||||
|
"min_length": "Please enter at least {min} characters",
|
||||||
|
"min_selections": "Please select at least {min} options",
|
||||||
|
"min_value": "Please enter a value of at least {min}",
|
||||||
|
"minimum_options_ranked": "Please rank at least {min} options",
|
||||||
|
"minimum_rows_answered": "Please answer at least {min} rows",
|
||||||
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
||||||
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
||||||
"please_enter_a_valid_url": "Please enter a valid URL",
|
"please_enter_a_valid_url": "Please enter a valid URL",
|
||||||
"please_fill_out_this_field": "Please fill out this field",
|
"please_fill_out_this_field": "Please fill out this field",
|
||||||
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
|
|
||||||
"please_select_a_date": "Please select a date",
|
|
||||||
"please_select_an_option": "Please select an option",
|
|
||||||
"please_upload_a_file": "Please upload a file",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
|
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
|
||||||
"title": "We couldn't verify that you're human."
|
"title": "We couldn't verify that you're human."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Value must contain {value}",
|
||||||
|
"value_must_equal": "Value must equal {value}",
|
||||||
|
"value_must_not_contain": "Value must not contain {value}",
|
||||||
|
"value_must_not_equal": "Value must not equal {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Abrir en nueva pestaña",
|
"open_in_new_tab": "Abrir en nueva pestaña",
|
||||||
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
|
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
|
||||||
"please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.",
|
"please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.",
|
||||||
"please_specify": "Por favor especifique",
|
|
||||||
"powered_by": "Desarrollado por",
|
"powered_by": "Desarrollado por",
|
||||||
"privacy_policy": "Política de privacidad",
|
"privacy_policy": "Política de privacidad",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Protegido por reCAPTCHA y Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Protegido por reCAPTCHA y Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Tu feedback está atascado :("
|
"your_feedback_is_stuck": "Tu feedback está atascado :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Por favor, clasifica todas las opciones",
|
||||||
|
"file_extension_must_be": "La extensión del archivo debe ser {extension}",
|
||||||
|
"file_extension_must_not_be": "La extensión del archivo no debe ser {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Los siguientes archivos ya están subidos: {duplicateNames}. No se permiten archivos duplicados.",
|
"duplicate_files": "Los siguientes archivos ya están subidos: {duplicateNames}. No se permiten archivos duplicados.",
|
||||||
"file_size_exceeded": "Los siguientes archivos exceden el tamaño máximo de {maxSizeInMB} MB y fueron eliminados: {fileNames}",
|
"file_size_exceeded": "Los siguientes archivos exceden el tamaño máximo de {maxSizeInMB} MB y fueron eliminados: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Por favor, desactive la protección contra spam en la configuración de la encuesta para continuar usando este dispositivo.",
|
"message": "Por favor, desactive la protección contra spam en la configuración de la encuesta para continuar usando este dispositivo.",
|
||||||
"title": "Este dispositivo no es compatible con la protección contra spam."
|
"title": "Este dispositivo no es compatible con la protección contra spam."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Por favor, reserve una cita",
|
"invalid_format": "Por favor, introduce un formato válido",
|
||||||
|
"is_between": "Por favor, selecciona una fecha entre {startDate} y {endDate}",
|
||||||
|
"is_earlier_than": "Por favor, selecciona una fecha anterior a {date}",
|
||||||
|
"is_greater_than": "Por favor, introduce un valor mayor que {min}",
|
||||||
|
"is_later_than": "Por favor, selecciona una fecha posterior a {date}",
|
||||||
|
"is_less_than": "Por favor, introduce un valor menor que {max}",
|
||||||
|
"is_not_between": "Por favor, selecciona una fecha que no esté entre {startDate} y {endDate}",
|
||||||
|
"max_length": "Por favor, introduce no más de {max} caracteres",
|
||||||
|
"max_selections": "Por favor, selecciona no más de {max} opciones",
|
||||||
|
"max_value": "Por favor, introduce un valor no mayor que {max}",
|
||||||
|
"min_length": "Por favor, introduce al menos {min} caracteres",
|
||||||
|
"min_selections": "Por favor, selecciona al menos {min} opciones",
|
||||||
|
"min_value": "Por favor, introduce un valor de al menos {min}",
|
||||||
|
"minimum_options_ranked": "Por favor, clasifica al menos {min} opciones",
|
||||||
|
"minimum_rows_answered": "Por favor, responde al menos {min} filas",
|
||||||
"please_enter_a_valid_email_address": "Por favor, introduce una dirección de correo electrónico válida",
|
"please_enter_a_valid_email_address": "Por favor, introduce una dirección de correo electrónico válida",
|
||||||
"please_enter_a_valid_phone_number": "Por favor, introduzca un número de teléfono válido",
|
"please_enter_a_valid_phone_number": "Por favor, introduzca un número de teléfono válido",
|
||||||
"please_enter_a_valid_url": "Por favor, introduce una URL válida",
|
"please_enter_a_valid_url": "Por favor, introduce una URL válida",
|
||||||
"please_fill_out_this_field": "Por favor, complete este campo",
|
"please_fill_out_this_field": "Por favor, complete este campo",
|
||||||
"please_rank_all_items_before_submitting": "Por favor, clasifique todos los elementos antes de enviar",
|
|
||||||
"please_select_a_date": "Por favor, seleccione una fecha",
|
|
||||||
"please_select_an_option": "Por favor selecciona una opción",
|
|
||||||
"please_upload_a_file": "Por favor, suba un archivo",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Su respuesta no pudo ser enviada porque fue marcada como actividad automatizada. Si respira, por favor inténtelo de nuevo.",
|
"message": "Su respuesta no pudo ser enviada porque fue marcada como actividad automatizada. Si respira, por favor inténtelo de nuevo.",
|
||||||
"title": "No pudimos verificar que usted es humano."
|
"title": "No pudimos verificar que usted es humano."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "El valor debe contener {value}",
|
||||||
|
"value_must_equal": "El valor debe ser igual a {value}",
|
||||||
|
"value_must_not_contain": "El valor no debe contener {value}",
|
||||||
|
"value_must_not_equal": "El valor no debe ser igual a {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||||
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
|
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
|
||||||
"please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.",
|
"please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.",
|
||||||
"please_specify": "Veuillez préciser",
|
|
||||||
"powered_by": "Propulsé par",
|
"powered_by": "Propulsé par",
|
||||||
"privacy_policy": "Politique de confidentialité",
|
"privacy_policy": "Politique de confidentialité",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Protégé par reCAPTCHA et Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Protégé par reCAPTCHA et Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Votre feedback est bloqué :("
|
"your_feedback_is_stuck": "Votre feedback est bloqué :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Veuillez classer toutes les options",
|
||||||
|
"file_extension_must_be": "L'extension du fichier doit être {extension}",
|
||||||
|
"file_extension_must_not_be": "L'extension du fichier ne doit pas être {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Les fichiers suivants sont déjà téléchargés : {duplicateNames}. Les fichiers en double ne sont pas autorisés.",
|
"duplicate_files": "Les fichiers suivants sont déjà téléchargés : {duplicateNames}. Les fichiers en double ne sont pas autorisés.",
|
||||||
"file_size_exceeded": "Les fichiers suivants dépassent la taille maximale de {maxSizeInMB} Mo et ont été supprimés : {fileNames}",
|
"file_size_exceeded": "Les fichiers suivants dépassent la taille maximale de {maxSizeInMB} Mo et ont été supprimés : {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Veuillez désactiver la protection contre le spam dans les paramètres du sondage pour continuer à utiliser cet appareil.",
|
"message": "Veuillez désactiver la protection contre le spam dans les paramètres du sondage pour continuer à utiliser cet appareil.",
|
||||||
"title": "Cet appareil ne prend pas en charge la protection contre le spam."
|
"title": "Cet appareil ne prend pas en charge la protection contre le spam."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Veuillez prendre rendez-vous",
|
"invalid_format": "Veuillez saisir un format valide",
|
||||||
|
"is_between": "Veuillez sélectionner une date entre le {startDate} et le {endDate}",
|
||||||
|
"is_earlier_than": "Veuillez sélectionner une date antérieure au {date}",
|
||||||
|
"is_greater_than": "Veuillez saisir une valeur supérieure à {min}",
|
||||||
|
"is_later_than": "Veuillez sélectionner une date postérieure au {date}",
|
||||||
|
"is_less_than": "Veuillez saisir une valeur inférieure à {max}",
|
||||||
|
"is_not_between": "Veuillez sélectionner une date qui ne soit pas entre le {startDate} et le {endDate}",
|
||||||
|
"max_length": "Veuillez saisir au maximum {max} caractères",
|
||||||
|
"max_selections": "Veuillez sélectionner au maximum {max} options",
|
||||||
|
"max_value": "Veuillez saisir une valeur ne dépassant pas {max}",
|
||||||
|
"min_length": "Veuillez saisir au moins {min} caractères",
|
||||||
|
"min_selections": "Veuillez sélectionner au moins {min} options",
|
||||||
|
"min_value": "Veuillez saisir une valeur d'au moins {min}",
|
||||||
|
"minimum_options_ranked": "Veuillez classer au moins {min} options",
|
||||||
|
"minimum_rows_answered": "Veuillez répondre à au moins {min} lignes",
|
||||||
"please_enter_a_valid_email_address": "Veuillez saisir une adresse e-mail valide",
|
"please_enter_a_valid_email_address": "Veuillez saisir une adresse e-mail valide",
|
||||||
"please_enter_a_valid_phone_number": "Veuillez saisir un numéro de téléphone valide",
|
"please_enter_a_valid_phone_number": "Veuillez saisir un numéro de téléphone valide",
|
||||||
"please_enter_a_valid_url": "Veuillez saisir une URL valide",
|
"please_enter_a_valid_url": "Veuillez saisir une URL valide",
|
||||||
"please_fill_out_this_field": "Veuillez remplir ce champ",
|
"please_fill_out_this_field": "Veuillez remplir ce champ",
|
||||||
"please_rank_all_items_before_submitting": "Veuillez classer tous les éléments avant de soumettre",
|
|
||||||
"please_select_a_date": "Veuillez sélectionner une date",
|
|
||||||
"please_select_an_option": "Veuillez sélectionner une option",
|
|
||||||
"please_upload_a_file": "Veuillez télécharger un fichier",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Votre réponse n'a pas pu être soumise car elle a été signalée comme une activité automatisée. Si vous respirez, veuillez réessayer.",
|
"message": "Votre réponse n'a pas pu être soumise car elle a été signalée comme une activité automatisée. Si vous respirez, veuillez réessayer.",
|
||||||
"title": "Nous n'avons pas pu vérifier que vous êtes humain."
|
"title": "Nous n'avons pas pu vérifier que vous êtes humain."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "La valeur doit contenir {value}",
|
||||||
|
"value_must_equal": "La valeur doit être égale à {value}",
|
||||||
|
"value_must_not_contain": "La valeur ne doit pas contenir {value}",
|
||||||
|
"value_must_not_equal": "La valeur ne doit pas être égale à {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "नए टैब में खोलें",
|
"open_in_new_tab": "नए टैब में खोलें",
|
||||||
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
|
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
|
||||||
"please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।",
|
"please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।",
|
||||||
"please_specify": "कृपया निर्दिष्ट करें",
|
|
||||||
"powered_by": "द्वारा संचालित",
|
"powered_by": "द्वारा संचालित",
|
||||||
"privacy_policy": "गोपनीयता नीति",
|
"privacy_policy": "गोपनीयता नीति",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHA और Google द्वारा संरक्षित",
|
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHA और Google द्वारा संरक्षित",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
|
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "कृपया सभी विकल्पों को रैंक करें",
|
||||||
|
"file_extension_must_be": "फ़ाइल एक्सटेंशन {extension} होना चाहिए",
|
||||||
|
"file_extension_must_not_be": "फ़ाइल एक्सटेंशन {extension} नहीं होना चाहिए",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "निम्नलिखित फ़ाइलें पहले से ही अपलोड की गई हैं: {duplicateNames}। डुप्लिकेट फ़ाइलों की अनुमति नहीं है।",
|
"duplicate_files": "निम्नलिखित फ़ाइलें पहले से ही अपलोड की गई हैं: {duplicateNames}। डुप्लिकेट फ़ाइलों की अनुमति नहीं है।",
|
||||||
"file_size_exceeded": "निम्नलिखित फ़ाइल(ें) अधिकतम आकार {maxSizeInMB} MB से अधिक हैं और हटा दी गई हैं: {fileNames}",
|
"file_size_exceeded": "निम्नलिखित फ़ाइल(ें) अधिकतम आकार {maxSizeInMB} MB से अधिक हैं और हटा दी गई हैं: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "इस डिवाइस का उपयोग जारी रखने के लिए कृपया सर्वेक्षण सेटिंग्स में स्पैम सुरक्षा को अक्षम करें।",
|
"message": "इस डिवाइस का उपयोग जारी रखने के लिए कृपया सर्वेक्षण सेटिंग्स में स्पैम सुरक्षा को अक्षम करें।",
|
||||||
"title": "यह डिवाइस स्पैम सुरक्षा का समर्थन नहीं करता है।"
|
"title": "यह डिवाइस स्पैम सुरक्षा का समर्थन नहीं करता है।"
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "कृपया एक अपॉइंटमेंट बुक करें",
|
"invalid_format": "कृपया एक मान्य प्रारूप दर्ज करें",
|
||||||
|
"is_between": "कृपया {startDate} और {endDate} के बीच की तारीख चुनें",
|
||||||
|
"is_earlier_than": "कृपया {date} से पहले की तारीख चुनें",
|
||||||
|
"is_greater_than": "कृपया {min} से अधिक मान दर्ज करें",
|
||||||
|
"is_later_than": "कृपया {date} के बाद की तारीख चुनें",
|
||||||
|
"is_less_than": "कृपया {max} से कम मान दर्ज करें",
|
||||||
|
"is_not_between": "कृपया {startDate} और {endDate} के बीच की तारीख न चुनें",
|
||||||
|
"max_length": "कृपया {max} वर्णों से अधिक दर्ज न करें",
|
||||||
|
"max_selections": "कृपया {max} विकल्पों से अधिक का चयन न करें",
|
||||||
|
"max_value": "कृपया {max} से अधिक मान दर्ज न करें",
|
||||||
|
"min_length": "कृपया कम से कम {min} वर्ण दर्ज करें",
|
||||||
|
"min_selections": "कृपया कम से कम {min} विकल्पों का चयन करें",
|
||||||
|
"min_value": "कृपया कम से कम {min} का मान दर्ज करें",
|
||||||
|
"minimum_options_ranked": "कृपया कम से कम {min} विकल्पों को रैंक करें",
|
||||||
|
"minimum_rows_answered": "कृपया कम से कम {min} पंक्तियों का उत्तर दें",
|
||||||
"please_enter_a_valid_email_address": "कृपया एक वैध ईमेल पता दर्ज करें",
|
"please_enter_a_valid_email_address": "कृपया एक वैध ईमेल पता दर्ज करें",
|
||||||
"please_enter_a_valid_phone_number": "कृपया एक वैध फोन नंबर दर्ज करें",
|
"please_enter_a_valid_phone_number": "कृपया एक वैध फोन नंबर दर्ज करें",
|
||||||
"please_enter_a_valid_url": "कृपया एक वैध URL दर्ज करें",
|
"please_enter_a_valid_url": "कृपया एक वैध URL दर्ज करें",
|
||||||
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
|
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
|
||||||
"please_rank_all_items_before_submitting": "जमा करने से पहले कृपया सभी आइटम्स को रैंक करें",
|
|
||||||
"please_select_a_date": "कृपया एक तारीख चुनें",
|
|
||||||
"please_select_an_option": "कृपया एक विकल्प चुनें",
|
|
||||||
"please_upload_a_file": "कृपया एक फाइल अपलोड करें",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "आपका प्रतिसाद जमा नहीं किया जा सका क्योंकि इसे स्वचालित गतिविधि के रूप में चिह्नित किया गया था। यदि आप सांस लेते हैं, तो कृपया पुनः प्रयास करें।",
|
"message": "आपका प्रतिसाद जमा नहीं किया जा सका क्योंकि इसे स्वचालित गतिविधि के रूप में चिह्नित किया गया था। यदि आप सांस लेते हैं, तो कृपया पुनः प्रयास करें।",
|
||||||
"title": "हम यह सत्यापित नहीं कर सके कि आप मानव हैं।"
|
"title": "हम यह सत्यापित नहीं कर सके कि आप मानव हैं।"
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "मान में {value} होना चाहिए",
|
||||||
|
"value_must_equal": "मान {value} के बराबर होना चाहिए",
|
||||||
|
"value_must_not_contain": "मान में {value} नहीं होना चाहिए",
|
||||||
|
"value_must_not_equal": "मान {value} के बराबर नहीं होना चाहिए"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Apri in una nuova scheda",
|
"open_in_new_tab": "Apri in una nuova scheda",
|
||||||
"people_responded": "{count, plural, one {1 persona ha risposto} other {{count} persone hanno risposto}}",
|
"people_responded": "{count, plural, one {1 persona ha risposto} other {{count} persone hanno risposto}}",
|
||||||
"please_retry_now_or_try_again_later": "Riprova ora o più tardi.",
|
"please_retry_now_or_try_again_later": "Riprova ora o più tardi.",
|
||||||
"please_specify": "Si prega di specificare",
|
|
||||||
"powered_by": "Offerto da",
|
"powered_by": "Offerto da",
|
||||||
"privacy_policy": "Informativa sulla privacy",
|
"privacy_policy": "Informativa sulla privacy",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Protetto da reCAPTCHA e da Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Protetto da reCAPTCHA e da Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
|
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Classifica tutte le opzioni",
|
||||||
|
"file_extension_must_be": "L'estensione del file deve essere {extension}",
|
||||||
|
"file_extension_must_not_be": "L'estensione del file non deve essere {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "I seguenti file sono già caricati: {duplicateNames}. I file duplicati non sono consentiti.",
|
"duplicate_files": "I seguenti file sono già caricati: {duplicateNames}. I file duplicati non sono consentiti.",
|
||||||
"file_size_exceeded": "I seguenti file superano la dimensione massima di {maxSizeInMB} MB e sono stati rimossi: {fileNames}",
|
"file_size_exceeded": "I seguenti file superano la dimensione massima di {maxSizeInMB} MB e sono stati rimossi: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Per continuare a utilizzare questo dispositivo, disabilita la protezione anti-spam nelle impostazioni del sondaggio.",
|
"message": "Per continuare a utilizzare questo dispositivo, disabilita la protezione anti-spam nelle impostazioni del sondaggio.",
|
||||||
"title": "Questo dispositivo non supporta la protezione anti-spam."
|
"title": "Questo dispositivo non supporta la protezione anti-spam."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Prenota un appuntamento",
|
"invalid_format": "Inserisci un formato valido",
|
||||||
|
"is_between": "Seleziona una data compresa tra {startDate} e {endDate}",
|
||||||
|
"is_earlier_than": "Seleziona una data precedente a {date}",
|
||||||
|
"is_greater_than": "Inserisci un valore maggiore di {min}",
|
||||||
|
"is_later_than": "Seleziona una data successiva a {date}",
|
||||||
|
"is_less_than": "Inserisci un valore minore di {max}",
|
||||||
|
"is_not_between": "Seleziona una data non compresa tra {startDate} e {endDate}",
|
||||||
|
"max_length": "Inserisci non più di {max} caratteri",
|
||||||
|
"max_selections": "Seleziona non più di {max} opzioni",
|
||||||
|
"max_value": "Inserisci un valore non superiore a {max}",
|
||||||
|
"min_length": "Inserisci almeno {min} caratteri",
|
||||||
|
"min_selections": "Seleziona almeno {min} opzioni",
|
||||||
|
"min_value": "Inserisci un valore di almeno {min}",
|
||||||
|
"minimum_options_ranked": "Classifica almeno {min} opzioni",
|
||||||
|
"minimum_rows_answered": "Rispondi ad almeno {min} righe",
|
||||||
"please_enter_a_valid_email_address": "Inserisci un indirizzo email valido",
|
"please_enter_a_valid_email_address": "Inserisci un indirizzo email valido",
|
||||||
"please_enter_a_valid_phone_number": "Inserisci un numero di telefono valido",
|
"please_enter_a_valid_phone_number": "Inserisci un numero di telefono valido",
|
||||||
"please_enter_a_valid_url": "Inserisci un URL valido",
|
"please_enter_a_valid_url": "Inserisci un URL valido",
|
||||||
"please_fill_out_this_field": "Compila questo campo",
|
"please_fill_out_this_field": "Compila questo campo",
|
||||||
"please_rank_all_items_before_submitting": "Classifica tutti gli elementi prima di inviare",
|
|
||||||
"please_select_a_date": "Seleziona una data",
|
|
||||||
"please_select_an_option": "Seleziona un'opzione",
|
|
||||||
"please_upload_a_file": "Carica un file",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "La tua risposta non può essere inviata perché è stata segnalata come attività automatizzata. Se respiri, riprova.",
|
"message": "La tua risposta non può essere inviata perché è stata segnalata come attività automatizzata. Se respiri, riprova.",
|
||||||
"title": "Non siamo riusciti a verificare che sei umano."
|
"title": "Non siamo riusciti a verificare che sei umano."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Il valore deve contenere {value}",
|
||||||
|
"value_must_equal": "Il valore deve essere uguale a {value}",
|
||||||
|
"value_must_not_contain": "Il valore non deve contenere {value}",
|
||||||
|
"value_must_not_equal": "Il valore non deve essere uguale a {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "新しいタブで開く",
|
"open_in_new_tab": "新しいタブで開く",
|
||||||
"people_responded": "{count, plural, other {{count}人が回答しました}}",
|
"people_responded": "{count, plural, other {{count}人が回答しました}}",
|
||||||
"please_retry_now_or_try_again_later": "今すぐ再試行するか、後でもう一度お試しください。",
|
"please_retry_now_or_try_again_later": "今すぐ再試行するか、後でもう一度お試しください。",
|
||||||
"please_specify": "具体的に記入してください",
|
|
||||||
"powered_by": "提供:",
|
"powered_by": "提供:",
|
||||||
"privacy_policy": "プライバシーポリシー",
|
"privacy_policy": "プライバシーポリシー",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHAとGoogleによって保護されています",
|
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHAとGoogleによって保護されています",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "フィードバックが送信できません :("
|
"your_feedback_is_stuck": "フィードバックが送信できません :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "すべてのオプションをランク付けしてください",
|
||||||
|
"file_extension_must_be": "ファイル拡張子は{extension}である必要があります",
|
||||||
|
"file_extension_must_not_be": "ファイル拡張子は{extension}であってはなりません",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "以下のファイルはすでにアップロードされています:{duplicateNames}。重複ファイルは許可されていません。",
|
"duplicate_files": "以下のファイルはすでにアップロードされています:{duplicateNames}。重複ファイルは許可されていません。",
|
||||||
"file_size_exceeded": "以下のファイルは最大サイズ{maxSizeInMB}MBを超えているため削除されました:{fileNames}",
|
"file_size_exceeded": "以下のファイルは最大サイズ{maxSizeInMB}MBを超えているため削除されました:{fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "このデバイスを引き続き使用するには、アンケート設定でスパム保護を無効にしてください。",
|
"message": "このデバイスを引き続き使用するには、アンケート設定でスパム保護を無効にしてください。",
|
||||||
"title": "このデバイスはスパム保護に対応していません。"
|
"title": "このデバイスはスパム保護に対応していません。"
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "予約をお取りください",
|
"invalid_format": "有効な形式を入力してください",
|
||||||
|
"is_between": "{startDate}から{endDate}の間の日付を選択してください",
|
||||||
|
"is_earlier_than": "{date}より前の日付を選択してください",
|
||||||
|
"is_greater_than": "{min}より大きい値を入力してください",
|
||||||
|
"is_later_than": "{date}より後の日付を選択してください",
|
||||||
|
"is_less_than": "{max}より小さい値を入力してください",
|
||||||
|
"is_not_between": "{startDate}から{endDate}の間以外の日付を選択してください",
|
||||||
|
"max_length": "{max}文字以内で入力してください",
|
||||||
|
"max_selections": "{max}個以内のオプションを選択してください",
|
||||||
|
"max_value": "{max}以下の値を入力してください",
|
||||||
|
"min_length": "{min}文字以上入力してください",
|
||||||
|
"min_selections": "{min}個以上のオプションを選択してください",
|
||||||
|
"min_value": "{min}以上の値を入力してください",
|
||||||
|
"minimum_options_ranked": "最低{min}個のオプションをランク付けしてください",
|
||||||
|
"minimum_rows_answered": "最低{min}行に回答してください",
|
||||||
"please_enter_a_valid_email_address": "有効なメールアドレスを入力してください",
|
"please_enter_a_valid_email_address": "有効なメールアドレスを入力してください",
|
||||||
"please_enter_a_valid_phone_number": "有効な電話番号を入力してください",
|
"please_enter_a_valid_phone_number": "有効な電話番号を入力してください",
|
||||||
"please_enter_a_valid_url": "有効なURLを入力してください",
|
"please_enter_a_valid_url": "有効なURLを入力してください",
|
||||||
"please_fill_out_this_field": "このフィールドに入力してください",
|
"please_fill_out_this_field": "このフィールドに入力してください",
|
||||||
"please_rank_all_items_before_submitting": "送信する前にすべての項目をランク付けしてください",
|
|
||||||
"please_select_a_date": "日付を選択してください",
|
|
||||||
"please_select_an_option": "オプションを選択してください",
|
|
||||||
"please_upload_a_file": "ファイルをアップロードしてください",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "自動化された活動としてフラグが立てられたため、回答を送信できませんでした。人間の方は、もう一度お試しください。",
|
"message": "自動化された活動としてフラグが立てられたため、回答を送信できませんでした。人間の方は、もう一度お試しください。",
|
||||||
"title": "あなたが人間であることを確認できませんでした。"
|
"title": "あなたが人間であることを確認できませんでした。"
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "値に{value}を含める必要があります",
|
||||||
|
"value_must_equal": "値は{value}と等しい必要があります",
|
||||||
|
"value_must_not_contain": "値に{value}を含めることはできません",
|
||||||
|
"value_must_not_equal": "値は{value}と等しくない必要があります"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Openen in nieuw tabblad",
|
"open_in_new_tab": "Openen in nieuw tabblad",
|
||||||
"people_responded": "{count, plural, one {1 persoon heeft gereageerd} other {{count} mensen hebben gereageerd}}",
|
"people_responded": "{count, plural, one {1 persoon heeft gereageerd} other {{count} mensen hebben gereageerd}}",
|
||||||
"please_retry_now_or_try_again_later": "Probeer het nu opnieuw of probeer het later opnieuw.",
|
"please_retry_now_or_try_again_later": "Probeer het nu opnieuw of probeer het later opnieuw.",
|
||||||
"please_specify": "Graag specificeren",
|
|
||||||
"powered_by": "Aangedreven door",
|
"powered_by": "Aangedreven door",
|
||||||
"privacy_policy": "Privacybeleid",
|
"privacy_policy": "Privacybeleid",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Beschermd door reCAPTCHA en Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Beschermd door reCAPTCHA en Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Je feedback blijft hangen :("
|
"your_feedback_is_stuck": "Je feedback blijft hangen :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Rangschik alle opties",
|
||||||
|
"file_extension_must_be": "Bestandsextensie moet {extension} zijn",
|
||||||
|
"file_extension_must_not_be": "Bestandsextensie mag niet {extension} zijn",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "De volgende bestanden zijn al geüpload: {duplicateNames}. Dubbele bestanden zijn niet toegestaan.",
|
"duplicate_files": "De volgende bestanden zijn al geüpload: {duplicateNames}. Dubbele bestanden zijn niet toegestaan.",
|
||||||
"file_size_exceeded": "De volgende bestanden overschrijden de maximale grootte van {maxSizeInMB} MB en zijn verwijderd: {fileNames}",
|
"file_size_exceeded": "De volgende bestanden overschrijden de maximale grootte van {maxSizeInMB} MB en zijn verwijderd: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Schakel de spambeveiliging uit in de enquête-instellingen om dit apparaat te blijven gebruiken.",
|
"message": "Schakel de spambeveiliging uit in de enquête-instellingen om dit apparaat te blijven gebruiken.",
|
||||||
"title": "Dit apparaat ondersteunt geen spambeveiliging."
|
"title": "Dit apparaat ondersteunt geen spambeveiliging."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Maak een afspraak",
|
"invalid_format": "Voer een geldig formaat in",
|
||||||
|
"is_between": "Selecteer een datum tussen {startDate} en {endDate}",
|
||||||
|
"is_earlier_than": "Selecteer een datum vóór {date}",
|
||||||
|
"is_greater_than": "Voer een waarde in die groter is dan {min}",
|
||||||
|
"is_later_than": "Selecteer een datum na {date}",
|
||||||
|
"is_less_than": "Voer een waarde in die kleiner is dan {max}",
|
||||||
|
"is_not_between": "Selecteer een datum die niet tussen {startDate} en {endDate} ligt",
|
||||||
|
"max_length": "Voer niet meer dan {max} tekens in",
|
||||||
|
"max_selections": "Selecteer niet meer dan {max} opties",
|
||||||
|
"max_value": "Voer een waarde in die niet groter is dan {max}",
|
||||||
|
"min_length": "Voer minimaal {min} tekens in",
|
||||||
|
"min_selections": "Selecteer minimaal {min} opties",
|
||||||
|
"min_value": "Voer een waarde in van minimaal {min}",
|
||||||
|
"minimum_options_ranked": "Rangschik minimaal {min} opties",
|
||||||
|
"minimum_rows_answered": "Beantwoord minimaal {min} rijen",
|
||||||
"please_enter_a_valid_email_address": "Voer een geldig e-mailadres in",
|
"please_enter_a_valid_email_address": "Voer een geldig e-mailadres in",
|
||||||
"please_enter_a_valid_phone_number": "Voer een geldig telefoonnummer in",
|
"please_enter_a_valid_phone_number": "Voer een geldig telefoonnummer in",
|
||||||
"please_enter_a_valid_url": "Voer een geldige URL in",
|
"please_enter_a_valid_url": "Voer een geldige URL in",
|
||||||
"please_fill_out_this_field": "Vul dit veld in",
|
"please_fill_out_this_field": "Vul dit veld in",
|
||||||
"please_rank_all_items_before_submitting": "Rangschik alle items voordat u ze verzendt",
|
|
||||||
"please_select_a_date": "Selecteer een datum",
|
|
||||||
"please_select_an_option": "Selecteer een optie",
|
|
||||||
"please_upload_a_file": "Upload een bestand",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Uw reactie kan niet worden verzonden omdat deze is gemarkeerd als geautomatiseerde activiteit. Als u ademhaalt, probeer het dan opnieuw.",
|
"message": "Uw reactie kan niet worden verzonden omdat deze is gemarkeerd als geautomatiseerde activiteit. Als u ademhaalt, probeer het dan opnieuw.",
|
||||||
"title": "We konden niet verifiëren dat je een mens bent."
|
"title": "We konden niet verifiëren dat je een mens bent."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Waarde moet {value} bevatten",
|
||||||
|
"value_must_equal": "Waarde moet gelijk zijn aan {value}",
|
||||||
|
"value_must_not_contain": "Waarde mag {value} niet bevatten",
|
||||||
|
"value_must_not_equal": "Waarde mag niet gelijk zijn aan {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Abrir em nova aba",
|
"open_in_new_tab": "Abrir em nova aba",
|
||||||
"people_responded": "{count, plural, one {1 pessoa respondeu} other {{count} pessoas responderam}}",
|
"people_responded": "{count, plural, one {1 pessoa respondeu} other {{count} pessoas responderam}}",
|
||||||
"please_retry_now_or_try_again_later": "Por favor, tente novamente agora ou mais tarde.",
|
"please_retry_now_or_try_again_later": "Por favor, tente novamente agora ou mais tarde.",
|
||||||
"please_specify": "Por favor, especifique",
|
|
||||||
"powered_by": "Desenvolvido por",
|
"powered_by": "Desenvolvido por",
|
||||||
"privacy_policy": "Política de privacidade",
|
"privacy_policy": "Política de privacidade",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Protegido pelo reCAPTCHA e o Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Protegido pelo reCAPTCHA e o Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Seu feedback está preso :("
|
"your_feedback_is_stuck": "Seu feedback está preso :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Por favor, classifique todas as opções",
|
||||||
|
"file_extension_must_be": "A extensão do arquivo deve ser {extension}",
|
||||||
|
"file_extension_must_not_be": "A extensão do arquivo não deve ser {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Os seguintes arquivos já foram carregados: {duplicateNames}. Arquivos duplicados não são permitidos.",
|
"duplicate_files": "Os seguintes arquivos já foram carregados: {duplicateNames}. Arquivos duplicados não são permitidos.",
|
||||||
"file_size_exceeded": "Os seguintes arquivos excedem o tamanho máximo de {maxSizeInMB} MB e foram removidos: {fileNames}",
|
"file_size_exceeded": "Os seguintes arquivos excedem o tamanho máximo de {maxSizeInMB} MB e foram removidos: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Por favor, desative a proteção contra spam nas configurações da pesquisa para continuar usando este dispositivo.",
|
"message": "Por favor, desative a proteção contra spam nas configurações da pesquisa para continuar usando este dispositivo.",
|
||||||
"title": "Este dispositivo não suporta proteção contra spam."
|
"title": "Este dispositivo não suporta proteção contra spam."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Por favor, marque uma consulta",
|
"invalid_format": "Por favor, insira um formato válido",
|
||||||
|
"is_between": "Por favor, selecione uma data entre {startDate} e {endDate}",
|
||||||
|
"is_earlier_than": "Por favor, selecione uma data anterior a {date}",
|
||||||
|
"is_greater_than": "Por favor, insira um valor superior a {min}",
|
||||||
|
"is_later_than": "Por favor, selecione uma data posterior a {date}",
|
||||||
|
"is_less_than": "Por favor, insira um valor inferior a {max}",
|
||||||
|
"is_not_between": "Por favor, selecione uma data que não esteja entre {startDate} e {endDate}",
|
||||||
|
"max_length": "Por favor, insira no máximo {max} caracteres",
|
||||||
|
"max_selections": "Por favor, selecione no máximo {max} opções",
|
||||||
|
"max_value": "Por favor, insira um valor não superior a {max}",
|
||||||
|
"min_length": "Por favor, insira pelo menos {min} caracteres",
|
||||||
|
"min_selections": "Por favor, selecione pelo menos {min} opções",
|
||||||
|
"min_value": "Por favor, insira um valor de pelo menos {min}",
|
||||||
|
"minimum_options_ranked": "Por favor, classifique pelo menos {min} opções",
|
||||||
|
"minimum_rows_answered": "Por favor, responda pelo menos {min} linhas",
|
||||||
"please_enter_a_valid_email_address": "Por favor, insira um endereço de email válido",
|
"please_enter_a_valid_email_address": "Por favor, insira um endereço de email válido",
|
||||||
"please_enter_a_valid_phone_number": "Por favor, insira um número de telefone válido",
|
"please_enter_a_valid_phone_number": "Por favor, insira um número de telefone válido",
|
||||||
"please_enter_a_valid_url": "Por favor, insira uma URL válida",
|
"please_enter_a_valid_url": "Por favor, insira uma URL válida",
|
||||||
"please_fill_out_this_field": "Por favor, preencha este campo",
|
"please_fill_out_this_field": "Por favor, preencha este campo",
|
||||||
"please_rank_all_items_before_submitting": "Por favor, classifique todos os itens antes de enviar",
|
|
||||||
"please_select_a_date": "Por favor, selecione uma data",
|
|
||||||
"please_select_an_option": "Por favor, selecione uma opção",
|
|
||||||
"please_upload_a_file": "Por favor, carregue um arquivo",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Sua resposta não pôde ser enviada porque foi sinalizada como atividade automatizada. Se você respira, por favor tente novamente.",
|
"message": "Sua resposta não pôde ser enviada porque foi sinalizada como atividade automatizada. Se você respira, por favor tente novamente.",
|
||||||
"title": "Não conseguimos verificar que você é humano."
|
"title": "Não conseguimos verificar que você é humano."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "O valor deve conter {value}",
|
||||||
|
"value_must_equal": "O valor deve ser igual a {value}",
|
||||||
|
"value_must_not_contain": "O valor não deve conter {value}",
|
||||||
|
"value_must_not_equal": "O valor não deve ser igual a {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Deschide într-o filă nouă",
|
"open_in_new_tab": "Deschide într-o filă nouă",
|
||||||
"people_responded": "{count, plural, one {1 persoană a răspuns} other {{count} persoane au răspuns}}",
|
"people_responded": "{count, plural, one {1 persoană a răspuns} other {{count} persoane au răspuns}}",
|
||||||
"please_retry_now_or_try_again_later": "Te rugăm să încerci din nou acum sau mai târziu.",
|
"please_retry_now_or_try_again_later": "Te rugăm să încerci din nou acum sau mai târziu.",
|
||||||
"please_specify": "Vă rugăm să specificați",
|
|
||||||
"powered_by": "Susținut de",
|
"powered_by": "Susținut de",
|
||||||
"privacy_policy": "Politica de confidențialitate",
|
"privacy_policy": "Politica de confidențialitate",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Protejat de reCAPTCHA și de Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Protejat de reCAPTCHA și de Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
|
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Vă rugăm să ordonați toate opțiunile",
|
||||||
|
"file_extension_must_be": "Extensia fișierului trebuie să fie {extension}",
|
||||||
|
"file_extension_must_not_be": "Extensia fișierului nu trebuie să fie {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Următoarele fișiere sunt deja încărcate: {duplicateNames}. Fișierele duplicate nu sunt permise.",
|
"duplicate_files": "Următoarele fișiere sunt deja încărcate: {duplicateNames}. Fișierele duplicate nu sunt permise.",
|
||||||
"file_size_exceeded": "Următoarele fișiere depășesc dimensiunea maximă de {maxSizeInMB} MB și au fost eliminate: {fileNames}",
|
"file_size_exceeded": "Următoarele fișiere depășesc dimensiunea maximă de {maxSizeInMB} MB și au fost eliminate: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Dezactivați protecția împotriva spamului în setările sondajului pentru a continua să utilizați acest dispozitiv.",
|
"message": "Dezactivați protecția împotriva spamului în setările sondajului pentru a continua să utilizați acest dispozitiv.",
|
||||||
"title": "Acest dispozitiv nu acceptă protecția împotriva spamului."
|
"title": "Acest dispozitiv nu acceptă protecția împotriva spamului."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Vă rugăm să faceți o programare",
|
"invalid_format": "Vă rugăm să introduceți un format valid",
|
||||||
|
"is_between": "Vă rugăm să selectați o dată între {startDate} și {endDate}",
|
||||||
|
"is_earlier_than": "Vă rugăm să selectați o dată anterioară datei {date}",
|
||||||
|
"is_greater_than": "Vă rugăm să introduceți o valoare mai mare decât {min}",
|
||||||
|
"is_later_than": "Vă rugăm să selectați o dată ulterioară datei {date}",
|
||||||
|
"is_less_than": "Vă rugăm să introduceți o valoare mai mică decât {max}",
|
||||||
|
"is_not_between": "Vă rugăm să selectați o dată care nu este între {startDate} și {endDate}",
|
||||||
|
"max_length": "Vă rugăm să nu introduceți mai mult de {max} caractere",
|
||||||
|
"max_selections": "Vă rugăm să selectați cel mult {max} opțiuni",
|
||||||
|
"max_value": "Vă rugăm să introduceți o valoare care să nu depășească {max}",
|
||||||
|
"min_length": "Vă rugăm să introduceți cel puțin {min} caractere",
|
||||||
|
"min_selections": "Vă rugăm să selectați cel puțin {min} opțiuni",
|
||||||
|
"min_value": "Vă rugăm să introduceți o valoare de cel puțin {min}",
|
||||||
|
"minimum_options_ranked": "Vă rugăm să ordonați cel puțin {min} opțiuni",
|
||||||
|
"minimum_rows_answered": "Vă rugăm să răspundeți la cel puțin {min} rânduri",
|
||||||
"please_enter_a_valid_email_address": "Vă rugăm să introduceți o adresă de email validă",
|
"please_enter_a_valid_email_address": "Vă rugăm să introduceți o adresă de email validă",
|
||||||
"please_enter_a_valid_phone_number": "Vă rugăm să introduceți un număr de telefon valid",
|
"please_enter_a_valid_phone_number": "Vă rugăm să introduceți un număr de telefon valid",
|
||||||
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid",
|
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid",
|
||||||
"please_fill_out_this_field": "Vă rugăm să completați acest câmp",
|
"please_fill_out_this_field": "Vă rugăm să completați acest câmp",
|
||||||
"please_rank_all_items_before_submitting": "Vă rugăm să clasificați toate elementele înainte de a trimite",
|
|
||||||
"please_select_a_date": "Vă rugăm să selectați o dată",
|
|
||||||
"please_select_an_option": "Vă rugăm să selectați o opțiune",
|
|
||||||
"please_upload_a_file": "Vă rugăm să încărcați un fișier",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Răspunsul dumneavoastră nu a putut fi trimis deoarece a fost marcat ca activitate automată. Dacă respirați, încercați din nou.",
|
"message": "Răspunsul dumneavoastră nu a putut fi trimis deoarece a fost marcat ca activitate automată. Dacă respirați, încercați din nou.",
|
||||||
"title": "Nu am putut verifica dacă sunteți uman."
|
"title": "Nu am putut verifica dacă sunteți uman."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Valoarea trebuie să conțină {value}",
|
||||||
|
"value_must_equal": "Valoarea trebuie să fie egală cu {value}",
|
||||||
|
"value_must_not_contain": "Valoarea nu trebuie să conțină {value}",
|
||||||
|
"value_must_not_equal": "Valoarea nu trebuie să fie egală cu {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Открыть в новой вкладке",
|
"open_in_new_tab": "Открыть в новой вкладке",
|
||||||
"people_responded": "{count, plural, one {1 человек ответил} other {{count} человека ответили}}",
|
"people_responded": "{count, plural, one {1 человек ответил} other {{count} человека ответили}}",
|
||||||
"please_retry_now_or_try_again_later": "Пожалуйста, повторите попытку сейчас или попробуйте позже.",
|
"please_retry_now_or_try_again_later": "Пожалуйста, повторите попытку сейчас или попробуйте позже.",
|
||||||
"please_specify": "Пожалуйста, уточните",
|
|
||||||
"powered_by": "Работает на основе",
|
"powered_by": "Работает на основе",
|
||||||
"privacy_policy": "Политика конфиденциальности",
|
"privacy_policy": "Политика конфиденциальности",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Защищено reCAPTCHA и Google",
|
"protected_by_reCAPTCHA_and_the_Google": "Защищено reCAPTCHA и Google",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Ваш отзыв застрял :("
|
"your_feedback_is_stuck": "Ваш отзыв застрял :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Пожалуйста, расставьте все варианты по порядку",
|
||||||
|
"file_extension_must_be": "Расширение файла должно быть {extension}",
|
||||||
|
"file_extension_must_not_be": "Расширение файла не должно быть {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Следующие файлы уже загружены: {duplicateNames}. Дублирующие файлы не допускаются.",
|
"duplicate_files": "Следующие файлы уже загружены: {duplicateNames}. Дублирующие файлы не допускаются.",
|
||||||
"file_size_exceeded": "Следующие файлы превышают максимальный размер {maxSizeInMB} МБ и были удалены: {fileNames}",
|
"file_size_exceeded": "Следующие файлы превышают максимальный размер {maxSizeInMB} МБ и были удалены: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Пожалуйста, отключите защиту от спама в настройках опроса, чтобы продолжить использование этого устройства.",
|
"message": "Пожалуйста, отключите защиту от спама в настройках опроса, чтобы продолжить использование этого устройства.",
|
||||||
"title": "Это устройство не поддерживает защиту от спама."
|
"title": "Это устройство не поддерживает защиту от спама."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Пожалуйста, запишитесь на приём",
|
"invalid_format": "Пожалуйста, введите корректный формат",
|
||||||
|
"is_between": "Пожалуйста, выберите дату в диапазоне от {startDate} до {endDate}",
|
||||||
|
"is_earlier_than": "Пожалуйста, выберите дату раньше {date}",
|
||||||
|
"is_greater_than": "Пожалуйста, введите значение больше {min}",
|
||||||
|
"is_later_than": "Пожалуйста, выберите дату позже {date}",
|
||||||
|
"is_less_than": "Пожалуйста, введите значение меньше {max}",
|
||||||
|
"is_not_between": "Пожалуйста, выберите дату вне диапазона от {startDate} до {endDate}",
|
||||||
|
"max_length": "Пожалуйста, введите не более {max} символов",
|
||||||
|
"max_selections": "Пожалуйста, выберите не более {max} вариантов",
|
||||||
|
"max_value": "Пожалуйста, введите значение не больше {max}",
|
||||||
|
"min_length": "Пожалуйста, введите не менее {min} символов",
|
||||||
|
"min_selections": "Пожалуйста, выберите не менее {min} вариантов",
|
||||||
|
"min_value": "Пожалуйста, введите значение не меньше {min}",
|
||||||
|
"minimum_options_ranked": "Пожалуйста, ранжируйте не менее {min} вариантов",
|
||||||
|
"minimum_rows_answered": "Пожалуйста, ответьте как минимум на {min} строк(и)",
|
||||||
"please_enter_a_valid_email_address": "Пожалуйста, введите действительный адрес электронной почты",
|
"please_enter_a_valid_email_address": "Пожалуйста, введите действительный адрес электронной почты",
|
||||||
"please_enter_a_valid_phone_number": "Пожалуйста, введите действительный номер телефона",
|
"please_enter_a_valid_phone_number": "Пожалуйста, введите действительный номер телефона",
|
||||||
"please_enter_a_valid_url": "Пожалуйста, введите действительный URL-адрес",
|
"please_enter_a_valid_url": "Пожалуйста, введите действительный URL-адрес",
|
||||||
"please_fill_out_this_field": "Пожалуйста, заполните это поле",
|
"please_fill_out_this_field": "Пожалуйста, заполните это поле",
|
||||||
"please_rank_all_items_before_submitting": "Пожалуйста, оцените все элементы перед отправкой",
|
|
||||||
"please_select_a_date": "Пожалуйста, выберите дату",
|
|
||||||
"please_select_an_option": "Пожалуйста, выберите вариант",
|
|
||||||
"please_upload_a_file": "Пожалуйста, загрузите файл",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Ваш ответ не может быть отправлен, так как он был помечен как автоматическая активность. Если вы дышите, попробуйте ещё раз.",
|
"message": "Ваш ответ не может быть отправлен, так как он был помечен как автоматическая активность. Если вы дышите, попробуйте ещё раз.",
|
||||||
"title": "Мы не смогли подтвердить, что вы человек."
|
"title": "Мы не смогли подтвердить, что вы человек."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Значение должно содержать {value}",
|
||||||
|
"value_must_equal": "Значение должно быть равно {value}",
|
||||||
|
"value_must_not_contain": "Значение не должно содержать {value}",
|
||||||
|
"value_must_not_equal": "Значение не должно быть равно {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Öppna i ny flik",
|
"open_in_new_tab": "Öppna i ny flik",
|
||||||
"people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}",
|
"people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}",
|
||||||
"please_retry_now_or_try_again_later": "Försök igen nu eller försök igen senare.",
|
"please_retry_now_or_try_again_later": "Försök igen nu eller försök igen senare.",
|
||||||
"please_specify": "Vänligen ange",
|
|
||||||
"powered_by": "Drivs av",
|
"powered_by": "Drivs av",
|
||||||
"privacy_policy": "Integritetspolicy",
|
"privacy_policy": "Integritetspolicy",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "Skyddas av reCAPTCHA och Googles",
|
"protected_by_reCAPTCHA_and_the_Google": "Skyddas av reCAPTCHA och Googles",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Din feedback fastnade :("
|
"your_feedback_is_stuck": "Din feedback fastnade :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Vänligen rangordna alla alternativ",
|
||||||
|
"file_extension_must_be": "Filändelsen måste vara {extension}",
|
||||||
|
"file_extension_must_not_be": "Filändelsen får inte vara {extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Följande filer är redan uppladdade: {duplicateNames}. Dubbletter av filer är inte tillåtna.",
|
"duplicate_files": "Följande filer är redan uppladdade: {duplicateNames}. Dubbletter av filer är inte tillåtna.",
|
||||||
"file_size_exceeded": "Följande filer överstiger maxstorleken på {maxSizeInMB} MB och togs bort: {fileNames}",
|
"file_size_exceeded": "Följande filer överstiger maxstorleken på {maxSizeInMB} MB och togs bort: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Vänligen inaktivera skräppostskyddet i enkätinställningarna för att fortsätta använda denna enhet.",
|
"message": "Vänligen inaktivera skräppostskyddet i enkätinställningarna för att fortsätta använda denna enhet.",
|
||||||
"title": "Denna enhet stöder inte skräppostskydd."
|
"title": "Denna enhet stöder inte skräppostskydd."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Vänligen boka ett möte",
|
"invalid_format": "Ange ett giltigt format",
|
||||||
|
"is_between": "Välj ett datum mellan {startDate} och {endDate}",
|
||||||
|
"is_earlier_than": "Välj ett datum före {date}",
|
||||||
|
"is_greater_than": "Ange ett värde som är större än {min}",
|
||||||
|
"is_later_than": "Välj ett datum efter {date}",
|
||||||
|
"is_less_than": "Ange ett värde som är mindre än {max}",
|
||||||
|
"is_not_between": "Välj ett datum som inte är mellan {startDate} och {endDate}",
|
||||||
|
"max_length": "Ange högst {max} tecken",
|
||||||
|
"max_selections": "Välj högst {max} alternativ",
|
||||||
|
"max_value": "Ange ett värde som är högst {max}",
|
||||||
|
"min_length": "Ange minst {min} tecken",
|
||||||
|
"min_selections": "Välj minst {min} alternativ",
|
||||||
|
"min_value": "Ange ett värde som är minst {min}",
|
||||||
|
"minimum_options_ranked": "Rangordna minst {min} alternativ",
|
||||||
|
"minimum_rows_answered": "Besvara minst {min} rader",
|
||||||
"please_enter_a_valid_email_address": "Vänligen ange en giltig e-postadress",
|
"please_enter_a_valid_email_address": "Vänligen ange en giltig e-postadress",
|
||||||
"please_enter_a_valid_phone_number": "Vänligen ange ett giltigt telefonnummer",
|
"please_enter_a_valid_phone_number": "Vänligen ange ett giltigt telefonnummer",
|
||||||
"please_enter_a_valid_url": "Vänligen ange en giltig URL",
|
"please_enter_a_valid_url": "Vänligen ange en giltig URL",
|
||||||
"please_fill_out_this_field": "Vänligen fyll i detta fält",
|
"please_fill_out_this_field": "Vänligen fyll i detta fält",
|
||||||
"please_rank_all_items_before_submitting": "Vänligen rangordna alla objekt innan du skickar",
|
|
||||||
"please_select_a_date": "Vänligen välj ett datum",
|
|
||||||
"please_select_an_option": "Vänligen välj ett alternativ",
|
|
||||||
"please_upload_a_file": "Vänligen ladda upp en fil",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Ditt svar kunde inte skickas eftersom det flaggades som automatiserad aktivitet. Om du andas, försök igen.",
|
"message": "Ditt svar kunde inte skickas eftersom det flaggades som automatiserad aktivitet. Om du andas, försök igen.",
|
||||||
"title": "Vi kunde inte verifiera att du är människa."
|
"title": "Vi kunde inte verifiera att du är människa."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Värdet måste innehålla {value}",
|
||||||
|
"value_must_equal": "Värdet måste vara {value}",
|
||||||
|
"value_must_not_contain": "Värdet får inte innehålla {value}",
|
||||||
|
"value_must_not_equal": "Värdet får inte vara {value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "Yangi oynada ochish",
|
"open_in_new_tab": "Yangi oynada ochish",
|
||||||
"people_responded": "{count, plural, one {1 kishi javob berdi} other {{count} kishi javob berdi}}",
|
"people_responded": "{count, plural, one {1 kishi javob berdi} other {{count} kishi javob berdi}}",
|
||||||
"please_retry_now_or_try_again_later": "Iltimos, hozir qayta urinib ko‘ring yoki keyinroq urinib ko‘ring.",
|
"please_retry_now_or_try_again_later": "Iltimos, hozir qayta urinib ko‘ring yoki keyinroq urinib ko‘ring.",
|
||||||
"please_specify": "Iltimos, aniqlang",
|
|
||||||
"powered_by": "Quvvatlanadi",
|
"powered_by": "Quvvatlanadi",
|
||||||
"privacy_policy": "Maxfiylik siyosati",
|
"privacy_policy": "Maxfiylik siyosati",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHA va Google tomonidan himoyalangan",
|
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHA va Google tomonidan himoyalangan",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
|
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "Iltimos, barcha variantlarni tartiblang",
|
||||||
|
"file_extension_must_be": "Fayl kengaytmasi {extension} bo‘lishi kerak",
|
||||||
|
"file_extension_must_not_be": "Fayl kengaytmasi {extension} bo‘lmasligi kerak",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "Quyidagi fayllar allaqachon yuklangan: {duplicateNames}. Takroriy fayllarga ruxsat berilmaydi.",
|
"duplicate_files": "Quyidagi fayllar allaqachon yuklangan: {duplicateNames}. Takroriy fayllarga ruxsat berilmaydi.",
|
||||||
"file_size_exceeded": "Quyidagi fayl(lar) {maxSizeInMB} MB maksimal hajmdan oshib ketdi va olib tashlandi: {fileNames}",
|
"file_size_exceeded": "Quyidagi fayl(lar) {maxSizeInMB} MB maksimal hajmdan oshib ketdi va olib tashlandi: {fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "Iltimos, ushbu qurilmadan foydalanishni davom ettirish uchun so'rov sozlamalarida spam himoyasini o'chiring.",
|
"message": "Iltimos, ushbu qurilmadan foydalanishni davom ettirish uchun so'rov sozlamalarida spam himoyasini o'chiring.",
|
||||||
"title": "Ushbu qurilma spam himoyasini qo'llab-quvvatlamaydi."
|
"title": "Ushbu qurilma spam himoyasini qo'llab-quvvatlamaydi."
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "Iltimos, uchrashuvni bron qiling",
|
"invalid_format": "Iltimos, to‘g‘ri formatda kiriting",
|
||||||
|
"is_between": "Iltimos, {startDate} va {endDate} oralig‘idagi sanani tanlang",
|
||||||
|
"is_earlier_than": "Iltimos, {date} dan oldingi sanani tanlang",
|
||||||
|
"is_greater_than": "Iltimos, {min} dan katta qiymat kiriting",
|
||||||
|
"is_later_than": "Iltimos, {date} dan keyingi sanani tanlang",
|
||||||
|
"is_less_than": "Iltimos, {max} dan kichik qiymat kiriting",
|
||||||
|
"is_not_between": "Iltimos, {startDate} va {endDate} oralig‘idan tashqaridagi sanani tanlang",
|
||||||
|
"max_length": "Iltimos, {max} ta belgidan ko‘p bo‘lmagan matn kiriting",
|
||||||
|
"max_selections": "Iltimos, {max} tadan ortiq variant tanlamang",
|
||||||
|
"max_value": "Iltimos, {max} dan katta bo‘lmagan qiymat kiriting",
|
||||||
|
"min_length": "Iltimos, kamida {min} ta belgi kiriting",
|
||||||
|
"min_selections": "Iltimos, kamida {min} ta variant tanlang",
|
||||||
|
"min_value": "Iltimos, kamida {min} qiymat kiriting",
|
||||||
|
"minimum_options_ranked": "Iltimos, kamida {min} ta variantni tartiblang",
|
||||||
|
"minimum_rows_answered": "Iltimos, kamida {min} ta qatorga javob bering",
|
||||||
"please_enter_a_valid_email_address": "Iltimos, toʻgʻri elektron pochta manzilini kiriting",
|
"please_enter_a_valid_email_address": "Iltimos, toʻgʻri elektron pochta manzilini kiriting",
|
||||||
"please_enter_a_valid_phone_number": "Iltimos, to'g'ri telefon raqamini kiriting",
|
"please_enter_a_valid_phone_number": "Iltimos, to'g'ri telefon raqamini kiriting",
|
||||||
"please_enter_a_valid_url": "Iltimos, toʻgʻri URL manzilini kiriting",
|
"please_enter_a_valid_url": "Iltimos, toʻgʻri URL manzilini kiriting",
|
||||||
"please_fill_out_this_field": "Iltimos, ushbu maydonni to'ldiring",
|
"please_fill_out_this_field": "Iltimos, ushbu maydonni to'ldiring",
|
||||||
"please_rank_all_items_before_submitting": "Iltimos, yuborishdan oldin barcha elementlarni baholang",
|
|
||||||
"please_select_a_date": "Iltimos, sanani tanlang",
|
|
||||||
"please_select_an_option": "Iltimos, biror bir variantni tanlang",
|
|
||||||
"please_upload_a_file": "Iltimos, faylni yuklang",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "Sizning javobingiz avtomatlashtirilgan faoliyat sifatida belgilanganligi sababli yuborilmadi. Agar siz nafas olayotgan bo'lsangiz, qayta urinib ko'ring.",
|
"message": "Sizning javobingiz avtomatlashtirilgan faoliyat sifatida belgilanganligi sababli yuborilmadi. Agar siz nafas olayotgan bo'lsangiz, qayta urinib ko'ring.",
|
||||||
"title": "Biz sizning inson ekanligingizni tasdiqlay olmadik."
|
"title": "Biz sizning inson ekanligingizni tasdiqlay olmadik."
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "Qiymatda {value} bo‘lishi kerak",
|
||||||
|
"value_must_equal": "Qiymat {value} ga teng bo‘lishi kerak",
|
||||||
|
"value_must_not_contain": "Qiymatda {value} bo‘lmasligi kerak",
|
||||||
|
"value_must_not_equal": "Qiymat {value} ga teng bo‘lmasligi kerak"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"open_in_new_tab": "在新标签页中打开",
|
"open_in_new_tab": "在新标签页中打开",
|
||||||
"people_responded": "{count, plural, one {1 人已回应} other {{count} 人已回应}}",
|
"people_responded": "{count, plural, one {1 人已回应} other {{count} 人已回应}}",
|
||||||
"please_retry_now_or_try_again_later": "请立即重试或稍后再试。",
|
"please_retry_now_or_try_again_later": "请立即重试或稍后再试。",
|
||||||
"please_specify": "请具体说明",
|
|
||||||
"powered_by": "技术支持",
|
"powered_by": "技术支持",
|
||||||
"privacy_policy": "隐私政策",
|
"privacy_policy": "隐私政策",
|
||||||
"protected_by_reCAPTCHA_and_the_Google": "受 reCAPTCHA 和 Google 保护",
|
"protected_by_reCAPTCHA_and_the_Google": "受 reCAPTCHA 和 Google 保护",
|
||||||
@@ -32,6 +31,9 @@
|
|||||||
"your_feedback_is_stuck": "您的反馈卡住了 :("
|
"your_feedback_is_stuck": "您的反馈卡住了 :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"all_options_must_be_ranked": "请对所有选项进行排序",
|
||||||
|
"file_extension_must_be": "文件扩展名必须为{extension}",
|
||||||
|
"file_extension_must_not_be": "文件扩展名不能为{extension}",
|
||||||
"file_input": {
|
"file_input": {
|
||||||
"duplicate_files": "以下文件已上传:{duplicateNames}。不允许重复文件。",
|
"duplicate_files": "以下文件已上传:{duplicateNames}。不允许重复文件。",
|
||||||
"file_size_exceeded": "以下文件超过了最大大小 {maxSizeInMB} MB,已被移除:{fileNames}",
|
"file_size_exceeded": "以下文件超过了最大大小 {maxSizeInMB} MB,已被移除:{fileNames}",
|
||||||
@@ -45,18 +47,32 @@
|
|||||||
"message": "请在调查设置中禁用垃圾邮件保护以继续使用此设备。",
|
"message": "请在调查设置中禁用垃圾邮件保护以继续使用此设备。",
|
||||||
"title": "此设备不支持垃圾邮件保护。"
|
"title": "此设备不支持垃圾邮件保护。"
|
||||||
},
|
},
|
||||||
"please_book_an_appointment": "请预约",
|
"invalid_format": "请输入有效的格式",
|
||||||
|
"is_between": "请选择{startDate}到{endDate}之间的日期",
|
||||||
|
"is_earlier_than": "请选择早于{date}的日期",
|
||||||
|
"is_greater_than": "请输入大于{min}的值",
|
||||||
|
"is_later_than": "请选择晚于{date}的日期",
|
||||||
|
"is_less_than": "请输入小于{max}的值",
|
||||||
|
"is_not_between": "请选择不在{startDate}到{endDate}之间的日期",
|
||||||
|
"max_length": "请输入不超过{max}个字符",
|
||||||
|
"max_selections": "请选择不超过{max}个选项",
|
||||||
|
"max_value": "请输入不大于{max}的值",
|
||||||
|
"min_length": "请输入至少{min}个字符",
|
||||||
|
"min_selections": "请选择至少{min}个选项",
|
||||||
|
"min_value": "请输入不少于{min}的值",
|
||||||
|
"minimum_options_ranked": "请至少排序{min}个选项",
|
||||||
|
"minimum_rows_answered": "请至少回答{min}行",
|
||||||
"please_enter_a_valid_email_address": "请输入有效的电子邮件地址",
|
"please_enter_a_valid_email_address": "请输入有效的电子邮件地址",
|
||||||
"please_enter_a_valid_phone_number": "请输入有效的电话号码",
|
"please_enter_a_valid_phone_number": "请输入有效的电话号码",
|
||||||
"please_enter_a_valid_url": "请输入有效的URL",
|
"please_enter_a_valid_url": "请输入有效的URL",
|
||||||
"please_fill_out_this_field": "请填写此字段",
|
"please_fill_out_this_field": "请填写此字段",
|
||||||
"please_rank_all_items_before_submitting": "请在提交之前对所有项目进行排名",
|
|
||||||
"please_select_a_date": "请选择一个日期",
|
|
||||||
"please_select_an_option": "请选择一个选项",
|
|
||||||
"please_upload_a_file": "请上传一个文件",
|
|
||||||
"recaptcha_error": {
|
"recaptcha_error": {
|
||||||
"message": "您的响应未能提交,因为它被标记为自动活动。如果您是人类,请重试。",
|
"message": "您的响应未能提交,因为它被标记为自动活动。如果您是人类,请重试。",
|
||||||
"title": "我们无法验证您是人类。"
|
"title": "我们无法验证您是人类。"
|
||||||
}
|
},
|
||||||
|
"value_must_contain": "值必须包含{value}",
|
||||||
|
"value_must_equal": "值必须等于{value}",
|
||||||
|
"value_must_not_contain": "值不能包含{value}",
|
||||||
|
"value_must_not_equal": "值不能等于{value}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface AddressElementProps {
|
|||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddressElement({
|
export function AddressElement({
|
||||||
@@ -26,9 +27,11 @@ export function AddressElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: 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.required;
|
||||||
|
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
|
|
||||||
@@ -117,10 +120,11 @@ 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}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useState } from "preact/hooks";
|
import { useCallback, useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyCalElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyCalElement } from "@formbricks/types/surveys/elements";
|
||||||
import { CalEmbed } from "@/components/general/cal-embed";
|
import { CalEmbed } from "@/components/general/cal-embed";
|
||||||
@@ -17,6 +16,7 @@ interface CalElementProps {
|
|||||||
ttc: TResponseTtc;
|
ttc: TResponseTtc;
|
||||||
setTtc: (ttc: TResponseTtc) => void;
|
setTtc: (ttc: TResponseTtc) => void;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalElement({
|
export function CalElement({
|
||||||
@@ -27,30 +27,24 @@ export function CalElement({
|
|||||||
ttc,
|
ttc,
|
||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
|
errorMessage,
|
||||||
}: Readonly<CalElementProps>) {
|
}: Readonly<CalElementProps>) {
|
||||||
const { t } = useTranslation();
|
|
||||||
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("");
|
|
||||||
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(() => {
|
||||||
setErrorMessage("");
|
|
||||||
onChange({ [element.id]: "booked" });
|
onChange({ [element.id]: "booked" });
|
||||||
const updatedttc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedttc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedttc);
|
setTtc(updatedttc);
|
||||||
}, [onChange, element.id, setTtc, startTime, ttc, setErrorMessage]);
|
}, [onChange, element.id, setTtc, startTime, ttc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
key={element.id}
|
key={element.id}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (element.required && !value) {
|
// Validation is handled by centralized system, just update TTC
|
||||||
setErrorMessage(t("errors.please_book_an_appointment"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedttc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedttc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedttc);
|
setTtc(updatedttc);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Consent } from "@formbricks/survey-ui";
|
import { Consent } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyConsentElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyConsentElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -16,6 +15,7 @@ interface ConsentElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConsentElement({
|
export function ConsentElement({
|
||||||
@@ -27,30 +27,20 @@ export function ConsentElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<ConsentElementProps>) {
|
}: Readonly<ConsentElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
|
const isRequired = element.required;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleChange = (checked: boolean) => {
|
const handleChange = (checked: boolean) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
onChange({ [element.id]: checked ? "accepted" : "" });
|
onChange({ [element.id]: checked ? "accepted" : "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required && value !== "accepted") {
|
|
||||||
setErrorMessage(t("errors.please_fill_out_this_field"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (!validateRequired()) return;
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -65,7 +55,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}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface ContactInfoElementProps {
|
|||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactInfoElement({
|
export function ContactInfoElement({
|
||||||
@@ -27,9 +28,11 @@ export function ContactInfoElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: 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.required;
|
||||||
|
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
|
|
||||||
@@ -113,10 +116,11 @@ 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}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { DateElement as SurveyUIDateElement } from "@formbricks/survey-ui";
|
import { DateElement as SurveyUIDateElement } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyDateElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyDateElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -16,6 +15,7 @@ interface DateElementProps {
|
|||||||
setTtc: (ttc: TResponseTtc) => void;
|
setTtc: (ttc: TResponseTtc) => void;
|
||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DateElement({
|
export function DateElement({
|
||||||
@@ -26,32 +26,21 @@ export function DateElement({
|
|||||||
ttc,
|
ttc,
|
||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
|
errorMessage,
|
||||||
}: Readonly<DateElementProps>) {
|
}: Readonly<DateElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
|
const isRequired = element.required;
|
||||||
|
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleChange = (dateValue: string) => {
|
const handleChange = (dateValue: string) => {
|
||||||
// Clear error when user selects a date
|
|
||||||
setErrorMessage(undefined);
|
|
||||||
onChange({ [element.id]: dateValue });
|
onChange({ [element.id]: dateValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required && (!value || value.trim() === "")) {
|
|
||||||
setErrorMessage(t("errors.please_select_a_date"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateRequired()) return;
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -75,7 +64,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}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface FileUploadElementProps {
|
|||||||
setTtc: (ttc: TResponseTtc) => void;
|
setTtc: (ttc: TResponseTtc) => void;
|
||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILE_LIMIT = 25;
|
const FILE_LIMIT = 25;
|
||||||
@@ -32,16 +33,21 @@ export function FileUploadElement({
|
|||||||
ttc,
|
ttc,
|
||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
|
errorMessage: centralizedErrorMessage,
|
||||||
}: Readonly<FileUploadElementProps>) {
|
}: Readonly<FileUploadElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
const [fileErrorMessage, setFileErrorMessage] = 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.required;
|
||||||
|
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [fileNames, setFileNames] = useState<Record<string, string>>({});
|
const [fileNames, setFileNames] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Use centralized error message for required validation, file-specific errors for upload issues
|
||||||
|
const errorMessage = centralizedErrorMessage || fileErrorMessage;
|
||||||
|
|
||||||
// Convert string[] to UploadedFile[] for survey-ui component
|
// Convert string[] to UploadedFile[] for survey-ui component
|
||||||
const convertToUploadedFiles = (urls: string[]): UploadedFile[] => {
|
const convertToUploadedFiles = (urls: string[]): UploadedFile[] => {
|
||||||
return urls.map((url) => {
|
return urls.map((url) => {
|
||||||
@@ -85,8 +91,8 @@ export function FileUploadElement({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (files: UploadedFile[]) => {
|
const handleChange = (files: UploadedFile[]) => {
|
||||||
// Clear error when user uploads files
|
// Clear file-specific errors when user uploads files (centralized errors are cleared by block-conditional)
|
||||||
setErrorMessage(undefined);
|
setFileErrorMessage(undefined);
|
||||||
|
|
||||||
// Store names locally
|
// Store names locally
|
||||||
const newFileNames: Record<string, string> = {};
|
const newFileNames: Record<string, string> = {};
|
||||||
@@ -128,7 +134,7 @@ export function FileUploadElement({
|
|||||||
|
|
||||||
if (duplicateFiles.length > 0) {
|
if (duplicateFiles.length > 0) {
|
||||||
const duplicateNames = duplicateFiles.map((file) => file.name).join(", ");
|
const duplicateNames = duplicateFiles.map((file) => file.name).join(", ");
|
||||||
setErrorMessage(t("errors.file_input.duplicate_files", { duplicateNames }));
|
setFileErrorMessage(t("errors.file_input.duplicate_files", { duplicateNames }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { filteredFiles, duplicateFiles };
|
return { filteredFiles, duplicateFiles };
|
||||||
@@ -166,7 +172,7 @@ export function FileUploadElement({
|
|||||||
|
|
||||||
const fileSizeInMB = file.size / (1024 * 1024);
|
const fileSizeInMB = file.size / (1024 * 1024);
|
||||||
if (fileSizeInMB > element.maxSizeInMB) {
|
if (fileSizeInMB > element.maxSizeInMB) {
|
||||||
setErrorMessage(
|
setFileErrorMessage(
|
||||||
t("errors.file_input.file_size_exceeded_alert", { maxSizeInMB: element.maxSizeInMB })
|
t("errors.file_input.file_size_exceeded_alert", { maxSizeInMB: element.maxSizeInMB })
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
@@ -183,12 +189,12 @@ export function FileUploadElement({
|
|||||||
const validateFileLimits = useCallback(
|
const validateFileLimits = useCallback(
|
||||||
(fileArray: File[]): boolean => {
|
(fileArray: File[]): boolean => {
|
||||||
if (!element.allowMultipleFiles && fileArray.length > 1) {
|
if (!element.allowMultipleFiles && fileArray.length > 1) {
|
||||||
setErrorMessage(t("errors.file_input.only_one_file_can_be_uploaded_at_a_time"));
|
setFileErrorMessage(t("errors.file_input.only_one_file_can_be_uploaded_at_a_time"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.allowMultipleFiles && (value?.length || 0) + fileArray.length > FILE_LIMIT) {
|
if (element.allowMultipleFiles && (value?.length || 0) + fileArray.length > FILE_LIMIT) {
|
||||||
setErrorMessage(t("errors.file_input.you_can_only_upload_a_maximum_of_files", { FILE_LIMIT }));
|
setFileErrorMessage(t("errors.file_input.you_can_only_upload_a_maximum_of_files", { FILE_LIMIT }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +210,7 @@ export function FileUploadElement({
|
|||||||
async (files: File[]): Promise<{ filesToUpload: File[]; sizeRejectedFiles: string[] }> => {
|
async (files: File[]): Promise<{ filesToUpload: File[]; sizeRejectedFiles: string[] }> => {
|
||||||
const validFiles = files.filter((file) => validateFileExtension(file));
|
const validFiles = files.filter((file) => validateFileExtension(file));
|
||||||
if (validFiles.length === 0) {
|
if (validFiles.length === 0) {
|
||||||
setErrorMessage(t("errors.file_input.no_valid_file_types_selected"));
|
setFileErrorMessage(t("errors.file_input.no_valid_file_types_selected"));
|
||||||
return { filesToUpload: [], sizeRejectedFiles: [] };
|
return { filesToUpload: [], sizeRejectedFiles: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +228,7 @@ export function FileUploadElement({
|
|||||||
|
|
||||||
if (sizeRejectedFiles.length > 0 && element.maxSizeInMB) {
|
if (sizeRejectedFiles.length > 0 && element.maxSizeInMB) {
|
||||||
const fileNames = sizeRejectedFiles.join(", ");
|
const fileNames = sizeRejectedFiles.join(", ");
|
||||||
setErrorMessage(
|
setFileErrorMessage(
|
||||||
t("errors.file_input.file_size_exceeded", { fileNames, maxSizeInMB: element.maxSizeInMB })
|
t("errors.file_input.file_size_exceeded", { fileNames, maxSizeInMB: element.maxSizeInMB })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,15 +281,15 @@ export function FileUploadElement({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear error on success
|
// Clear error on success
|
||||||
setErrorMessage(undefined);
|
setFileErrorMessage(undefined);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Handle upload errors
|
// Handle upload errors
|
||||||
if (err?.name === "FileTooLargeError") {
|
if (err?.name === "FileTooLargeError") {
|
||||||
setErrorMessage(err.message);
|
setFileErrorMessage(err.message);
|
||||||
} else if (err?.name === "InvalidFileNameError") {
|
} else if (err?.name === "InvalidFileNameError") {
|
||||||
setErrorMessage(t("errors.file_input.upload_failed"));
|
setFileErrorMessage(t("errors.file_input.upload_failed"));
|
||||||
} else {
|
} else {
|
||||||
setErrorMessage(t("errors.file_input.upload_failed"));
|
setFileErrorMessage(t("errors.file_input.upload_failed"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
@@ -298,7 +304,7 @@ export function FileUploadElement({
|
|||||||
const handleFileSelect = useCallback(
|
const handleFileSelect = useCallback(
|
||||||
async (files: FileList): Promise<void> => {
|
async (files: FileList): Promise<void> => {
|
||||||
// Clear previous errors
|
// Clear previous errors
|
||||||
setErrorMessage(undefined);
|
setFileErrorMessage(undefined);
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
@@ -325,17 +331,9 @@ export function FileUploadElement({
|
|||||||
[validateFileLimits, filterDuplicateFiles, validateAndFilterFiles, uploadFiles]
|
[validateFileLimits, filterDuplicateFiles, validateAndFilterFiles, uploadFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required && (!value || value.length === 0)) {
|
|
||||||
setErrorMessage(t("errors.please_upload_a_file"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateRequired()) return;
|
// Validation is handled by centralized system, just update TTC
|
||||||
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
@@ -353,7 +351,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}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo, useState } from "preact/hooks";
|
import { useMemo, useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Matrix, type MatrixOption } from "@formbricks/survey-ui";
|
import { Matrix, type MatrixOption } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -15,6 +14,7 @@ interface MatrixElementProps {
|
|||||||
ttc: TResponseTtc;
|
ttc: TResponseTtc;
|
||||||
setTtc: (ttc: TResponseTtc) => void;
|
setTtc: (ttc: TResponseTtc) => void;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatrixElement({
|
export function MatrixElement({
|
||||||
@@ -25,13 +25,13 @@ export function MatrixElement({
|
|||||||
ttc,
|
ttc,
|
||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
|
errorMessage,
|
||||||
}: Readonly<MatrixElementProps>) {
|
}: Readonly<MatrixElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
|
const isRequired = element.required;
|
||||||
|
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const rowShuffleIdx = useMemo(() => {
|
const rowShuffleIdx = useMemo(() => {
|
||||||
if (element.shuffleOption !== "none") {
|
if (element.shuffleOption !== "none") {
|
||||||
@@ -109,7 +109,6 @@ export function MatrixElement({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (newValue: Record<string, string>) => {
|
const handleChange = (newValue: Record<string, string>) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
const labelValue = convertValueFromIds(newValue);
|
const labelValue = convertValueFromIds(newValue);
|
||||||
|
|
||||||
// Check if all values are empty and if so, make it an empty object
|
// Check if all values are empty and if so, make it an empty object
|
||||||
@@ -120,21 +119,9 @@ export function MatrixElement({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required) {
|
|
||||||
const hasUnansweredRows = rows.some((row) => !value[row.label]);
|
|
||||||
if (hasUnansweredRows) {
|
|
||||||
setErrorMessage(t("errors.please_select_an_option"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (!validateRequired()) return;
|
|
||||||
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtc);
|
setTtc(updatedTtc);
|
||||||
};
|
};
|
||||||
@@ -150,7 +137,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}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { MultiSelect, type MultiSelectOption } from "@formbricks/survey-ui";
|
import { MultiSelect, type MultiSelectOption } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -17,6 +16,7 @@ interface MultipleChoiceMultiElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultipleChoiceMultiElement({
|
export function MultipleChoiceMultiElement({
|
||||||
@@ -28,12 +28,12 @@ export function MultipleChoiceMultiElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<MultipleChoiceMultiElementProps>) {
|
}: Readonly<MultipleChoiceMultiElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [otherValue, setOtherValue] = useState("");
|
const [otherValue, setOtherValue] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
const { t } = useTranslation();
|
const isRequired = element.required;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
|
|
||||||
const shuffledChoicesIds = useMemo(() => {
|
const shuffledChoicesIds = useMemo(() => {
|
||||||
@@ -173,22 +173,9 @@ export function MultipleChoiceMultiElement({
|
|||||||
onChange({ [element.id]: nextValue });
|
onChange({ [element.id]: nextValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required && (!Array.isArray(value) || value.length === 0)) {
|
|
||||||
setErrorMessage(t("errors.please_select_an_option"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (element.required && isOtherSelected && !otherValue.trim()) {
|
|
||||||
setErrorMessage(t("errors.please_fill_out_this_field"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (!validateRequired()) return;
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -228,7 +215,6 @@ export function MultipleChoiceMultiElement({
|
|||||||
|
|
||||||
// Handle selection changes - store labels directly instead of IDs
|
// Handle selection changes - store labels directly instead of IDs
|
||||||
const handleMultiSelectChange = (selectedIds: string[]) => {
|
const handleMultiSelectChange = (selectedIds: string[]) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
const nextLabels: string[] = [];
|
const nextLabels: string[] = [];
|
||||||
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);
|
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);
|
||||||
|
|
||||||
@@ -262,7 +248,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}
|
||||||
@@ -270,7 +256,7 @@ export function MultipleChoiceMultiElement({
|
|||||||
otherOptionPlaceholder={
|
otherOptionPlaceholder={
|
||||||
element.otherOptionPlaceholder && getLocalizedValue(element.otherOptionPlaceholder, languageCode)
|
element.otherOptionPlaceholder && getLocalizedValue(element.otherOptionPlaceholder, languageCode)
|
||||||
? getLocalizedValue(element.otherOptionPlaceholder, languageCode)
|
? getLocalizedValue(element.otherOptionPlaceholder, languageCode)
|
||||||
: t("common.please_specify")
|
: undefined
|
||||||
}
|
}
|
||||||
otherValue={otherValue}
|
otherValue={otherValue}
|
||||||
onOtherValueChange={handleOtherValueChange}
|
onOtherValueChange={handleOtherValueChange}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { SingleSelect, type SingleSelectOption } from "@formbricks/survey-ui";
|
import { SingleSelect, type SingleSelectOption } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -17,6 +16,7 @@ interface MultipleChoiceSingleElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultipleChoiceSingleElement({
|
export function MultipleChoiceSingleElement({
|
||||||
@@ -28,13 +28,13 @@ export function MultipleChoiceSingleElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<MultipleChoiceSingleElementProps>) {
|
}: Readonly<MultipleChoiceSingleElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [otherValue, setOtherValue] = useState("");
|
const [otherValue, setOtherValue] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
|
const isRequired = element.required;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const shuffledChoicesIds = useMemo(() => {
|
const shuffledChoicesIds = useMemo(() => {
|
||||||
if (element.shuffleOption) {
|
if (element.shuffleOption) {
|
||||||
@@ -90,7 +90,6 @@ export function MultipleChoiceSingleElement({
|
|||||||
}, [isOtherSelected, value]);
|
}, [isOtherSelected, value]);
|
||||||
|
|
||||||
const handleChange = (selectedValue: string) => {
|
const handleChange = (selectedValue: string) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
if (selectedValue === otherOption?.id) {
|
if (selectedValue === otherOption?.id) {
|
||||||
setOtherValue("");
|
setOtherValue("");
|
||||||
onChange({ [element.id]: "" });
|
onChange({ [element.id]: "" });
|
||||||
@@ -152,24 +151,9 @@ export function MultipleChoiceSingleElement({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [value, otherOption, allOptions, isOtherSelected]);
|
}, [value, otherOption, allOptions, isOtherSelected]);
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
// Check if nothing is selected
|
|
||||||
if (element.required && selectedValue === undefined) {
|
|
||||||
setErrorMessage(t("errors.please_select_an_option"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Check if "other" is selected but not filled
|
|
||||||
if (element.required && isOtherSelected && !otherValue.trim()) {
|
|
||||||
setErrorMessage(t("errors.please_fill_out_this_field"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (!validateRequired()) return;
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -184,7 +168,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}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { NPS as Nps } from "@formbricks/survey-ui";
|
import { NPS as Nps } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyNPSElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyNPSElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -17,6 +16,7 @@ interface NPSElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NPSElement({
|
export function NPSElement({
|
||||||
@@ -28,32 +28,22 @@ export function NPSElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<NPSElementProps>) {
|
}: Readonly<NPSElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
|
const isRequired = element.required;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleChange = (npsValue: number) => {
|
const handleChange = (npsValue: number) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
onChange({ [element.id]: npsValue });
|
onChange({ [element.id]: npsValue });
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required && value === undefined) {
|
|
||||||
setErrorMessage(t("errors.please_select_an_option"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (!validateRequired()) return;
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -70,7 +60,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}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { OpenText } from "@formbricks/survey-ui";
|
import { OpenText } from "@formbricks/survey-ui";
|
||||||
import { ZEmail, ZUrl } from "@formbricks/types/common";
|
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
@@ -18,6 +16,7 @@ interface OpenTextElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenTextElement({
|
export function OpenTextElement({
|
||||||
@@ -29,76 +28,20 @@ export function OpenTextElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<OpenTextElementProps>) {
|
}: Readonly<OpenTextElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
|
const isRequired = element.required;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleChange = (inputValue: string) => {
|
const handleChange = (inputValue: string) => {
|
||||||
// Clear error when user starts typing
|
|
||||||
setErrorMessage(undefined);
|
|
||||||
onChange({ [element.id]: inputValue });
|
onChange({ [element.id]: inputValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required && (!value || value.trim() === "")) {
|
|
||||||
setErrorMessage(t("errors.please_fill_out_this_field"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateEmail = (): boolean => {
|
|
||||||
if (!ZEmail.safeParse(value).success) {
|
|
||||||
setErrorMessage(t("errors.please_enter_a_valid_email_address"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateUrl = (): boolean => {
|
|
||||||
if (!ZUrl.safeParse(value).success) {
|
|
||||||
setErrorMessage(t("errors.please_enter_a_valid_url"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatePhone = (): boolean => {
|
|
||||||
// Match the same pattern: must start with digit or +, end with digit
|
|
||||||
// Allows digits, +, -, and spaces in between
|
|
||||||
const phoneRegex = /^[0-9+][0-9+\- ]*[0-9]$/;
|
|
||||||
if (!phoneRegex.test(value)) {
|
|
||||||
setErrorMessage(t("errors.please_enter_a_valid_phone_number"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateInput = (): boolean => {
|
|
||||||
if (!value || value.trim() === "") return true;
|
|
||||||
|
|
||||||
if (element.inputType === "email") {
|
|
||||||
return validateEmail();
|
|
||||||
}
|
|
||||||
if (element.inputType === "url") {
|
|
||||||
return validateUrl();
|
|
||||||
}
|
|
||||||
if (element.inputType === "phone") {
|
|
||||||
return validatePhone();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnSubmit = (e: Event) => {
|
const handleOnSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
|
|
||||||
if (!validateRequired()) return;
|
|
||||||
if (!validateInput()) return;
|
|
||||||
|
|
||||||
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtc);
|
setTtc(updatedTtc);
|
||||||
};
|
};
|
||||||
@@ -122,7 +65,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}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { PictureSelect, type PictureSelectOption } from "@formbricks/survey-ui";
|
import { PictureSelect, type PictureSelectOption } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -17,6 +16,7 @@ interface PictureSelectionProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PictureSelectionElement({
|
export function PictureSelectionElement({
|
||||||
@@ -28,12 +28,12 @@ export function PictureSelectionElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<PictureSelectionProps>) {
|
}: Readonly<PictureSelectionProps>) {
|
||||||
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 isRequired = element.required;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
// Convert choices to PictureSelectOption format
|
// Convert choices to PictureSelectOption format
|
||||||
const options: PictureSelectOption[] = element.choices.map((choice) => ({
|
const options: PictureSelectOption[] = element.choices.map((choice) => ({
|
||||||
id: choice.id,
|
id: choice.id,
|
||||||
@@ -52,7 +52,6 @@ export function PictureSelectionElement({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (newValue: string | string[]) => {
|
const handleChange = (newValue: string | string[]) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
let stringArray: string[];
|
let stringArray: string[];
|
||||||
if (Array.isArray(newValue)) {
|
if (Array.isArray(newValue)) {
|
||||||
stringArray = newValue;
|
stringArray = newValue;
|
||||||
@@ -66,21 +65,9 @@ export function PictureSelectionElement({
|
|||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (element.required) {
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (element.allowMulti) {
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
if (!currentValue || !Array.isArray(currentValue) || currentValue.length === 0) {
|
setTtc(updatedTtcObj);
|
||||||
setErrorMessage(t("errors.please_select_an_option"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!currentValue) {
|
|
||||||
setErrorMessage(t("errors.please_select_an_option"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
|
||||||
setTtc(updatedTtcObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +81,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}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo, useState } from "preact/hooks";
|
import { useMemo, useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Ranking, type RankingOption } from "@formbricks/survey-ui";
|
import { Ranking, type RankingOption } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -16,6 +15,7 @@ interface RankingElementProps {
|
|||||||
setTtc: (ttc: TResponseTtc) => void;
|
setTtc: (ttc: TResponseTtc) => void;
|
||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RankingElement({
|
export function RankingElement({
|
||||||
@@ -26,11 +26,11 @@ export function RankingElement({
|
|||||||
ttc,
|
ttc,
|
||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
|
errorMessage,
|
||||||
}: Readonly<RankingElementProps>) {
|
}: Readonly<RankingElementProps>) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
|
const isRequired = element.required;
|
||||||
|
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
|
|
||||||
@@ -90,9 +90,6 @@ export function RankingElement({
|
|||||||
|
|
||||||
// Handle selection changes - store labels directly instead of IDs
|
// Handle selection changes - store labels directly instead of IDs
|
||||||
const handleChange = (selectedIds: string[]) => {
|
const handleChange = (selectedIds: string[]) => {
|
||||||
// Clear error when user changes ranking
|
|
||||||
setErrorMessage(undefined);
|
|
||||||
|
|
||||||
const nextLabels: string[] = [];
|
const nextLabels: string[] = [];
|
||||||
selectedIds.forEach((id) => {
|
selectedIds.forEach((id) => {
|
||||||
const matchingOption = options.find((opt) => opt.id === id);
|
const matchingOption = options.find((opt) => opt.id === id);
|
||||||
@@ -105,22 +102,9 @@ export function RankingElement({
|
|||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
const isValueArray = Array.isArray(value);
|
|
||||||
const allItemsRanked = isValueArray && value.length === element.choices.length;
|
|
||||||
|
|
||||||
if ((element.required && !allItemsRanked) || (!element.required && value.length > 0 && !allItemsRanked)) {
|
|
||||||
setErrorMessage(t("errors.please_rank_all_items_before_submitting"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateRequired()) return;
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -135,7 +119,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}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Rating } from "@formbricks/survey-ui";
|
import { Rating } from "@formbricks/survey-ui";
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
|
||||||
@@ -15,6 +14,7 @@ interface RatingElementProps {
|
|||||||
setTtc: (ttc: TResponseTtc) => void;
|
setTtc: (ttc: TResponseTtc) => void;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RatingElement({
|
export function RatingElement({
|
||||||
@@ -26,31 +26,22 @@ export function RatingElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: RatingElementProps) {
|
}: RatingElementProps) {
|
||||||
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 isRequired = element.required;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleChange = (ratingValue: number) => {
|
const handleChange = (ratingValue: number) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
onChange({ [element.id]: ratingValue });
|
onChange({ [element.id]: ratingValue });
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateRequired = (): boolean => {
|
|
||||||
if (element.required && !value) {
|
|
||||||
setErrorMessage(t("errors.please_select_an_option"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: Event) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateRequired()) return;
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -69,7 +60,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,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||||
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||||
@@ -9,12 +10,14 @@ import {
|
|||||||
TSurveyMatrixElement,
|
TSurveyMatrixElement,
|
||||||
TSurveyRankingElement,
|
TSurveyRankingElement,
|
||||||
} from "@formbricks/types/surveys/elements";
|
} from "@formbricks/types/surveys/elements";
|
||||||
|
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
|
||||||
import { BackButton } from "@/components/buttons/back-button";
|
import { BackButton } from "@/components/buttons/back-button";
|
||||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||||
import { ElementConditional } from "@/components/general/element-conditional";
|
import { ElementConditional } from "@/components/general/element-conditional";
|
||||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getFirstErrorMessage, validateBlockResponses } from "@/lib/validation";
|
||||||
|
|
||||||
interface BlockConditionalProps {
|
interface BlockConditionalProps {
|
||||||
block: TSurveyBlock;
|
block: TSurveyBlock;
|
||||||
@@ -59,9 +62,14 @@ export function BlockConditional({
|
|||||||
dir,
|
dir,
|
||||||
fullSizeCards,
|
fullSizeCards,
|
||||||
}: BlockConditionalProps) {
|
}: BlockConditionalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Track the current element being filled (for TTC tracking)
|
// Track the current element being filled (for TTC tracking)
|
||||||
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
|
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
|
||||||
|
|
||||||
|
// State to store validation errors from centralized validation
|
||||||
|
const [elementErrors, setElementErrors] = useState<TValidationErrorMap>({});
|
||||||
|
|
||||||
// Refs to store form elements for each element so we can trigger their validation
|
// Refs to store form elements for each element so we can trigger their validation
|
||||||
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
||||||
|
|
||||||
@@ -74,6 +82,14 @@ export function BlockConditional({
|
|||||||
if (elementId !== currentElementId) {
|
if (elementId !== currentElementId) {
|
||||||
setCurrentElementId(elementId);
|
setCurrentElementId(elementId);
|
||||||
}
|
}
|
||||||
|
// Clear error for this element when user makes a change
|
||||||
|
if (elementErrors[elementId]) {
|
||||||
|
setElementErrors((prev: TValidationErrorMap) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
delete updated[elementId];
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
// Merge with existing block data to preserve other element values
|
// Merge with existing block data to preserve other element values
|
||||||
onChange({ ...value, ...responseData });
|
onChange({ ...value, ...responseData });
|
||||||
};
|
};
|
||||||
@@ -131,19 +147,19 @@ export function BlockConditional({
|
|||||||
response: unknown,
|
response: unknown,
|
||||||
form: HTMLFormElement
|
form: HTMLFormElement
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const rankingElement = element;
|
const isRequired = element.required;
|
||||||
const hasIncompleteRanking =
|
const isValueArray = Array.isArray(response);
|
||||||
(rankingElement.required &&
|
const atLeastOneRanked = isValueArray && response.length >= 1;
|
||||||
(!Array.isArray(response) || response.length !== rankingElement.choices.length)) ||
|
|
||||||
(!rankingElement.required &&
|
|
||||||
Array.isArray(response) &&
|
|
||||||
response.length > 0 &&
|
|
||||||
response.length < rankingElement.choices.length);
|
|
||||||
|
|
||||||
if (hasIncompleteRanking) {
|
// If required: at least 1 option must be ranked
|
||||||
|
if (isRequired && (!isValueArray || !atLeastOneRanked)) {
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If not required: allow partial ranking (some items ranked, some not)
|
||||||
|
// No validation needed - user can proceed with any number of ranked items (including 0)
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,21 +201,21 @@ export function BlockConditional({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// Custom validation for matrix questions
|
||||||
element.type === TSurveyElementTypeEnum.Matrix &&
|
if (element.type === TSurveyElementTypeEnum.Matrix) {
|
||||||
element.required &&
|
if (element.required && (!response || hasUnansweredRows(response, element))) {
|
||||||
response &&
|
form.requestSubmit();
|
||||||
hasUnansweredRows(response, element)
|
return false;
|
||||||
) {
|
}
|
||||||
form.requestSubmit();
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other element types, check if required fields are empty
|
// For other element types, check if required fields are empty
|
||||||
// CTA elements should not block navigation even if marked required (as they are informational)
|
// CTA elements should not block navigation even if marked required (as they are informational)
|
||||||
if (element.type !== TSurveyElementTypeEnum.CTA && element.required && isEmptyResponse(response)) {
|
if (element.type !== TSurveyElementTypeEnum.CTA) {
|
||||||
form.requestSubmit();
|
if (element.required && isEmptyResponse(response)) {
|
||||||
return false;
|
form.requestSubmit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -263,15 +279,34 @@ export function BlockConditional({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all forms and check for custom validation rules
|
// Run centralized validation for elements that support it
|
||||||
const firstInvalidForm = findFirstInvalidForm();
|
const errorMap = validateBlockResponses(block.elements, value, languageCode, t);
|
||||||
|
|
||||||
// If any form is invalid, scroll to it and stop
|
// Check if there are any validation errors from centralized validation
|
||||||
|
const hasValidationErrors = Object.keys(errorMap).length > 0;
|
||||||
|
|
||||||
|
if (hasValidationErrors) {
|
||||||
|
setElementErrors(errorMap);
|
||||||
|
|
||||||
|
// Find the first element with an error and scroll to it
|
||||||
|
const firstErrorElementId = Object.keys(errorMap)[0];
|
||||||
|
const form = elementFormRefs.current.get(firstErrorElementId);
|
||||||
|
if (form) {
|
||||||
|
form.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also run legacy validation for elements not yet migrated to centralized validation
|
||||||
|
const firstInvalidForm = findFirstInvalidForm();
|
||||||
if (firstInvalidForm) {
|
if (firstInvalidForm) {
|
||||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any previous errors
|
||||||
|
setElementErrors({});
|
||||||
|
|
||||||
// Collect TTC and responses, then submit
|
// Collect TTC and responses, then submit
|
||||||
const blockTtc = collectTtcValues();
|
const blockTtc = collectTtcValues();
|
||||||
const blockResponses = collectBlockResponses();
|
const blockResponses = collectBlockResponses();
|
||||||
@@ -310,6 +345,7 @@ export function BlockConditional({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTtcCollect={handleTtcCollect}
|
onTtcCollect={handleTtcCollect}
|
||||||
|
errorMessage={getFirstErrorMessage(elementErrors, element.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface ElementConditionalProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
||||||
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
||||||
|
errorMessage?: string; // Validation error message from centralized validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElementConditional({
|
export function ElementConditional({
|
||||||
@@ -56,6 +57,7 @@ export function ElementConditional({
|
|||||||
dir,
|
dir,
|
||||||
formRef,
|
formRef,
|
||||||
onTtcCollect,
|
onTtcCollect,
|
||||||
|
errorMessage,
|
||||||
}: ElementConditionalProps) {
|
}: ElementConditionalProps) {
|
||||||
// Ref to the container div, used to find and expose the form element inside
|
// Ref to the container div, used to find and expose the form element inside
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -124,6 +126,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||||
@@ -139,6 +142,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||||
@@ -154,6 +158,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.NPS:
|
case TSurveyElementTypeEnum.NPS:
|
||||||
@@ -169,6 +174,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.CTA:
|
case TSurveyElementTypeEnum.CTA:
|
||||||
@@ -198,6 +204,7 @@ export function ElementConditional({
|
|||||||
setTtc={wrappedSetTtc}
|
setTtc={wrappedSetTtc}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.Consent:
|
case TSurveyElementTypeEnum.Consent:
|
||||||
@@ -213,6 +220,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.Date:
|
case TSurveyElementTypeEnum.Date:
|
||||||
@@ -227,6 +235,7 @@ export function ElementConditional({
|
|||||||
setTtc={wrappedSetTtc}
|
setTtc={wrappedSetTtc}
|
||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.PictureSelection:
|
case TSurveyElementTypeEnum.PictureSelection:
|
||||||
@@ -242,6 +251,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.FileUpload:
|
case TSurveyElementTypeEnum.FileUpload:
|
||||||
@@ -258,6 +268,7 @@ export function ElementConditional({
|
|||||||
setTtc={wrappedSetTtc}
|
setTtc={wrappedSetTtc}
|
||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.Cal:
|
case TSurveyElementTypeEnum.Cal:
|
||||||
@@ -271,6 +282,7 @@ export function ElementConditional({
|
|||||||
ttc={ttc}
|
ttc={ttc}
|
||||||
setTtc={wrappedSetTtc}
|
setTtc={wrappedSetTtc}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.Matrix:
|
case TSurveyElementTypeEnum.Matrix:
|
||||||
@@ -283,6 +295,7 @@ export function ElementConditional({
|
|||||||
ttc={ttc}
|
ttc={ttc}
|
||||||
setTtc={wrappedSetTtc}
|
setTtc={wrappedSetTtc}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.Address:
|
case TSurveyElementTypeEnum.Address:
|
||||||
@@ -297,6 +310,7 @@ export function ElementConditional({
|
|||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.Ranking:
|
case TSurveyElementTypeEnum.Ranking:
|
||||||
@@ -310,6 +324,7 @@ export function ElementConditional({
|
|||||||
setTtc={wrappedSetTtc}
|
setTtc={wrappedSetTtc}
|
||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.ContactInfo:
|
case TSurveyElementTypeEnum.ContactInfo:
|
||||||
@@ -324,6 +339,7 @@ export function ElementConditional({
|
|||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ interface HeadlineProps {
|
|||||||
alignTextCenter?: boolean;
|
alignTextCenter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Headline({ headline, elementId, required = true, alignTextCenter = false }: HeadlineProps) {
|
export function Headline({
|
||||||
|
headline,
|
||||||
|
elementId,
|
||||||
|
required = false,
|
||||||
|
alignTextCenter = false,
|
||||||
|
}: Readonly<HeadlineProps>) {
|
||||||
|
const hasRequiredRule = required;
|
||||||
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
|
||||||
@@ -25,7 +31,7 @@ export function Headline({ headline, elementId, required = true, alignTextCenter
|
|||||||
|
|
||||||
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}
|
||||||
|
|||||||
@@ -0,0 +1,725 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import type { TResponseData } from "@formbricks/types/responses";
|
||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
|
import type {
|
||||||
|
TSurveyAddressElement,
|
||||||
|
TSurveyContactInfoElement,
|
||||||
|
TSurveyElement,
|
||||||
|
TSurveyMatrixElement,
|
||||||
|
TSurveyOpenTextElement,
|
||||||
|
TSurveyRankingElement,
|
||||||
|
} from "@formbricks/types/surveys/elements";
|
||||||
|
import { getFirstErrorMessage, validateBlockResponses, validateElementResponse } from "./evaluator";
|
||||||
|
|
||||||
|
// Mock translation function
|
||||||
|
const mockT = vi.fn((key: string) => {
|
||||||
|
return key;
|
||||||
|
}) as unknown as TFunction;
|
||||||
|
|
||||||
|
// Mock getLocalizedValue
|
||||||
|
vi.mock("@/lib/i18n", () => ({
|
||||||
|
getLocalizedValue: (localizedString: Record<string, string> | undefined, languageCode: string): string => {
|
||||||
|
if (!localizedString) return "";
|
||||||
|
return localizedString[languageCode] || localizedString.default || "";
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("validateElementResponse", () => {
|
||||||
|
describe("required field validation", () => {
|
||||||
|
test("should return error when required field is empty", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0].ruleId).toBe("required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid when required field has value", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "test value", "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid when field is not required", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "", "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle required ranking element - at least one ranked", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "rank1",
|
||||||
|
type: TSurveyElementTypeEnum.Ranking,
|
||||||
|
headline: { default: "Rank these" },
|
||||||
|
required: true,
|
||||||
|
choices: [
|
||||||
|
{ id: "opt1", label: { default: "Option 1" } },
|
||||||
|
{ id: "opt2", label: { default: "Option 2" } },
|
||||||
|
],
|
||||||
|
} as unknown as TSurveyRankingElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, ["opt1"], "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error when required ranking element has no ranked options", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "rank1",
|
||||||
|
type: TSurveyElementTypeEnum.Ranking,
|
||||||
|
headline: { default: "Rank these" },
|
||||||
|
required: true,
|
||||||
|
choices: [
|
||||||
|
{ id: "opt1", label: { default: "Option 1" } },
|
||||||
|
{ id: "opt2", label: { default: "Option 2" } },
|
||||||
|
],
|
||||||
|
} as unknown as TSurveyRankingElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, [], "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle required matrix element - all rows must be answered", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "matrix1",
|
||||||
|
type: TSurveyElementTypeEnum.Matrix,
|
||||||
|
headline: { default: "Matrix question" },
|
||||||
|
required: true,
|
||||||
|
shuffleOption: "none",
|
||||||
|
rows: [
|
||||||
|
{ id: "row1", label: { default: "Row 1" } },
|
||||||
|
{ id: "row2", label: { default: "Row 2" } },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ id: "col1", label: { default: "Col 1" } },
|
||||||
|
{ id: "col2", label: { default: "Col 2" } },
|
||||||
|
],
|
||||||
|
} as unknown as TSurveyMatrixElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, { row1: "col1", row2: "col2" }, "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error when required matrix element has incomplete rows", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "matrix1",
|
||||||
|
type: TSurveyElementTypeEnum.Matrix,
|
||||||
|
headline: { default: "Matrix question" },
|
||||||
|
required: true,
|
||||||
|
shuffleOption: "none",
|
||||||
|
rows: [
|
||||||
|
{ id: "row1", label: { default: "Row 1" } },
|
||||||
|
{ id: "row2", label: { default: "Row 2" } },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ id: "col1", label: { default: "Col 1" } },
|
||||||
|
{ id: "col2", label: { default: "Col 2" } },
|
||||||
|
],
|
||||||
|
} as unknown as TSurveyMatrixElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, { row1: "col1" }, "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validation rules - AND logic", () => {
|
||||||
|
test("should return valid when all rules pass", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{ id: "rule1", type: "minLength", params: { min: 5 } },
|
||||||
|
{ id: "rule2", type: "maxLength", params: { max: 10 } },
|
||||||
|
],
|
||||||
|
logic: "and",
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "hello", "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error when one rule fails", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{ id: "rule1", type: "minLength", params: { min: 10 } },
|
||||||
|
{ id: "rule2", type: "maxLength", params: { max: 20 } },
|
||||||
|
],
|
||||||
|
logic: "and",
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "hi", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return multiple errors when multiple rules fail", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{ id: "rule1", type: "minLength", params: { min: 10 } },
|
||||||
|
{ id: "rule2", type: "maxLength", params: { max: 5 } },
|
||||||
|
],
|
||||||
|
logic: "and",
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "hello", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should default to AND logic when logic is not specified", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{ id: "rule1", type: "minLength", params: { min: 10 } },
|
||||||
|
{ id: "rule2", type: "maxLength", params: { max: 5 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "hello", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validation rules - OR logic", () => {
|
||||||
|
test("should return valid when at least one rule passes", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{ id: "rule1", type: "minLength", params: { min: 10 } },
|
||||||
|
{ id: "rule2", type: "maxLength", params: { max: 20 } },
|
||||||
|
],
|
||||||
|
logic: "or",
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "hello", "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error when all rules fail", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{ id: "rule1", type: "minLength", params: { min: 10 } },
|
||||||
|
{ id: "rule2", type: "maxLength", params: { max: 3 } },
|
||||||
|
],
|
||||||
|
logic: "or",
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "hello", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("implicit validation for OpenText inputType", () => {
|
||||||
|
test("should add implicit email validation for email inputType", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
inputType: "email",
|
||||||
|
required: false,
|
||||||
|
charLimit: 0,
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "invalid-email", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.ruleId === "__implicit_email__")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add implicit url validation for url inputType", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
inputType: "url",
|
||||||
|
required: false,
|
||||||
|
charLimit: 0,
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "not-a-url", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.ruleId === "__implicit_url__")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add implicit phone validation for phone inputType", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
inputType: "phone",
|
||||||
|
required: false,
|
||||||
|
charLimit: 0,
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "invalid-phone", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.ruleId === "__implicit_phone__")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not add implicit rule if explicit rule exists", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
inputType: "email",
|
||||||
|
required: false,
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "email", params: {} }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "test@example.com", "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors.some((e) => e.ruleId === "__implicit_email__")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("implicit validation for ContactInfo", () => {
|
||||||
|
test("should add implicit email validation for email field", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "contact1",
|
||||||
|
type: TSurveyElementTypeEnum.ContactInfo,
|
||||||
|
headline: { default: "Contact Info" },
|
||||||
|
firstName: { show: true, required: false, placeholder: { default: "First Name" } },
|
||||||
|
lastName: { show: true, required: false, placeholder: { default: "Last Name" } },
|
||||||
|
email: { show: true, required: false, placeholder: { default: "Email" } },
|
||||||
|
phone: { show: false, required: false, placeholder: { default: "Phone" } },
|
||||||
|
company: { show: false, required: false, placeholder: { default: "Company" } },
|
||||||
|
required: false,
|
||||||
|
} as unknown as TSurveyContactInfoElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, ["John", "Doe", "invalid-email", "", ""], "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.ruleId === "__implicit_email_field__")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add implicit phone validation for phone field", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "contact1",
|
||||||
|
type: TSurveyElementTypeEnum.ContactInfo,
|
||||||
|
headline: { default: "Contact Info" },
|
||||||
|
firstName: { show: true, required: false, placeholder: { default: "First Name" } },
|
||||||
|
lastName: { show: true, required: false, placeholder: { default: "Last Name" } },
|
||||||
|
email: { show: false, required: false, placeholder: { default: "Email" } },
|
||||||
|
phone: { show: true, required: false, placeholder: { default: "Phone" } },
|
||||||
|
company: { show: false, required: false, placeholder: { default: "Company" } },
|
||||||
|
required: false,
|
||||||
|
} as unknown as TSurveyContactInfoElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, ["John", "Doe", "", "invalid-phone", ""], "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((e) => e.ruleId === "__implicit_phone_field__")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not add implicit rule if explicit rule exists", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "contact1",
|
||||||
|
type: TSurveyElementTypeEnum.ContactInfo,
|
||||||
|
headline: { default: "Contact Info" },
|
||||||
|
firstName: { show: true, required: false, placeholder: { default: "First Name" } },
|
||||||
|
lastName: { show: true, required: false, placeholder: { default: "Last Name" } },
|
||||||
|
email: { show: true, required: false, placeholder: { default: "Email" } },
|
||||||
|
phone: { show: false, required: false, placeholder: { default: "Phone" } },
|
||||||
|
company: { show: false, required: false, placeholder: { default: "Company" } },
|
||||||
|
required: false,
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "email", field: "email", params: {} }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyContactInfoElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(
|
||||||
|
element,
|
||||||
|
["John", "Doe", "test@example.com", "", ""],
|
||||||
|
"en",
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors.some((e) => e.ruleId === "__implicit_email_field__")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("field-specific validation for Address", () => {
|
||||||
|
test("should validate specific field in address element", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "address1",
|
||||||
|
type: TSurveyElementTypeEnum.Address,
|
||||||
|
headline: { default: "Address" },
|
||||||
|
addressLine1: { show: true, required: false, placeholder: { default: "Address Line 1" } },
|
||||||
|
addressLine2: { show: false, required: false, placeholder: { default: "Address Line 2" } },
|
||||||
|
city: { show: true, required: false, placeholder: { default: "City" } },
|
||||||
|
state: { show: true, required: false, placeholder: { default: "State" } },
|
||||||
|
zip: { show: true, required: false, placeholder: { default: "ZIP" } },
|
||||||
|
country: { show: true, required: false, placeholder: { default: "Country" } },
|
||||||
|
required: false,
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "minLength", field: "city", params: { min: 3 } }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyAddressElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, ["123 Main St", "", "NY", "", "", ""], "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate correct field value", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "address1",
|
||||||
|
type: TSurveyElementTypeEnum.Address,
|
||||||
|
headline: { default: "Address" },
|
||||||
|
addressLine1: { show: true, required: false, placeholder: { default: "Address Line 1" } },
|
||||||
|
addressLine2: { show: false, required: false, placeholder: { default: "Address Line 2" } },
|
||||||
|
city: { show: true, required: false, placeholder: { default: "City" } },
|
||||||
|
state: { show: true, required: false, placeholder: { default: "State" } },
|
||||||
|
zip: { show: true, required: false, placeholder: { default: "ZIP" } },
|
||||||
|
country: { show: true, required: false, placeholder: { default: "Country" } },
|
||||||
|
required: false,
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "minLength", field: "city", params: { min: 3 } }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyAddressElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(
|
||||||
|
element,
|
||||||
|
["123 Main St", "", "New York", "", "", ""],
|
||||||
|
"en",
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("field-specific validation for ContactInfo", () => {
|
||||||
|
test("should validate specific field in contact info element", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "contact1",
|
||||||
|
type: TSurveyElementTypeEnum.ContactInfo,
|
||||||
|
headline: { default: "Contact Info" },
|
||||||
|
firstName: { show: true, required: false, placeholder: { default: "First Name" } },
|
||||||
|
lastName: { show: true, required: false, placeholder: { default: "Last Name" } },
|
||||||
|
email: { show: true, required: false, placeholder: { default: "Email" } },
|
||||||
|
phone: { show: true, required: false, placeholder: { default: "Phone" } },
|
||||||
|
company: { show: false, required: false, placeholder: { default: "Company" } },
|
||||||
|
required: false,
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "minLength", field: "firstName", params: { min: 3 } }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyContactInfoElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(
|
||||||
|
element,
|
||||||
|
["Jo", "Doe", "test@example.com", "1234567890", ""],
|
||||||
|
"en",
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matrix element validation rules", () => {
|
||||||
|
test("should skip validation rules when matrix is required", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "matrix1",
|
||||||
|
type: TSurveyElementTypeEnum.Matrix,
|
||||||
|
headline: { default: "Matrix question" },
|
||||||
|
required: true,
|
||||||
|
shuffleOption: "none",
|
||||||
|
rows: [
|
||||||
|
{ id: "row1", label: { default: "Row 1" } },
|
||||||
|
{ id: "row2", label: { default: "Row 2" } },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ id: "col1", label: { default: "Col 1" } },
|
||||||
|
{ id: "col2", label: { default: "Col 2" } },
|
||||||
|
],
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "minRowsAnswered", params: { min: 1 } }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyMatrixElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, { row1: "col1", row2: "col2" }, "en", mockT);
|
||||||
|
// Should only check required, not validation rules
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should apply validation rules when matrix is not required", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "matrix1",
|
||||||
|
type: TSurveyElementTypeEnum.Matrix,
|
||||||
|
headline: { default: "Matrix question" },
|
||||||
|
required: false,
|
||||||
|
shuffleOption: "none",
|
||||||
|
rows: [
|
||||||
|
{ id: "row1", label: { default: "Row 1" } },
|
||||||
|
{ id: "row2", label: { default: "Row 2" } },
|
||||||
|
{ id: "row3", label: { default: "Row 3" } },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ id: "col1", label: { default: "Col 1" } },
|
||||||
|
{ id: "col2", label: { default: "Col 2" } },
|
||||||
|
],
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "minRowsAnswered", params: { min: 2 } }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyMatrixElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, { row1: "col1" }, "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom error messages", () => {
|
||||||
|
test("should use custom error message when provided", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: "rule1",
|
||||||
|
type: "minLength",
|
||||||
|
params: { min: 10 },
|
||||||
|
customErrorMessage: { default: "Custom error message" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "short", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors[0].message).toBe("Custom error message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use language-specific custom error message", () => {
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: "rule1",
|
||||||
|
type: "minLength",
|
||||||
|
params: { min: 10 },
|
||||||
|
customErrorMessage: { default: "Default message", en: "English message" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "short", "en", mockT);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors[0].message).toBe("English message");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown validation rule type", () => {
|
||||||
|
test("should handle unknown rule type gracefully", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const element: TSurveyElement = {
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
validation: {
|
||||||
|
rules: [{ id: "rule1", type: "unknown" as any, params: {} }],
|
||||||
|
},
|
||||||
|
} as unknown as TSurveyOpenTextElement;
|
||||||
|
|
||||||
|
const result = validateElementResponse(element, "test", "en", mockT);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateBlockResponses", () => {
|
||||||
|
test("should return empty error map when all elements are valid", () => {
|
||||||
|
const elements: TSurveyElement[] = [
|
||||||
|
{
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 1" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as TSurveyOpenTextElement,
|
||||||
|
{
|
||||||
|
id: "text2",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 2" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as TSurveyOpenTextElement,
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses: TResponseData = {
|
||||||
|
text1: "value1",
|
||||||
|
text2: "value2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateBlockResponses(elements, responses, "en", mockT);
|
||||||
|
expect(Object.keys(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error map with invalid elements", () => {
|
||||||
|
const elements: TSurveyElement[] = [
|
||||||
|
{
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 1" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as TSurveyOpenTextElement,
|
||||||
|
{
|
||||||
|
id: "text2",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question 2" },
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as TSurveyOpenTextElement,
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses: TResponseData = {
|
||||||
|
text1: "",
|
||||||
|
text2: "value2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateBlockResponses(elements, responses, "en", mockT);
|
||||||
|
expect(Object.keys(result)).toHaveLength(1);
|
||||||
|
const text1Errors = result.text1;
|
||||||
|
expect(text1Errors).toBeDefined();
|
||||||
|
expect(text1Errors?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle missing responses", () => {
|
||||||
|
const elements: TSurveyElement[] = [
|
||||||
|
{
|
||||||
|
id: "text1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: { default: "Question" },
|
||||||
|
required: true,
|
||||||
|
inputType: "text",
|
||||||
|
charLimit: 0,
|
||||||
|
} as TSurveyOpenTextElement,
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses: TResponseData = {};
|
||||||
|
|
||||||
|
const result = validateBlockResponses(elements, responses, "en", mockT);
|
||||||
|
expect(Object.keys(result)).toHaveLength(1);
|
||||||
|
expect(result.text1).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFirstErrorMessage", () => {
|
||||||
|
test("should return first error message for element", () => {
|
||||||
|
const errorMap = {
|
||||||
|
text1: [
|
||||||
|
{ ruleId: "rule1", ruleType: "minLength" as const, message: "First error" },
|
||||||
|
{ ruleId: "rule2", ruleType: "maxLength" as const, message: "Second error" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = getFirstErrorMessage(errorMap, "text1");
|
||||||
|
expect(message).toBe("First error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined when element has no errors", () => {
|
||||||
|
const errorMap = {
|
||||||
|
text1: [{ ruleId: "rule1", ruleType: "minLength" as const, message: "Error" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = getFirstErrorMessage(errorMap, "text2");
|
||||||
|
expect(message).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined when error map is empty", () => {
|
||||||
|
const errorMap = {};
|
||||||
|
const message = getFirstErrorMessage(errorMap, "text1");
|
||||||
|
expect(message).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import type { TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||||
|
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
|
import type {
|
||||||
|
TAddressField,
|
||||||
|
TContactInfoField,
|
||||||
|
TValidationError,
|
||||||
|
TValidationErrorMap,
|
||||||
|
TValidationResult,
|
||||||
|
TValidationRule,
|
||||||
|
} from "@formbricks/types/surveys/validation-rules";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
|
import { validators } from "./validators";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value is empty
|
||||||
|
*/
|
||||||
|
const isEmpty = (value: TResponseDataValue): boolean => {
|
||||||
|
return (
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === "" ||
|
||||||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|
(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a required field error
|
||||||
|
*/
|
||||||
|
const createRequiredError = (t: TFunction): TValidationError => {
|
||||||
|
return {
|
||||||
|
ruleId: "required",
|
||||||
|
ruleType: "minLength", // Structural field only - required is not a validation rule
|
||||||
|
message: t("errors.please_fill_out_this_field"),
|
||||||
|
} as TValidationError;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field label for address/contact info elements
|
||||||
|
*/
|
||||||
|
const getFieldLabel = (
|
||||||
|
element: TSurveyElement,
|
||||||
|
field: TAddressField | TContactInfoField | undefined,
|
||||||
|
languageCode: string
|
||||||
|
): string | undefined => {
|
||||||
|
if (!field) return undefined;
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.Address && "addressLine1" in element) {
|
||||||
|
const fieldConfig = element[field as TAddressField];
|
||||||
|
if (fieldConfig && "placeholder" in fieldConfig) {
|
||||||
|
return getLocalizedValue(fieldConfig.placeholder, languageCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.ContactInfo && "firstName" in element) {
|
||||||
|
const fieldConfig = element[field as TContactInfoField];
|
||||||
|
if (fieldConfig && "placeholder" in fieldConfig) {
|
||||||
|
return getLocalizedValue(fieldConfig.placeholder, languageCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default error message from rule or validator
|
||||||
|
*/
|
||||||
|
const getDefaultErrorMessage = (
|
||||||
|
rule: TValidationRule,
|
||||||
|
element: TSurveyElement,
|
||||||
|
languageCode: string,
|
||||||
|
t: TFunction
|
||||||
|
): string => {
|
||||||
|
const validator = validators[rule.type];
|
||||||
|
if (!validator) {
|
||||||
|
return t("errors.invalid_format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMessage =
|
||||||
|
rule.customErrorMessage?.[languageCode] ??
|
||||||
|
rule.customErrorMessage?.default ??
|
||||||
|
validator.getDefaultMessage(rule.params, element, t);
|
||||||
|
|
||||||
|
// For field-specific validation, prepend the field name
|
||||||
|
if (rule.field) {
|
||||||
|
const fieldLabel = getFieldLabel(element, rule.field, languageCode);
|
||||||
|
if (fieldLabel) {
|
||||||
|
return `${fieldLabel}: ${baseMessage}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate required field for ranking elements
|
||||||
|
*/
|
||||||
|
const validateRequiredRanking = (value: TResponseDataValue, t: TFunction): TValidationError | null => {
|
||||||
|
const isValueArray = Array.isArray(value);
|
||||||
|
const atLeastOneRanked = isValueArray && value.length >= 1;
|
||||||
|
if (isEmpty(value) || !atLeastOneRanked) {
|
||||||
|
return createRequiredError(t);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate required field for matrix elements
|
||||||
|
*/
|
||||||
|
const validateRequiredMatrix = (
|
||||||
|
value: TResponseDataValue,
|
||||||
|
element: TSurveyElement,
|
||||||
|
t: TFunction
|
||||||
|
): TValidationError | null => {
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
return createRequiredError(t);
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && !Array.isArray(value) && value !== null && "rows" in element) {
|
||||||
|
const answeredRows = Object.values(value).filter((v) => v !== "" && v !== null && v !== undefined).length;
|
||||||
|
const allRowsAnswered = answeredRows === element.rows.length;
|
||||||
|
if (!allRowsAnswered) {
|
||||||
|
return createRequiredError(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check required field validation
|
||||||
|
*/
|
||||||
|
const checkRequiredField = (
|
||||||
|
element: TSurveyElement,
|
||||||
|
value: TResponseDataValue,
|
||||||
|
t: TFunction
|
||||||
|
): TValidationError | null => {
|
||||||
|
if (!element.required) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.Ranking) {
|
||||||
|
return validateRequiredRanking(value, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.Matrix) {
|
||||||
|
return validateRequiredMatrix(value, element, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
return createRequiredError(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add implicit validation rules for OpenText elements based on inputType
|
||||||
|
*/
|
||||||
|
const addImplicitOpenTextRules = (element: TSurveyElement, rules: TValidationRule[]): TValidationRule[] => {
|
||||||
|
if (element.type !== TSurveyElementTypeEnum.OpenText || !("inputType" in element)) {
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputType = element.inputType;
|
||||||
|
const hasRule = (type: string) => rules.some((r) => r.type === type);
|
||||||
|
|
||||||
|
if (inputType === "email" && !hasRule("email")) {
|
||||||
|
rules.push({
|
||||||
|
id: "__implicit_email__",
|
||||||
|
type: "email",
|
||||||
|
params: {},
|
||||||
|
} as TValidationRule);
|
||||||
|
} else if (inputType === "url" && !hasRule("url")) {
|
||||||
|
rules.push({
|
||||||
|
id: "__implicit_url__",
|
||||||
|
type: "url",
|
||||||
|
params: {},
|
||||||
|
} as TValidationRule);
|
||||||
|
} else if (inputType === "phone" && !hasRule("phone")) {
|
||||||
|
rules.push({
|
||||||
|
id: "__implicit_phone__",
|
||||||
|
type: "phone",
|
||||||
|
params: {},
|
||||||
|
} as TValidationRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add implicit validation rules for ContactInfo elements
|
||||||
|
*/
|
||||||
|
const addImplicitContactInfoRules = (
|
||||||
|
element: TSurveyElement,
|
||||||
|
rules: TValidationRule[]
|
||||||
|
): TValidationRule[] => {
|
||||||
|
if (element.type !== TSurveyElementTypeEnum.ContactInfo) {
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactInfoElement = element;
|
||||||
|
const hasFieldRule = (type: string, field: string) =>
|
||||||
|
rules.some((r) => r.type === type && r.field === field);
|
||||||
|
|
||||||
|
if (contactInfoElement.email?.show && !hasFieldRule("email", "email")) {
|
||||||
|
rules.push({
|
||||||
|
id: "__implicit_email_field__",
|
||||||
|
type: "email",
|
||||||
|
field: "email",
|
||||||
|
params: {},
|
||||||
|
} as TValidationRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactInfoElement.phone?.show && !hasFieldRule("phone", "phone")) {
|
||||||
|
rules.push({
|
||||||
|
id: "__implicit_phone_field__",
|
||||||
|
type: "phone",
|
||||||
|
field: "phone",
|
||||||
|
params: {},
|
||||||
|
} as TValidationRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field value for address/contact info elements
|
||||||
|
*/
|
||||||
|
const getFieldValue = (
|
||||||
|
rule: TValidationRule,
|
||||||
|
element: TSurveyElement,
|
||||||
|
elementValue: TResponseDataValue
|
||||||
|
): TResponseDataValue => {
|
||||||
|
if (!rule.field) {
|
||||||
|
return elementValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.Address && Array.isArray(elementValue)) {
|
||||||
|
const addressFieldOrder: TAddressField[] = [
|
||||||
|
"addressLine1",
|
||||||
|
"addressLine2",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip",
|
||||||
|
"country",
|
||||||
|
];
|
||||||
|
const fieldIndex = addressFieldOrder.indexOf(rule.field as TAddressField);
|
||||||
|
if (fieldIndex >= 0 && fieldIndex < elementValue.length) {
|
||||||
|
return elementValue[fieldIndex] ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type === TSurveyElementTypeEnum.ContactInfo && Array.isArray(elementValue)) {
|
||||||
|
const contactFieldOrder: TContactInfoField[] = ["firstName", "lastName", "email", "phone", "company"];
|
||||||
|
const fieldIndex = contactFieldOrder.indexOf(rule.field as TContactInfoField);
|
||||||
|
if (fieldIndex >= 0 && fieldIndex < elementValue.length) {
|
||||||
|
return elementValue[fieldIndex] ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute validation rules with OR logic
|
||||||
|
*/
|
||||||
|
const executeOrLogic = (
|
||||||
|
rules: TValidationRule[],
|
||||||
|
element: TSurveyElement,
|
||||||
|
value: TResponseDataValue,
|
||||||
|
languageCode: string,
|
||||||
|
t: TFunction,
|
||||||
|
initialErrors: TValidationError[]
|
||||||
|
): TValidationResult => {
|
||||||
|
const ruleResults: TValidationError[] = [];
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const validator = validators[rule.type];
|
||||||
|
if (!validator) {
|
||||||
|
console.warn(`Unknown validation rule type: ${rule.type}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueToValidate = getFieldValue(rule, element, value);
|
||||||
|
const checkResult = validator.check(valueToValidate, rule.params, element);
|
||||||
|
|
||||||
|
if (checkResult.valid) {
|
||||||
|
return { valid: initialErrors.length === 0, errors: initialErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = getDefaultErrorMessage(rule, element, languageCode, t);
|
||||||
|
ruleResults.push({
|
||||||
|
ruleId: rule.id,
|
||||||
|
ruleType: rule.type,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, errors: [...initialErrors, ...ruleResults] };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute validation rules with AND logic
|
||||||
|
*/
|
||||||
|
const executeAndLogic = (
|
||||||
|
rules: TValidationRule[],
|
||||||
|
element: TSurveyElement,
|
||||||
|
value: TResponseDataValue,
|
||||||
|
languageCode: string,
|
||||||
|
t: TFunction,
|
||||||
|
initialErrors: TValidationError[]
|
||||||
|
): TValidationResult => {
|
||||||
|
const errors = [...initialErrors];
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const validator = validators[rule.type];
|
||||||
|
if (!validator) {
|
||||||
|
console.warn(`Unknown validation rule type: ${rule.type}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueToValidate = getFieldValue(rule, element, value);
|
||||||
|
const checkResult = validator.check(valueToValidate, rule.params, element);
|
||||||
|
|
||||||
|
if (!checkResult.valid) {
|
||||||
|
const message = getDefaultErrorMessage(rule, element, languageCode, t);
|
||||||
|
errors.push({
|
||||||
|
ruleId: rule.id,
|
||||||
|
ruleType: rule.type,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single entrypoint for validating an element's response value.
|
||||||
|
* Called by block-conditional.tsx during form submission.
|
||||||
|
*
|
||||||
|
* @param element - The survey element being validated
|
||||||
|
* @param value - The response value for this element
|
||||||
|
* @param languageCode - Current language code for error messages
|
||||||
|
* @param t - i18next translation function
|
||||||
|
* @returns Validation result with valid flag and array of errors
|
||||||
|
*/
|
||||||
|
export const validateElementResponse = (
|
||||||
|
element: TSurveyElement,
|
||||||
|
value: TResponseDataValue,
|
||||||
|
languageCode: string,
|
||||||
|
t: TFunction
|
||||||
|
): TValidationResult => {
|
||||||
|
const errors: TValidationError[] = [];
|
||||||
|
|
||||||
|
// Check if element is required (separate from validation rules)
|
||||||
|
const requiredError = checkRequiredField(element, value, t);
|
||||||
|
if (requiredError) {
|
||||||
|
errors.push(requiredError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For matrix elements, skip validation rules if element is required
|
||||||
|
if (element.type === TSurveyElementTypeEnum.Matrix && element.required) {
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get validation rules
|
||||||
|
const validation = (
|
||||||
|
element as TSurveyElement & { validation?: { rules?: TValidationRule[]; logic?: "and" | "or" } }
|
||||||
|
).validation;
|
||||||
|
let rules: TValidationRule[] = [...(validation?.rules ?? [])];
|
||||||
|
|
||||||
|
// Add implicit rules based on element type
|
||||||
|
rules = addImplicitOpenTextRules(element, rules);
|
||||||
|
rules = addImplicitContactInfoRules(element, rules);
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationLogic = validation?.logic ?? "and";
|
||||||
|
|
||||||
|
if (validationLogic === "or") {
|
||||||
|
return executeOrLogic(rules, element, value, languageCode, t, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeAndLogic(rules, element, value, languageCode, t, errors);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate all elements in a block, returning an error map.
|
||||||
|
*
|
||||||
|
* @param elements - Array of elements to validate
|
||||||
|
* @param responses - Response data keyed by element ID
|
||||||
|
* @param languageCode - Current language code for error messages
|
||||||
|
* @param t - i18next translation function
|
||||||
|
* @returns Map of element IDs to their validation errors
|
||||||
|
*/
|
||||||
|
export const validateBlockResponses = (
|
||||||
|
elements: TSurveyElement[],
|
||||||
|
responses: TResponseData,
|
||||||
|
languageCode: string,
|
||||||
|
t: TFunction
|
||||||
|
): TValidationErrorMap => {
|
||||||
|
const errorMap: TValidationErrorMap = {};
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
const result = validateElementResponse(element, responses[element.id], languageCode, t);
|
||||||
|
if (!result.valid) {
|
||||||
|
errorMap[element.id] = result.errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first error message for an element from the error map.
|
||||||
|
* Useful for UI components that only display one error at a time.
|
||||||
|
*
|
||||||
|
* @param errorMap - The validation error map
|
||||||
|
* @param elementId - The element ID to get error for
|
||||||
|
* @returns The first error message or undefined
|
||||||
|
*/
|
||||||
|
export const getFirstErrorMessage = (
|
||||||
|
errorMap: TValidationErrorMap,
|
||||||
|
elementId: string
|
||||||
|
): string | undefined => {
|
||||||
|
const errors = errorMap[elementId];
|
||||||
|
return errors?.[0]?.message;
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { validateElementResponse, validateBlockResponses, getFirstErrorMessage } from "./evaluator";
|
||||||
|
export { validators } from "./validators";
|
||||||
|
export type { TValidator, TValidatorCheckResult } from "./validators";
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
|
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import { validators } from "./validators";
|
||||||
|
|
||||||
|
// Mock translation function - just return the key for testing
|
||||||
|
const mockT = vi.fn((key: string) => {
|
||||||
|
return key;
|
||||||
|
}) as unknown as TFunction;
|
||||||
|
|
||||||
|
describe("validators", () => {
|
||||||
|
describe("minLength", () => {
|
||||||
|
test("should return valid true when string length >= min", () => {
|
||||||
|
const result = validators.minLength.check("hello", { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when string length < min", () => {
|
||||||
|
const result = validators.minLength.check("hi", { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty string", () => {
|
||||||
|
const result = validators.minLength.check("", { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is not a string", () => {
|
||||||
|
const result = validators.minLength.check(123, { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.minLength.getDefaultMessage({ min: 10 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.min_length");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("maxLength", () => {
|
||||||
|
test("should return valid true when string length <= max", () => {
|
||||||
|
const result = validators.maxLength.check("hello", { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when string length > max", () => {
|
||||||
|
const result = validators.maxLength.check("hello world", { max: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is not a string", () => {
|
||||||
|
const result = validators.maxLength.check(123, { max: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.maxLength.getDefaultMessage({ max: 100 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.max_length");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pattern", () => {
|
||||||
|
test("should return valid true when pattern matches", () => {
|
||||||
|
const result = validators.pattern.check("Hello", { pattern: "^[A-Z]" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when pattern does not match", () => {
|
||||||
|
const result = validators.pattern.check("hello", { pattern: "^[A-Z]" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.pattern.check("", { pattern: "^[A-Z]" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle regex flags", () => {
|
||||||
|
const result = validators.pattern.check(
|
||||||
|
"hello",
|
||||||
|
{ pattern: "^[A-Z]", flags: "i" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject patterns longer than 512 chars", () => {
|
||||||
|
const longPattern = "a".repeat(513);
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const result = validators.pattern.check("test", { pattern: longPattern }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject values longer than 4096 chars", () => {
|
||||||
|
const longValue = "a".repeat(4097);
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const result = validators.pattern.check(longValue, { pattern: ".*" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle invalid regex gracefully", () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const result = validators.pattern.check("test", { pattern: "[invalid" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true); // Returns valid for invalid regex
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.pattern.getDefaultMessage({ pattern: ".*" }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.invalid_format");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("email", () => {
|
||||||
|
test("should return valid true for valid email", () => {
|
||||||
|
const result = validators.email.check("test@example.com", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false for invalid email", () => {
|
||||||
|
const result = validators.email.check("invalid-email", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.email.check("", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is not a string", () => {
|
||||||
|
const result = validators.email.check(123, {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.email.getDefaultMessage({}, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.please_enter_a_valid_email_address");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("url", () => {
|
||||||
|
test("should return valid true for valid URL", () => {
|
||||||
|
const result = validators.url.check("https://example.com", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false for invalid URL", () => {
|
||||||
|
const result = validators.url.check("not-a-url", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.url.check("", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.url.getDefaultMessage({}, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.please_enter_a_valid_url");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phone", () => {
|
||||||
|
test("should return valid true for valid phone number", () => {
|
||||||
|
const result = validators.phone.check("+1234567890", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true for phone with spaces and dashes", () => {
|
||||||
|
const result = validators.phone.check("+1 234-567-890", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false for invalid phone", () => {
|
||||||
|
const result = validators.phone.check("abc123", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.phone.check("", {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.phone.getDefaultMessage({}, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.please_enter_a_valid_phone_number");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minValue", () => {
|
||||||
|
test("should return valid true when value >= min", () => {
|
||||||
|
const result = validators.minValue.check(10, { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value < min", () => {
|
||||||
|
const result = validators.minValue.check(3, { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle string numbers", () => {
|
||||||
|
const result = validators.minValue.check("10", { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.minValue.check("", { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true for non-numeric values", () => {
|
||||||
|
const result = validators.minValue.check("abc", { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.minValue.getDefaultMessage({ min: 10 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.min_value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("maxValue", () => {
|
||||||
|
test("should return valid true when value <= max", () => {
|
||||||
|
const result = validators.maxValue.check(5, { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value > max", () => {
|
||||||
|
const result = validators.maxValue.check(15, { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle string numbers", () => {
|
||||||
|
const result = validators.maxValue.check("5", { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.maxValue.check("", { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.maxValue.getDefaultMessage({ max: 100 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.max_value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minSelections", () => {
|
||||||
|
test("should return valid true when selection count >= min", () => {
|
||||||
|
const result = validators.minSelections.check(["opt1", "opt2"], { min: 2 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when selection count < min", () => {
|
||||||
|
const result = validators.minSelections.check(["opt1"], { min: 2 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value is not an array", () => {
|
||||||
|
const result = validators.minSelections.check("not-array", { min: 2 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle 'other' option correctly", () => {
|
||||||
|
const result = validators.minSelections.check(["opt1", "", "custom"], { min: 2 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.minSelections.getDefaultMessage({ min: 2 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.min_selections");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("maxSelections", () => {
|
||||||
|
test("should return valid true when selection count <= max", () => {
|
||||||
|
const result = validators.maxSelections.check(["opt1", "opt2"], { max: 3 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when selection count > max", () => {
|
||||||
|
const result = validators.maxSelections.check(
|
||||||
|
["opt1", "opt2", "opt3", "opt4"],
|
||||||
|
{ max: 3 },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is not an array", () => {
|
||||||
|
const result = validators.maxSelections.check("not-array", { max: 3 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.maxSelections.getDefaultMessage({ max: 5 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.max_selections");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equals", () => {
|
||||||
|
test("should return valid true when value equals", () => {
|
||||||
|
const result = validators.equals.check("test", { value: "test" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value does not equal", () => {
|
||||||
|
const result = validators.equals.check("test", { value: "other" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.equals.check("", { value: "test" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.equals.getDefaultMessage({ value: "test" }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.value_must_equal");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("doesNotEqual", () => {
|
||||||
|
test("should return valid true when value does not equal", () => {
|
||||||
|
const result = validators.doesNotEqual.check("test", { value: "other" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value equals", () => {
|
||||||
|
const result = validators.doesNotEqual.check("test", { value: "test" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.doesNotEqual.check("", { value: "test" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.doesNotEqual.getDefaultMessage(
|
||||||
|
{ value: "test" },
|
||||||
|
{} as TSurveyElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.value_must_not_equal");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contains", () => {
|
||||||
|
test("should return valid true when value contains substring", () => {
|
||||||
|
const result = validators.contains.check("hello world", { value: "world" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value does not contain substring", () => {
|
||||||
|
const result = validators.contains.check("hello", { value: "world" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.contains.check("", { value: "test" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.contains.getDefaultMessage({ value: "test" }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.value_must_contain");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("doesNotContain", () => {
|
||||||
|
test("should return valid true when value does not contain substring", () => {
|
||||||
|
const result = validators.doesNotContain.check("hello", { value: "world" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value contains substring", () => {
|
||||||
|
const result = validators.doesNotContain.check("hello world", { value: "world" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.doesNotContain.check("", { value: "test" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.doesNotContain.getDefaultMessage(
|
||||||
|
{ value: "test" },
|
||||||
|
{} as TSurveyElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.value_must_not_contain");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isGreaterThan", () => {
|
||||||
|
test("should return valid true when value > min", () => {
|
||||||
|
const result = validators.isGreaterThan.check(10, { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value <= min", () => {
|
||||||
|
const result = validators.isGreaterThan.check(5, { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.isGreaterThan.check("", { min: 5 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.isGreaterThan.getDefaultMessage({ min: 10 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.is_greater_than");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLessThan", () => {
|
||||||
|
test("should return valid true when value < max", () => {
|
||||||
|
const result = validators.isLessThan.check(5, { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when value >= max", () => {
|
||||||
|
const result = validators.isLessThan.check(10, { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.isLessThan.check("", { max: 10 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.isLessThan.getDefaultMessage({ max: 100 }, {} as TSurveyElement, mockT);
|
||||||
|
expect(message).toBe("errors.is_less_than");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLaterThan", () => {
|
||||||
|
test("should return valid true when date is later", () => {
|
||||||
|
const result = validators.isLaterThan.check("2024-12-31", { date: "2024-01-01" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when date is not later", () => {
|
||||||
|
const result = validators.isLaterThan.check("2024-01-01", { date: "2024-12-31" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.isLaterThan.check("", { date: "2024-01-01" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.isLaterThan.getDefaultMessage(
|
||||||
|
{ date: "2024-01-01" },
|
||||||
|
{} as TSurveyElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.is_later_than");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isEarlierThan", () => {
|
||||||
|
test("should return valid true when date is earlier", () => {
|
||||||
|
const result = validators.isEarlierThan.check(
|
||||||
|
"2024-01-01",
|
||||||
|
{ date: "2024-12-31" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when date is not earlier", () => {
|
||||||
|
const result = validators.isEarlierThan.check(
|
||||||
|
"2024-12-31",
|
||||||
|
{ date: "2024-01-01" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.isEarlierThan.check("", { date: "2024-01-01" }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.isEarlierThan.getDefaultMessage(
|
||||||
|
{ date: "2024-01-01" },
|
||||||
|
{} as TSurveyElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.is_earlier_than");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isBetween", () => {
|
||||||
|
test("should return valid true when date is between", () => {
|
||||||
|
const result = validators.isBetween.check(
|
||||||
|
"2024-06-15",
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when date is not between", () => {
|
||||||
|
const result = validators.isBetween.check(
|
||||||
|
"2025-01-01",
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.isBetween.check(
|
||||||
|
"",
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.isBetween.getDefaultMessage(
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.is_between");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isNotBetween", () => {
|
||||||
|
test("should return valid true when date is not between", () => {
|
||||||
|
const result = validators.isNotBetween.check(
|
||||||
|
"2025-01-01",
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when date is between", () => {
|
||||||
|
const result = validators.isNotBetween.check(
|
||||||
|
"2024-06-15",
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.isNotBetween.check(
|
||||||
|
"",
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.isNotBetween.getDefaultMessage(
|
||||||
|
{ startDate: "2024-01-01", endDate: "2024-12-31" },
|
||||||
|
{} as TSurveyElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.is_not_between");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minRanked", () => {
|
||||||
|
const rankingElement: TSurveyElement = {
|
||||||
|
id: "rank1",
|
||||||
|
type: TSurveyElementTypeEnum.Ranking,
|
||||||
|
headline: { default: "Rank these" },
|
||||||
|
required: false,
|
||||||
|
choices: [
|
||||||
|
{ id: "opt1", label: { default: "Option 1" } },
|
||||||
|
{ id: "opt2", label: { default: "Option 2" } },
|
||||||
|
{ id: "opt3", label: { default: "Option 3" } },
|
||||||
|
],
|
||||||
|
} as TSurveyElement;
|
||||||
|
|
||||||
|
test("should return valid true when ranked count >= min", () => {
|
||||||
|
const result = validators.minRanked.check(["opt1", "opt2"], { min: 2 }, rankingElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when ranked count < min", () => {
|
||||||
|
const result = validators.minRanked.check(["opt1"], { min: 2 }, rankingElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.minRanked.check([], { min: 2 }, rankingElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when element is not ranking", () => {
|
||||||
|
const result = validators.minRanked.check(["opt1"], { min: 2 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.minRanked.getDefaultMessage({ min: 2 }, rankingElement, mockT);
|
||||||
|
expect(message).toBe("errors.minimum_options_ranked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rankAll", () => {
|
||||||
|
const rankingElement: TSurveyElement = {
|
||||||
|
id: "rank1",
|
||||||
|
type: TSurveyElementTypeEnum.Ranking,
|
||||||
|
headline: { default: "Rank these" },
|
||||||
|
required: false,
|
||||||
|
choices: [
|
||||||
|
{ id: "opt1", label: { default: "Option 1" } },
|
||||||
|
{ id: "opt2", label: { default: "Option 2" } },
|
||||||
|
{ id: "opt3", label: { default: "Option 3" } },
|
||||||
|
],
|
||||||
|
} as TSurveyElement;
|
||||||
|
|
||||||
|
test("should return valid true when all options are ranked", () => {
|
||||||
|
const result = validators.rankAll.check(["opt1", "opt2", "opt3"], {}, rankingElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when not all options are ranked", () => {
|
||||||
|
const result = validators.rankAll.check(["opt1", "opt2"], {}, rankingElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.rankAll.check([], {}, rankingElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when element is not ranking", () => {
|
||||||
|
const result = validators.rankAll.check(["opt1"], {}, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.rankAll.getDefaultMessage({}, rankingElement, mockT);
|
||||||
|
expect(message).toBe("errors.all_options_must_be_ranked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minRowsAnswered", () => {
|
||||||
|
const matrixElement: TSurveyElement = {
|
||||||
|
id: "matrix1",
|
||||||
|
type: TSurveyElementTypeEnum.Matrix,
|
||||||
|
headline: { default: "Matrix question" },
|
||||||
|
required: false,
|
||||||
|
shuffleOption: "none",
|
||||||
|
rows: [
|
||||||
|
{ id: "row1", label: { default: "Row 1" } },
|
||||||
|
{ id: "row2", label: { default: "Row 2" } },
|
||||||
|
{ id: "row3", label: { default: "Row 3" } },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ id: "col1", label: { default: "Col 1" } },
|
||||||
|
{ id: "col2", label: { default: "Col 2" } },
|
||||||
|
],
|
||||||
|
} as TSurveyElement;
|
||||||
|
|
||||||
|
test("should return valid true when answered rows >= min", () => {
|
||||||
|
const result = validators.minRowsAnswered.check(
|
||||||
|
{ row1: "col1", row2: "col2" },
|
||||||
|
{ min: 2 },
|
||||||
|
matrixElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when answered rows < min", () => {
|
||||||
|
const result = validators.minRowsAnswered.check({ row1: "col1" }, { min: 2 }, matrixElement);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
// Empty object has 0 answered rows, which is less than min (2), so it should fail
|
||||||
|
// But if we pass undefined, it should skip validation
|
||||||
|
const result = validators.minRowsAnswered.check(undefined, { min: 2 }, matrixElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when element is not matrix", () => {
|
||||||
|
const result = validators.minRowsAnswered.check({ row1: "col1" }, { min: 2 }, {} as TSurveyElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter out empty values", () => {
|
||||||
|
const result = validators.minRowsAnswered.check({ row1: "col1", row2: "" }, { min: 1 }, matrixElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.minRowsAnswered.getDefaultMessage({ min: 2 }, matrixElement, mockT);
|
||||||
|
expect(message).toBe("errors.minimum_rows_answered");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fileExtensionIs", () => {
|
||||||
|
const fileUploadElement: TSurveyElement = {
|
||||||
|
id: "file1",
|
||||||
|
type: TSurveyElementTypeEnum.FileUpload,
|
||||||
|
headline: { default: "Upload file" },
|
||||||
|
required: false,
|
||||||
|
allowMultipleFiles: false,
|
||||||
|
} as TSurveyElement;
|
||||||
|
|
||||||
|
test("should return valid true when file extension matches", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file.pdf"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when file extension matches with dot", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file.pdf"],
|
||||||
|
{ extensions: [".pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when file extension does not match", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file.pdf"],
|
||||||
|
{ extensions: ["jpg"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple files", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file1.pdf", "https://example.com/file2.pdf"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false if any file does not match", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file1.pdf", "https://example.com/file2.jpg"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle URLs with query parameters", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file.pdf?token=123"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false for files without extension", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.fileExtensionIs.check([], { extensions: ["pdf"] }, fileUploadElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when element is not fileUpload", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file.pdf"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
{} as TSurveyElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle case-insensitive extensions", () => {
|
||||||
|
const result = validators.fileExtensionIs.check(
|
||||||
|
["https://example.com/file.PDF"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.fileExtensionIs.getDefaultMessage(
|
||||||
|
{ extensions: ["pdf", "jpg"] },
|
||||||
|
fileUploadElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.file_extension_must_be");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fileExtensionIsNot", () => {
|
||||||
|
const fileUploadElement: TSurveyElement = {
|
||||||
|
id: "file1",
|
||||||
|
type: TSurveyElementTypeEnum.FileUpload,
|
||||||
|
headline: { default: "Upload file" },
|
||||||
|
required: false,
|
||||||
|
allowMultipleFiles: false,
|
||||||
|
} as TSurveyElement;
|
||||||
|
|
||||||
|
test("should return valid true when file extension does not match", () => {
|
||||||
|
const result = validators.fileExtensionIsNot.check(
|
||||||
|
["https://example.com/file.pdf"],
|
||||||
|
{ extensions: ["jpg"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false when file extension matches", () => {
|
||||||
|
const result = validators.fileExtensionIsNot.check(
|
||||||
|
["https://example.com/file.pdf"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true for files without extension", () => {
|
||||||
|
const result = validators.fileExtensionIsNot.check(
|
||||||
|
["https://example.com/file"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple files", () => {
|
||||||
|
const result = validators.fileExtensionIsNot.check(
|
||||||
|
["https://example.com/file1.jpg", "https://example.com/file2.png"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid false if any file matches forbidden extension", () => {
|
||||||
|
const result = validators.fileExtensionIsNot.check(
|
||||||
|
["https://example.com/file1.pdf", "https://example.com/file2.jpg"],
|
||||||
|
{ extensions: ["pdf"] },
|
||||||
|
fileUploadElement
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return valid true when value is empty", () => {
|
||||||
|
const result = validators.fileExtensionIsNot.check([], { extensions: ["pdf"] }, fileUploadElement);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct error message", () => {
|
||||||
|
const message = validators.fileExtensionIsNot.getDefaultMessage(
|
||||||
|
{ extensions: ["exe", "bat"] },
|
||||||
|
fileUploadElement,
|
||||||
|
mockT
|
||||||
|
);
|
||||||
|
expect(message).toBe("errors.file_extension_must_not_be");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { ZEmail, ZUrl } from "@formbricks/types/common";
|
||||||
|
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||||
|
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import type {
|
||||||
|
TValidationRuleParams,
|
||||||
|
TValidationRuleParamsContains,
|
||||||
|
TValidationRuleParamsDoesNotContain,
|
||||||
|
TValidationRuleParamsDoesNotEqual,
|
||||||
|
TValidationRuleParamsEmail,
|
||||||
|
TValidationRuleParamsEquals,
|
||||||
|
TValidationRuleParamsFileExtensionIs,
|
||||||
|
TValidationRuleParamsFileExtensionIsNot,
|
||||||
|
TValidationRuleParamsIsBetween,
|
||||||
|
TValidationRuleParamsIsEarlierThan,
|
||||||
|
TValidationRuleParamsIsGreaterThan,
|
||||||
|
TValidationRuleParamsIsLaterThan,
|
||||||
|
TValidationRuleParamsIsLessThan,
|
||||||
|
TValidationRuleParamsIsNotBetween,
|
||||||
|
TValidationRuleParamsMaxLength,
|
||||||
|
TValidationRuleParamsMaxSelections,
|
||||||
|
TValidationRuleParamsMaxValue,
|
||||||
|
TValidationRuleParamsMinLength,
|
||||||
|
TValidationRuleParamsMinRanked,
|
||||||
|
TValidationRuleParamsMinRowsAnswered,
|
||||||
|
TValidationRuleParamsMinSelections,
|
||||||
|
TValidationRuleParamsMinValue,
|
||||||
|
TValidationRuleParamsPattern,
|
||||||
|
TValidationRuleParamsPhone,
|
||||||
|
TValidationRuleParamsUrl,
|
||||||
|
TValidationRuleType,
|
||||||
|
} from "@formbricks/types/surveys/validation-rules";
|
||||||
|
import { countSelections } from "./validators/selection-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a validator check
|
||||||
|
*/
|
||||||
|
export interface TValidatorCheckResult {
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic validator interface
|
||||||
|
* Uses type assertions internally to handle the discriminated union params
|
||||||
|
*/
|
||||||
|
export interface TValidator {
|
||||||
|
check: (
|
||||||
|
value: TResponseDataValue,
|
||||||
|
params: TValidationRuleParams,
|
||||||
|
element: TSurveyElement
|
||||||
|
) => TValidatorCheckResult;
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, element: TSurveyElement, t: TFunction) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone regex: may start with +, must end with digit
|
||||||
|
// Allows digits, -, and spaces in between (plus is only allowed as the first char)
|
||||||
|
const PHONE_REGEX = /^\+?\d[\d\- ]*\d$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value is empty
|
||||||
|
*/
|
||||||
|
const isEmpty = (value: TResponseDataValue): boolean => {
|
||||||
|
return (
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === "" ||
|
||||||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|
(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse numeric value from string or number
|
||||||
|
*/
|
||||||
|
const parseNumericValue = (value: TResponseDataValue): number | null => {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of all validators, keyed by rule type
|
||||||
|
*/
|
||||||
|
export const validators: Record<TValidationRuleType, TValidator> = {
|
||||||
|
minLength: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinLength;
|
||||||
|
// Skip validation if value is not a string or is empty
|
||||||
|
if (typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: value.length >= typedParams.min };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinLength;
|
||||||
|
return t("errors.min_length", { min: typedParams.min });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
maxLength: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMaxLength;
|
||||||
|
// Skip validation if value is not a string
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: value.length <= typedParams.max };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMaxLength;
|
||||||
|
return t("errors.max_length", { max: typedParams.max });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pattern: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsPattern;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReDoS protection: cap pattern length to prevent catastrophic backtracking
|
||||||
|
// Patterns longer than 512 chars can cause exponential time complexity
|
||||||
|
if (typedParams.pattern.length > 512) {
|
||||||
|
console.warn(`Pattern too long (${typedParams.pattern.length} chars), rejecting to prevent ReDoS`);
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReDoS protection: cap value length to prevent exponential backtracking
|
||||||
|
// Values longer than 4096 chars can cause main-thread lockup with malicious patterns
|
||||||
|
if (value.length > 4096) {
|
||||||
|
console.warn(`Value too long (${value.length} chars), rejecting to prevent ReDoS`);
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(typedParams.pattern, typedParams.flags);
|
||||||
|
return { valid: regex.test(value) };
|
||||||
|
} catch {
|
||||||
|
// If regex is invalid, consider it valid (design-time should catch this)
|
||||||
|
console.warn(`Invalid regex pattern: ${typedParams.pattern}`);
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getDefaultMessage: (_params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
return t("errors.invalid_format");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
email: {
|
||||||
|
check: (value: TResponseDataValue): TValidatorCheckResult => {
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: ZEmail.safeParse(value).success };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (
|
||||||
|
_params: TValidationRuleParamsEmail,
|
||||||
|
_element: TSurveyElement,
|
||||||
|
t: TFunction
|
||||||
|
): string => {
|
||||||
|
return t("errors.please_enter_a_valid_email_address");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
url: {
|
||||||
|
check: (value: TResponseDataValue): TValidatorCheckResult => {
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: ZUrl.safeParse(value).success };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (
|
||||||
|
_params: TValidationRuleParamsUrl,
|
||||||
|
_element: TSurveyElement,
|
||||||
|
t: TFunction
|
||||||
|
): string => {
|
||||||
|
return t("errors.please_enter_a_valid_url");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
phone: {
|
||||||
|
check: (value: TResponseDataValue): TValidatorCheckResult => {
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: PHONE_REGEX.test(value) };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (
|
||||||
|
_params: TValidationRuleParamsPhone,
|
||||||
|
_element: TSurveyElement,
|
||||||
|
t: TFunction
|
||||||
|
): string => {
|
||||||
|
return t("errors.please_enter_a_valid_phone_number");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
minValue: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinValue;
|
||||||
|
// Skip validation if value is empty (let required handle empty)
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numValue = parseNumericValue(value);
|
||||||
|
if (numValue === null) {
|
||||||
|
return { valid: true }; // Let pattern/type validation handle non-numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: numValue >= typedParams.min };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinValue;
|
||||||
|
return t("errors.min_value", { min: typedParams.min });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
maxValue: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMaxValue;
|
||||||
|
// Skip validation if value is empty (let required handle empty)
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numValue = parseNumericValue(value);
|
||||||
|
if (numValue === null) {
|
||||||
|
return { valid: true }; // Let pattern/type validation handle non-numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: numValue <= typedParams.max };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMaxValue;
|
||||||
|
return t("errors.max_value", { max: typedParams.max });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
minSelections: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinSelections;
|
||||||
|
// If value is not an array, check fails (need selections)
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionCount = countSelections(value);
|
||||||
|
return { valid: selectionCount >= typedParams.min };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinSelections;
|
||||||
|
return t("errors.min_selections", { min: typedParams.min });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
maxSelections: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMaxSelections;
|
||||||
|
// If value is not an array, rule doesn't apply (graceful)
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionCount = countSelections(value);
|
||||||
|
return { valid: selectionCount <= typedParams.max };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMaxSelections;
|
||||||
|
return t("errors.max_selections", { max: typedParams.max });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
equals: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsEquals;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: value === typedParams.value };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (_params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
return t("errors.value_must_equal", { value: (_params as TValidationRuleParamsEquals).value });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
doesNotEqual: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsDoesNotEqual;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: value !== typedParams.value };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (_params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
return t("errors.value_must_not_equal", {
|
||||||
|
value: (_params as TValidationRuleParamsDoesNotEqual).value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contains: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsContains;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: value.includes(typedParams.value) };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (_params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
return t("errors.value_must_contain", { value: (_params as TValidationRuleParamsContains).value });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
doesNotContain: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsDoesNotContain;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
return { valid: !value.includes(typedParams.value) };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (_params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
return t("errors.value_must_not_contain", {
|
||||||
|
value: (_params as TValidationRuleParamsDoesNotContain).value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isGreaterThan: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsGreaterThan;
|
||||||
|
// Skip validation if value is empty (let required handle empty)
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numValue = parseNumericValue(value);
|
||||||
|
if (numValue === null) {
|
||||||
|
return { valid: true }; // Let pattern/type validation handle non-numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: numValue > typedParams.min };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsGreaterThan;
|
||||||
|
return t("errors.is_greater_than", { min: typedParams.min });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLessThan: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsLessThan;
|
||||||
|
// Skip validation if value is empty (let required handle empty)
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numValue = parseNumericValue(value);
|
||||||
|
if (numValue === null) {
|
||||||
|
return { valid: true }; // Let pattern/type validation handle non-numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: numValue < typedParams.max };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsLessThan;
|
||||||
|
return t("errors.is_less_than", { max: typedParams.max });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLaterThan: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsLaterThan;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Compare dates as strings (YYYY-MM-DD format)
|
||||||
|
return { valid: value > typedParams.date };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsLaterThan;
|
||||||
|
return t("errors.is_later_than", { date: typedParams.date });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isEarlierThan: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsEarlierThan;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Compare dates as strings (YYYY-MM-DD format)
|
||||||
|
return { valid: value < typedParams.date };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsEarlierThan;
|
||||||
|
return t("errors.is_earlier_than", { date: typedParams.date });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isBetween: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsBetween;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Compare dates as strings (YYYY-MM-DD format)
|
||||||
|
return { valid: value > typedParams.startDate && value < typedParams.endDate };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsBetween;
|
||||||
|
return t("errors.is_between", { startDate: typedParams.startDate, endDate: typedParams.endDate });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isNotBetween: {
|
||||||
|
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsNotBetween;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "string" || value === "") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Compare dates as strings (YYYY-MM-DD format)
|
||||||
|
return { valid: value < typedParams.startDate || value > typedParams.endDate };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsIsNotBetween;
|
||||||
|
return t("errors.is_not_between", { startDate: typedParams.startDate, endDate: typedParams.endDate });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minRanked: {
|
||||||
|
check: (
|
||||||
|
value: TResponseDataValue,
|
||||||
|
params: TValidationRuleParams,
|
||||||
|
element: TSurveyElement
|
||||||
|
): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinRanked;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
if (element.type !== "ranking") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Count how many options have been ranked (array length)
|
||||||
|
const rankedCount = value.length;
|
||||||
|
return { valid: rankedCount >= typedParams.min };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinRanked;
|
||||||
|
return t("errors.minimum_options_ranked", { min: typedParams.min });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rankAll: {
|
||||||
|
check: (
|
||||||
|
value: TResponseDataValue,
|
||||||
|
_params: TValidationRuleParams,
|
||||||
|
element: TSurveyElement
|
||||||
|
): TValidatorCheckResult => {
|
||||||
|
if (element.type !== "ranking") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// All options must be ranked
|
||||||
|
const allItemsRanked = value.length === element.choices.length;
|
||||||
|
return { valid: allItemsRanked };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (_params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
return t("errors.all_options_must_be_ranked");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minRowsAnswered: {
|
||||||
|
check: (
|
||||||
|
value: TResponseDataValue,
|
||||||
|
params: TValidationRuleParams,
|
||||||
|
element: TSurveyElement
|
||||||
|
): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinRowsAnswered;
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value) || value === null) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
if (element.type !== "matrix") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Matrix responses are Record<string, string> where keys are row labels and values are column labels
|
||||||
|
// Count non-empty answers (rows that have been answered)
|
||||||
|
const answeredCount = Object.values(value).filter(
|
||||||
|
(v) => v !== "" && v !== null && v !== undefined
|
||||||
|
).length;
|
||||||
|
return { valid: answeredCount >= typedParams.min };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsMinRowsAnswered;
|
||||||
|
return t("errors.minimum_rows_answered", { min: typedParams.min });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fileExtensionIs: {
|
||||||
|
check: (
|
||||||
|
value: TResponseDataValue,
|
||||||
|
params: TValidationRuleParams,
|
||||||
|
element: TSurveyElement
|
||||||
|
): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsFileExtensionIs;
|
||||||
|
if (element.type !== "fileUpload") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Normalize expected extensions: ensure they start with a dot
|
||||||
|
const expectedExtensions = new Set(
|
||||||
|
typedParams.extensions.map((ext) =>
|
||||||
|
ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check all files in the array
|
||||||
|
for (const fileUrl of value) {
|
||||||
|
if (typeof fileUrl !== "string") continue;
|
||||||
|
// Extract filename from URL
|
||||||
|
const urlPath = fileUrl.split("?")[0]; // Remove query params
|
||||||
|
const fileName = urlPath.split("/").pop() || "";
|
||||||
|
if (!fileName.includes(".")) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
const fileExtension = `.${fileName.split(".").pop()?.toLowerCase() ?? ""}`;
|
||||||
|
// Check if file extension matches any of the expected extensions
|
||||||
|
if (!expectedExtensions.has(fileExtension)) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsFileExtensionIs;
|
||||||
|
const extensions = typedParams.extensions
|
||||||
|
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||||
|
.join(", ");
|
||||||
|
return t("errors.file_extension_must_be", { extension: extensions });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fileExtensionIsNot: {
|
||||||
|
check: (
|
||||||
|
value: TResponseDataValue,
|
||||||
|
params: TValidationRuleParams,
|
||||||
|
element: TSurveyElement
|
||||||
|
): TValidatorCheckResult => {
|
||||||
|
const typedParams = params as TValidationRuleParamsFileExtensionIsNot;
|
||||||
|
if (element.type !== "fileUpload") {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Skip validation if value is empty
|
||||||
|
if (!value || !Array.isArray(value) || value.length === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
// Normalize forbidden extensions: ensure they start with a dot
|
||||||
|
const forbiddenExtensions = new Set(
|
||||||
|
typedParams.extensions.map((ext) =>
|
||||||
|
ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check all files in the array
|
||||||
|
for (const fileUrl of value) {
|
||||||
|
if (typeof fileUrl !== "string") continue;
|
||||||
|
// Extract filename from URL
|
||||||
|
const urlPath = fileUrl.split("?")[0]; // Remove query params
|
||||||
|
const fileName = urlPath.split("/").pop() || "";
|
||||||
|
if (!fileName.includes(".")) {
|
||||||
|
continue; // Files without extensions are allowed
|
||||||
|
}
|
||||||
|
const fileExtension = `.${fileName.split(".").pop()?.toLowerCase() ?? ""}`;
|
||||||
|
// Check if file extension matches any of the forbidden extensions
|
||||||
|
if (forbiddenExtensions.has(fileExtension)) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
},
|
||||||
|
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
|
||||||
|
const typedParams = params as TValidationRuleParamsFileExtensionIsNot;
|
||||||
|
const extensions = typedParams.extensions
|
||||||
|
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||||
|
.join(", ");
|
||||||
|
return t("errors.file_extension_must_not_be", { extension: extensions });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Count the number of actual selections in a multi-select value array.
|
||||||
|
*
|
||||||
|
* The value array format for MultiSelect:
|
||||||
|
* - Regular options: ["Label1", "Label2"]
|
||||||
|
* - With "other" option: ["Label1", "", "custom other text"]
|
||||||
|
* - The "" sentinel indicates "other" is selected
|
||||||
|
* - The text following it is the custom value
|
||||||
|
*
|
||||||
|
* This function counts logical selections, not array length.
|
||||||
|
*/
|
||||||
|
export const countSelections = (value: unknown[]): number => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOtherSentinel = value.includes("");
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const item = value[i];
|
||||||
|
|
||||||
|
// Skip empty sentinel
|
||||||
|
if (item === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the value immediately after empty sentinel (it's the "other" custom text)
|
||||||
|
if (i > 0 && value[i - 1] === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 1 for "other" if it's selected (the sentinel + optional text count as 1 selection)
|
||||||
|
if (hasOtherSentinel) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import type { TResponseDataValue } from "@formbricks/types/responses";
|
||||||
|
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import type { TValidationRuleParams } from "@formbricks/types/surveys/validation-rules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a validator check
|
||||||
|
*/
|
||||||
|
export interface TValidatorCheckResult {
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic validator interface
|
||||||
|
* P = the specific params type for this validator
|
||||||
|
*/
|
||||||
|
export interface TValidator<P extends TValidationRuleParams = TValidationRuleParams> {
|
||||||
|
/**
|
||||||
|
* Check if the value passes validation
|
||||||
|
*/
|
||||||
|
check: (value: TResponseDataValue, params: P, element: TSurveyElement) => TValidatorCheckResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default error message for this rule
|
||||||
|
* Element is passed to allow element-specific error messages
|
||||||
|
*/
|
||||||
|
getDefaultMessage: (params: P, element: TSurveyElement, t: TFunction) => string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,33 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ALLOWED_FILE_EXTENSIONS: TAllowedFileExtension[] = [
|
||||||
|
"heic",
|
||||||
|
"png",
|
||||||
|
"jpeg",
|
||||||
|
"jpg",
|
||||||
|
"webp",
|
||||||
|
"ico",
|
||||||
|
"pdf",
|
||||||
|
"eml",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"txt",
|
||||||
|
"csv",
|
||||||
|
"mp4",
|
||||||
|
"mov",
|
||||||
|
"avi",
|
||||||
|
"mkv",
|
||||||
|
"webm",
|
||||||
|
"zip",
|
||||||
|
"rar",
|
||||||
|
"7z",
|
||||||
|
"tar",
|
||||||
|
]
|
||||||
|
|
||||||
export const ZAllowedFileExtension = z.enum([
|
export const ZAllowedFileExtension = z.enum([
|
||||||
"heic",
|
"heic",
|
||||||
"png",
|
"png",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { ZValidationRules } from "./validation-rules";
|
||||||
|
|
||||||
// Element Type Enum (same as question types)
|
// Element Type Enum (same as question types)
|
||||||
export enum TSurveyElementTypeEnum {
|
export enum TSurveyElementTypeEnum {
|
||||||
@@ -49,7 +50,19 @@ export const ZSurveyElementId = z.string().superRefine((id, ctx) => {
|
|||||||
|
|
||||||
export type TSurveyElementId = z.infer<typeof ZSurveyElementId>;
|
export type TSurveyElementId = z.infer<typeof ZSurveyElementId>;
|
||||||
|
|
||||||
|
// Validation logic operator - determines how multiple validation rules are combined
|
||||||
|
export const ZValidationLogic = z.enum(["and", "or"]);
|
||||||
|
export type TValidationLogic = z.infer<typeof ZValidationLogic>;
|
||||||
|
|
||||||
|
// Combined validation object that includes both rules and logic
|
||||||
|
// Uses general TValidationRule[] type instead of element-specific narrowed types
|
||||||
|
export const ZValidation = z.object({
|
||||||
|
rules: ZValidationRules,
|
||||||
|
logic: ZValidationLogic.default("and"),
|
||||||
|
});
|
||||||
|
|
||||||
// Base element (like ZSurveyQuestionBase but WITHOUT logic, buttonLabel, backButtonLabel)
|
// Base element (like ZSurveyQuestionBase but WITHOUT logic, buttonLabel, backButtonLabel)
|
||||||
|
// Note: validation 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 +93,7 @@ export const ZSurveyOpenTextElement = ZSurveyElementBase.extend({
|
|||||||
max: z.number().optional(),
|
max: z.number().optional(),
|
||||||
})
|
})
|
||||||
.default({ enabled: false }),
|
.default({ enabled: false }),
|
||||||
|
validation: ZValidation.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 +130,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,
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyConsentElement = z.infer<typeof ZSurveyConsentElement>;
|
export type TSurveyConsentElement = z.infer<typeof ZSurveyConsentElement>;
|
||||||
@@ -131,11 +146,9 @@ 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" }),
|
||||||
@@ -143,6 +156,23 @@ export const ZSurveyMultipleChoiceElement = ZSurveyElementBase.extend({
|
|||||||
otherOptionPlaceholder: ZI18nString.optional(),
|
otherOptionPlaceholder: ZI18nString.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(),
|
||||||
|
validation: ZValidation.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
|
||||||
@@ -220,6 +250,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" }),
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyPictureSelectionElement = z.infer<typeof ZSurveyPictureSelectionElement>;
|
export type TSurveyPictureSelectionElement = z.infer<typeof ZSurveyPictureSelectionElement>;
|
||||||
@@ -229,6 +260,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"]),
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyDateElement = z.infer<typeof ZSurveyDateElement>;
|
export type TSurveyDateElement = z.infer<typeof ZSurveyDateElement>;
|
||||||
@@ -239,6 +271,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(),
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyFileUploadElement = z.infer<typeof ZSurveyFileUploadElement>;
|
export type TSurveyFileUploadElement = z.infer<typeof ZSurveyFileUploadElement>;
|
||||||
@@ -265,6 +298,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"),
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyMatrixElement = z.infer<typeof ZSurveyMatrixElement>;
|
export type TSurveyMatrixElement = z.infer<typeof ZSurveyMatrixElement>;
|
||||||
@@ -286,6 +320,7 @@ export const ZSurveyAddressElement = ZSurveyElementBase.extend({
|
|||||||
state: ZToggleInputConfig,
|
state: ZToggleInputConfig,
|
||||||
zip: ZToggleInputConfig,
|
zip: ZToggleInputConfig,
|
||||||
country: ZToggleInputConfig,
|
country: ZToggleInputConfig,
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyAddressElement = z.infer<typeof ZSurveyAddressElement>;
|
export type TSurveyAddressElement = z.infer<typeof ZSurveyAddressElement>;
|
||||||
@@ -299,6 +334,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(),
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyRankingElement = z.infer<typeof ZSurveyRankingElement>;
|
export type TSurveyRankingElement = z.infer<typeof ZSurveyRankingElement>;
|
||||||
@@ -311,6 +347,7 @@ export const ZSurveyContactInfoElement = ZSurveyElementBase.extend({
|
|||||||
email: ZToggleInputConfig,
|
email: ZToggleInputConfig,
|
||||||
phone: ZToggleInputConfig,
|
phone: ZToggleInputConfig,
|
||||||
company: ZToggleInputConfig,
|
company: ZToggleInputConfig,
|
||||||
|
validation: ZValidation.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSurveyContactInfoElement = z.infer<typeof ZSurveyContactInfoElement>;
|
export type TSurveyContactInfoElement = z.infer<typeof ZSurveyContactInfoElement>;
|
||||||
@@ -319,7 +356,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,
|
||||||
|
|||||||
@@ -1953,6 +1953,19 @@ const isInvalidOperatorsForQuestionType = (
|
|||||||
"doesNotStartWith",
|
"doesNotStartWith",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"doesNotEndWith",
|
"doesNotEndWith",
|
||||||
|
"isValidEmail",
|
||||||
|
"isValidUrl",
|
||||||
|
"isValidCountry",
|
||||||
|
"isValidCity",
|
||||||
|
"isLongerThan",
|
||||||
|
"isLongerThanOrEqual",
|
||||||
|
"isShorterThan",
|
||||||
|
"isShorterThanOrEqual",
|
||||||
|
"matchesRegex",
|
||||||
|
"isGreaterThan",
|
||||||
|
"isGreaterThanOrEqual",
|
||||||
|
"isLessThan",
|
||||||
|
"isLessThanOrEqual",
|
||||||
"isSubmitted",
|
"isSubmitted",
|
||||||
"isSkipped",
|
"isSkipped",
|
||||||
].includes(operator)
|
].includes(operator)
|
||||||
@@ -1965,10 +1978,10 @@ const isInvalidOperatorsForQuestionType = (
|
|||||||
![
|
![
|
||||||
"equals",
|
"equals",
|
||||||
"doesNotEqual",
|
"doesNotEqual",
|
||||||
"isGreaterThan",
|
|
||||||
"isLessThan",
|
"isLessThan",
|
||||||
"isGreaterThanOrEqual",
|
|
||||||
"isLessThanOrEqual",
|
"isLessThanOrEqual",
|
||||||
|
"isGreaterThan",
|
||||||
|
"isGreaterThanOrEqual",
|
||||||
"isSubmitted",
|
"isSubmitted",
|
||||||
"isSkipped",
|
"isSkipped",
|
||||||
].includes(operator)
|
].includes(operator)
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ZI18nString } from "../i18n";
|
||||||
|
|
||||||
|
// Field types for field-specific validation (address and contact info elements)
|
||||||
|
export const ZAddressField = z.enum(["addressLine1", "addressLine2", "city", "state", "zip", "country"]);
|
||||||
|
export type TAddressField = z.infer<typeof ZAddressField>;
|
||||||
|
|
||||||
|
export const ZContactInfoField = z.enum(["firstName", "lastName", "email", "phone", "company"]);
|
||||||
|
export type TContactInfoField = z.infer<typeof ZContactInfoField>;
|
||||||
|
|
||||||
|
// Union type for all possible field types
|
||||||
|
export const ZValidationRuleField = z.union([ZAddressField, ZContactInfoField]);
|
||||||
|
export type TValidationRuleField = z.infer<typeof ZValidationRuleField>;
|
||||||
|
|
||||||
|
// Validation rule type enum - extensible for future rule types
|
||||||
|
export const ZValidationRuleType = z.enum([
|
||||||
|
// Text/OpenText rules
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"email",
|
||||||
|
"url",
|
||||||
|
"phone",
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
|
||||||
|
// Numeric rules (for OpenText inputType=number)
|
||||||
|
"minValue",
|
||||||
|
"maxValue",
|
||||||
|
"isGreaterThan",
|
||||||
|
"isLessThan",
|
||||||
|
|
||||||
|
// Selection rules (MultiSelect)
|
||||||
|
"minSelections",
|
||||||
|
"maxSelections",
|
||||||
|
|
||||||
|
// Ranking rules
|
||||||
|
"minRanked",
|
||||||
|
"rankAll",
|
||||||
|
|
||||||
|
// Matrix rules
|
||||||
|
"minRowsAnswered",
|
||||||
|
|
||||||
|
// Date rules
|
||||||
|
"isLaterThan",
|
||||||
|
"isEarlierThan",
|
||||||
|
"isBetween",
|
||||||
|
"isNotBetween",
|
||||||
|
|
||||||
|
// File upload rules
|
||||||
|
"fileExtensionIs",
|
||||||
|
"fileExtensionIsNot",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use strict() to prevent these empty objects from matching any object in unions
|
||||||
|
// Without strict(), z.object({}) is non-strict and accepts extra properties, defeating union discrimination
|
||||||
|
export const ZValidationRuleParamsEmail = z.object({}).strict();
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsUrl = z.object({}).strict();
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsPhone = z.object({}).strict();
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsEquals = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsDoesNotEqual = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsContains = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsDoesNotContain = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsIsGreaterThan = z.object({
|
||||||
|
min: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsIsLessThan = z.object({
|
||||||
|
max: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsIsLaterThan = z.object({
|
||||||
|
date: z.string(), // YYYY-MM-DD format
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsIsEarlierThan = z.object({
|
||||||
|
date: z.string(), // YYYY-MM-DD format
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsIsBetween = z.object({
|
||||||
|
startDate: z.string(), // YYYY-MM-DD format
|
||||||
|
endDate: z.string(), // YYYY-MM-DD format
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsIsNotBetween = z.object({
|
||||||
|
startDate: z.string(), // YYYY-MM-DD format
|
||||||
|
endDate: z.string(), // YYYY-MM-DD format
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsMinRanked = z.object({
|
||||||
|
min: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsRankAll = z.object({}).strict();
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsMinRowsAnswered = z.object({
|
||||||
|
min: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// File upload rule params
|
||||||
|
export const ZValidationRuleParamsFileExtensionIs = z.object({
|
||||||
|
extensions: z.array(z.string()).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZValidationRuleParamsFileExtensionIsNot = z.object({
|
||||||
|
extensions: z.array(z.string()).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Union of all params types
|
||||||
|
export const ZValidationRuleParams = z.union([
|
||||||
|
ZValidationRuleParamsMinLength,
|
||||||
|
ZValidationRuleParamsMaxLength,
|
||||||
|
ZValidationRuleParamsPattern,
|
||||||
|
ZValidationRuleParamsEmail,
|
||||||
|
ZValidationRuleParamsUrl,
|
||||||
|
ZValidationRuleParamsPhone,
|
||||||
|
ZValidationRuleParamsEquals,
|
||||||
|
ZValidationRuleParamsDoesNotEqual,
|
||||||
|
ZValidationRuleParamsContains,
|
||||||
|
ZValidationRuleParamsDoesNotContain,
|
||||||
|
ZValidationRuleParamsMinValue,
|
||||||
|
ZValidationRuleParamsMaxValue,
|
||||||
|
ZValidationRuleParamsIsGreaterThan,
|
||||||
|
ZValidationRuleParamsIsLessThan,
|
||||||
|
ZValidationRuleParamsMinSelections,
|
||||||
|
ZValidationRuleParamsMaxSelections,
|
||||||
|
ZValidationRuleParamsIsLaterThan,
|
||||||
|
ZValidationRuleParamsIsEarlierThan,
|
||||||
|
ZValidationRuleParamsIsBetween,
|
||||||
|
ZValidationRuleParamsIsNotBetween,
|
||||||
|
ZValidationRuleParamsMinRanked,
|
||||||
|
ZValidationRuleParamsRankAll,
|
||||||
|
ZValidationRuleParamsMinRowsAnswered,
|
||||||
|
ZValidationRuleParamsFileExtensionIs,
|
||||||
|
ZValidationRuleParamsFileExtensionIsNot,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type TValidationRuleParams = z.infer<typeof ZValidationRuleParams>;
|
||||||
|
|
||||||
|
// Extract specific param types for validators
|
||||||
|
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>;
|
||||||
|
export type TValidationRuleParamsEquals = z.infer<typeof ZValidationRuleParamsEquals>;
|
||||||
|
export type TValidationRuleParamsDoesNotEqual = z.infer<typeof ZValidationRuleParamsDoesNotEqual>;
|
||||||
|
export type TValidationRuleParamsContains = z.infer<typeof ZValidationRuleParamsContains>;
|
||||||
|
export type TValidationRuleParamsDoesNotContain = z.infer<typeof ZValidationRuleParamsDoesNotContain>;
|
||||||
|
export type TValidationRuleParamsIsGreaterThan = z.infer<typeof ZValidationRuleParamsIsGreaterThan>;
|
||||||
|
export type TValidationRuleParamsIsLessThan = z.infer<typeof ZValidationRuleParamsIsLessThan>;
|
||||||
|
export type TValidationRuleParamsIsLaterThan = z.infer<typeof ZValidationRuleParamsIsLaterThan>;
|
||||||
|
export type TValidationRuleParamsIsEarlierThan = z.infer<typeof ZValidationRuleParamsIsEarlierThan>;
|
||||||
|
export type TValidationRuleParamsIsBetween = z.infer<typeof ZValidationRuleParamsIsBetween>;
|
||||||
|
export type TValidationRuleParamsIsNotBetween = z.infer<typeof ZValidationRuleParamsIsNotBetween>;
|
||||||
|
export type TValidationRuleParamsMinRanked = z.infer<typeof ZValidationRuleParamsMinRanked>;
|
||||||
|
export type TValidationRuleParamsRankAll = z.infer<typeof ZValidationRuleParamsRankAll>;
|
||||||
|
export type TValidationRuleParamsMinRowsAnswered = z.infer<typeof ZValidationRuleParamsMinRowsAnswered>;
|
||||||
|
export type TValidationRuleParamsFileExtensionIs = z.infer<typeof ZValidationRuleParamsFileExtensionIs>;
|
||||||
|
export type TValidationRuleParamsFileExtensionIsNot = z.infer<typeof ZValidationRuleParamsFileExtensionIsNot>;
|
||||||
|
|
||||||
|
// Validation rule stored on element - discriminated union with type at top level
|
||||||
|
// Field property is optional and used for address/contact info elements to target specific sub-fields
|
||||||
|
export const ZValidationRule = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: ZValidationRuleType,
|
||||||
|
params: ZValidationRuleParams,
|
||||||
|
customErrorMessage: ZI18nString.optional(),
|
||||||
|
field: ZValidationRuleField.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 = [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"email",
|
||||||
|
"url",
|
||||||
|
"phone",
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
"minValue",
|
||||||
|
"maxValue",
|
||||||
|
"isGreaterThan",
|
||||||
|
"isLessThan",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MULTIPLE_CHOICE_MULTI_RULES = ["minSelections", "maxSelections"] as const;
|
||||||
|
const DATE_RULES = ["isLaterThan", "isEarlierThan", "isBetween", "isNotBetween"] as const;
|
||||||
|
const MATRIX_RULES = ["minRowsAnswered"] as const;
|
||||||
|
const RANKING_RULES = ["minRanked", "rankAll"] as const;
|
||||||
|
// Note: fileSizeAtLeast and fileSizeAtMost are not included because they cannot be validated
|
||||||
|
// from response URLs alone (responses only contain file URLs, not file metadata).
|
||||||
|
// File size validation happens client-side during upload via element.maxSizeInMB.
|
||||||
|
const FILE_UPLOAD_RULES = ["fileExtensionIs", "fileExtensionIsNot"] as const;
|
||||||
|
// Address and Contact Info can use text-based validation rules on specific fields
|
||||||
|
const ADDRESS_RULES = [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"email",
|
||||||
|
"url",
|
||||||
|
"phone",
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
] as const;
|
||||||
|
const CONTACT_INFO_RULES = [
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"email",
|
||||||
|
"url",
|
||||||
|
"phone",
|
||||||
|
"equals",
|
||||||
|
"doesNotEqual",
|
||||||
|
"contains",
|
||||||
|
"doesNotContain",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Applicable rules per element type
|
||||||
|
export const APPLICABLE_RULES: Record<string, TValidationRuleType[]> = {
|
||||||
|
openText: [...OPEN_TEXT_RULES],
|
||||||
|
multipleChoiceMulti: [...MULTIPLE_CHOICE_MULTI_RULES],
|
||||||
|
date: [...DATE_RULES],
|
||||||
|
matrix: [...MATRIX_RULES],
|
||||||
|
ranking: [...RANKING_RULES],
|
||||||
|
fileUpload: [...FILE_UPLOAD_RULES],
|
||||||
|
pictureSelection: [],
|
||||||
|
address: [...ADDRESS_RULES],
|
||||||
|
contactInfo: [...CONTACT_INFO_RULES],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 TValidationRulesForMultipleChoiceMulti = TValidationRulesForElementType<
|
||||||
|
typeof MULTIPLE_CHOICE_MULTI_RULES
|
||||||
|
>;
|
||||||
|
export type TValidationRulesForDate = TValidationRulesForElementType<typeof DATE_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 TValidationRulesForAddress = TValidationRulesForElementType<typeof ADDRESS_RULES>;
|
||||||
|
export type TValidationRulesForContactInfo = TValidationRulesForElementType<typeof CONTACT_INFO_RULES>;
|
||||||
|
|
||||||
|
// Validation error returned by evaluator
|
||||||
|
export interface TValidationError {
|
||||||
|
ruleId: string;
|
||||||
|
ruleType: TValidationRuleType;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation result for a single element
|
||||||
|
export interface TValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: TValidationError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error map for block-level validation (keyed by elementId)
|
||||||
|
export type TValidationErrorMap = Record<string, TValidationError[]>;
|
||||||
Generated
+3
@@ -659,6 +659,9 @@ importers:
|
|||||||
bcryptjs:
|
bcryptjs:
|
||||||
specifier: 2.4.3
|
specifier: 2.4.3
|
||||||
version: 2.4.3
|
version: 2.4.3
|
||||||
|
uuid:
|
||||||
|
specifier: 11.1.0
|
||||||
|
version: 11.1.0
|
||||||
zod:
|
zod:
|
||||||
specifier: 3.24.4
|
specifier: 3.24.4
|
||||||
version: 3.24.4
|
version: 3.24.4
|
||||||
|
|||||||
Reference in New Issue
Block a user