addressed feedback

This commit is contained in:
Dhruwang
2026-01-13 11:25:53 +05:30
parent 86b45c22b2
commit 760408a09a
47 changed files with 2136 additions and 209 deletions

View File

@@ -1506,20 +1506,20 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/validation/characters: f62970e214bd04fd1959e2759ee1ec48
environments/surveys/edit/validation/contains: 06dd606c0a8f81f9a03b414e9ae89440
environments/surveys/edit/validation/does_not_contain: 854da2bdf10613ce62fb454bab16c58b
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: 6e18221b26a14df86537e89f50dcec16
environments/surveys/edit/validation/is_between: 41ff45044d8a017a8a74f72be57916b8
environments/surveys/edit/validation/is_earlier_than: cd82c79c61a8ad1d0e5b813d0a951860
environments/surveys/edit/validation/is_greater_than: 314e7a053f9094fb4df21e1379d7f4f9
environments/surveys/edit/validation/is_later_than: ffd9e3f745e85bc10fed450444787320
environments/surveys/edit/validation/is_less_than: 5d497aca78681c793f32afd82fa85311
environments/surveys/edit/validation/is_not: a59746bb7a7590c4dc5e57092a5fc138
environments/surveys/edit/validation/is_not_between: c7e302092b76484d3fa1ca8c6b1ac609
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
@@ -1530,12 +1530,13 @@ checksums:
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: 088309b017c07c01494447dba82b2621
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: 05d02c5afac857da530b73dcf18dd8e4
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

View File

@@ -1591,9 +1591,9 @@
"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 kleiner als",
"is_less_than": "ist weniger als",
"is_not": "ist nicht",
"is_not_between": "liegt nicht zwischen",
"is_not_between": "ist nicht zwischen",
"kb": "KB",
"max_length": "Höchstens",
"max_selections": "Höchstens",
@@ -1607,10 +1607,11 @@
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"validation": {
"characters": "characters",
"contains": "contains",
"does_not_contain": "does not contain",
"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",
"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",
@@ -1604,13 +1604,14 @@
"min_value": "At least",
"minimum_options_ranked": "Minimum options ranked",
"minimum_rows_answered": "Minimum rows answered",
"options_selected": "options selected",
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"validation": {
"characters": "caracteres",
"contains": "contiene",
"does_not_contain": "no contiene",
"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",
"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",
@@ -1604,13 +1604,14 @@
"min_value": "Al menos",
"minimum_options_ranked": "Opciones mínimas clasificadas",
"minimum_rows_answered": "Filas mínimas respondidas",
"options_selected": "opciones seleccionadas",
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"validation": {
"characters": "caractères",
"contains": "contient",
"does_not_contain": "ne contient pas",
"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",
"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",
@@ -1604,13 +1604,14 @@
"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",
"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_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",

View File

@@ -1580,7 +1580,7 @@
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"validation": {
"characters": "文字",
"characters": "文字",
"contains": "を含む",
"does_not_contain": "を含まない",
"email": "有効なメールアドレスである",
@@ -1593,7 +1593,7 @@
"is_later_than": "より後である",
"is_less_than": "より小さい",
"is_not": "ではない",
"is_not_between": "範囲外",
"is_not_between": "の間ではない",
"kb": "KB",
"max_length": "最大",
"max_selections": "最大",
@@ -1604,13 +1604,14 @@
"min_value": "最小",
"minimum_options_ranked": "ランク付けされた最小オプション数",
"minimum_rows_answered": "回答された最小行数",
"options_selected": "個のオプションが選択されている",
"options_selected": "選択されたオプション",
"pattern": "正規表現パターンに一致する",
"phone": "有効な電話番号である",
"rank_all_options": "すべてのオプションをランク付け",
"select_file_extensions": "ファイル拡張子を選択...",
"url": "有効なURLである"
},
"validation_logic_and": "すべてが真",
"validation_logic_and": "すべてが真である",
"validation_logic_or": "いずれかが真",
"validation_rules": "検証ルール",
"validation_rules_description": "次の条件を満たす回答のみを受け付ける",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"validation": {
"characters": "tekens",
"contains": "bevat",
"does_not_contain": "bevat niet",
"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": "ligt niet tussen",
"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",
@@ -1604,13 +1604,14 @@
"min_value": "Minimaal",
"minimum_options_ranked": "Minimaal aantal gerangschikte opties",
"minimum_rows_answered": "Minimaal aantal beantwoorde rijen",
"options_selected": "opties geselecteerd",
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"validation": {
"characters": "caracteres",
"contains": "contém",
"does_not_contain": "não contém",
"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",
"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",
@@ -1604,13 +1604,14 @@
"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",
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"validation": {
"characters": "caracteres",
"contains": "contém",
"does_not_contain": "não contém",
"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",
"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",
@@ -1604,13 +1604,14 @@
"min_value": "Pelo menos",
"minimum_options_ranked": "Opções mínimas classificadas",
"minimum_rows_answered": "Linhas mínimas respondidas",
"options_selected": "opções selecionadas",
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"validation": {
"characters": "caractere",
"contains": "conține",
"does_not_contain": "nu conține",
"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",
"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",
@@ -1604,13 +1604,14 @@
"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",
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"validation": {
"characters": "символов",
"contains": "содержит",
"does_not_contain": "не содержит",
"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": "не находится между",
"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": "Не более",
@@ -1604,13 +1604,14 @@
"min_value": "Не менее",
"minimum_options_ranked": "Минимальное количество ранжированных вариантов",
"minimum_rows_answered": "Минимальное количество заполненных строк",
"options_selected": "выбрано вариантов",
"options_selected": "Выбранные опции",
"pattern": "Соответствует шаблону regex",
"phone": "Корректный телефон",
"rank_all_options": "Ранжируйте все опции",
"select_file_extensions": "Выберите расширения файлов...",
"url": "Корректный URL"
},
"validation_logic_and": "все условия выполняются",
"validation_logic_and": "Все условия выполняются",
"validation_logic_or": "выполняется хотя бы одно условие",
"validation_rules": "Правила валидации",
"validation_rules_description": "Принимать только ответы, соответствующие следующим критериям",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"validation": {
"characters": "tecken",
"contains": "innehåller",
"does_not_contain": "innehåller inte",
"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",
"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",
@@ -1604,13 +1604,14 @@
"min_value": "Minst",
"minimum_options_ranked": "Minsta antal rangordnade alternativ",
"minimum_rows_answered": "Minsta antal besvarade rader",
"options_selected": "valda alternativ",
"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_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",

View File

@@ -1580,20 +1580,20 @@
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"validation": {
"characters": "字符",
"characters": "字符",
"contains": "包含",
"does_not_contain": "不包含",
"email": "是有效的邮箱地址",
"file_extension_is": "文件扩展名为",
"file_extension_is_not": "文件扩展名不是",
"is": "",
"is": "等于",
"is_between": "介于",
"is_earlier_than": "早于",
"is_greater_than": "大于",
"is_later_than": "晚于",
"is_less_than": "小于",
"is_not": "不",
"is_not_between": "不在区间内",
"is_not": "不等于",
"is_not_between": "不介于",
"kb": "KB",
"max_length": "最多",
"max_selections": "最多",
@@ -1604,9 +1604,10 @@
"min_value": "至少",
"minimum_options_ranked": "最少排序选项数",
"minimum_rows_answered": "最少回答行数",
"options_selected": "已选择",
"options_selected": "已选择的选项",
"pattern": "匹配正则表达式模式",
"phone": "是有效的手机号",
"rank_all_options": "对所有选项进行排序",
"select_file_extensions": "选择文件扩展名...",
"url": "是有效的URL"
},

View File

@@ -1580,20 +1580,20 @@
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"validation": {
"characters": "字元",
"characters": "字元",
"contains": "包含",
"does_not_contain": "不包含",
"email": "是有效的電子郵件",
"file_extension_is": "檔案副檔名為",
"file_extension_is_not": "檔案副檔名不是",
"is": "",
"is": "等於",
"is_between": "介於",
"is_earlier_than": "早於",
"is_greater_than": "大於",
"is_later_than": "晚於",
"is_less_than": "小於",
"is_not": "不",
"is_not_between": "不在範圍內",
"is_not": "不等於",
"is_not_between": "不介於",
"kb": "KB",
"max_length": "最多",
"max_selections": "最多",
@@ -1604,9 +1604,10 @@
"min_value": "至少",
"minimum_options_ranked": "最少排序選項數",
"minimum_rows_answered": "最少作答列數",
"options_selected": "個選項已選",
"options_selected": "已選擇的選項",
"pattern": "符合正則表達式樣式",
"phone": "是有效的電話號碼",
"rank_all_options": "請為所有選項排序",
"select_file_extensions": "請選擇檔案副檔名...",
"url": "是有效的 URL"
},

View File

@@ -112,6 +112,7 @@ export const EditorCardMenu = ({
choices: card.choices,
type,
logic: undefined,
validation: undefined,
});
return;
@@ -128,6 +129,7 @@ export const EditorCardMenu = ({
buttonLabel,
backButtonLabel,
logic: undefined,
validation: undefined,
});
};

View File

@@ -5,13 +5,12 @@ import { createId } from "@paralleldrive/cuid2";
import { PlusIcon } from "lucide-react";
import { type JSX } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyElementTypeEnum, TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
import { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
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 { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label";
@@ -171,16 +170,6 @@ export const PictureSelectionForm = ({
</div>
</Label>
</div>
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.PictureSelection}
validation={element.validation}
onUpdateValidation={(validation) => {
updateElement(elementIdx, {
validation,
});
}}
/>
</form>
);
};

View File

@@ -132,7 +132,6 @@ export const ValidationRuleRow = ({
{config.unitOptions && config.unitOptions.length > 0 && (
<ValidationRuleUnitSelector
value={config.unitOptions[0].value}
onChange={undefined}
unitOptions={config.unitOptions}
ruleLabels={ruleLabels}
disabled={config.unitOptions.length === 1}

View File

@@ -16,7 +16,6 @@ interface UnitOption {
interface ValidationRuleUnitSelectorProps {
value: string;
onChange?: (value: string) => void;
unitOptions: UnitOption[];
ruleLabels: Record<string, string>;
disabled?: boolean;
@@ -24,19 +23,18 @@ interface ValidationRuleUnitSelectorProps {
export const ValidationRuleUnitSelector = ({
value,
onChange,
unitOptions,
ruleLabels,
disabled = false,
}: ValidationRuleUnitSelectorProps) => {
return (
<Select value={value} onValueChange={onChange} disabled={disabled || unitOptions.length === 1}>
<SelectTrigger className={cn("flex-1 bg-white", disabled && "cursor-not-allowed")}>
<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}>
<SelectItem key={unit.value} value={unit.value} className="truncate">
{ruleLabels[unit.labelKey]}
</SelectItem>
))}

View File

@@ -1,6 +1,5 @@
"use client";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { v7 as uuidv7 } from "uuid";
import { TAllowedFileExtension } from "@formbricks/types/storage";
@@ -48,8 +47,6 @@ export const ValidationRulesEditor = ({
validation,
onUpdateValidation,
element,
projectOrganizationId,
isFormbricksCloud = false,
inputType,
onUpdateInputType,
}: ValidationRulesEditorProps) => {
@@ -216,7 +213,7 @@ export const ValidationRulesEditor = ({
if (rule.id !== ruleId) return rule;
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
const parsedValue = parseRuleValue(ruleType, value, config, rule.params);
const parsedValue = parseRuleValue(ruleType, value, config);
return {
...rule,

View File

@@ -73,25 +73,25 @@ export const RULE_TYPE_CONFIG: Record<
labelKey: "is",
needsValue: true,
valueType: "text",
valuePlaceholder: "value",
valuePlaceholder: "Value",
},
doesNotEqual: {
labelKey: "is_not",
needsValue: true,
valueType: "text",
valuePlaceholder: "value",
valuePlaceholder: "Value",
},
contains: {
labelKey: "contains",
needsValue: true,
valueType: "text",
valuePlaceholder: "text",
valuePlaceholder: "Text",
},
doesNotContain: {
labelKey: "does_not_contain",
needsValue: true,
valueType: "text",
valuePlaceholder: "text",
valuePlaceholder: "Text",
},
isGreaterThan: {
labelKey: "is_greater_than",
@@ -135,6 +135,10 @@ export const RULE_TYPE_CONFIG: Record<
valueType: "number",
valuePlaceholder: "1",
},
rankAll: {
labelKey: "rank_all_options",
needsValue: false,
},
minRowsAnswered: {
labelKey: "minimum_rows_answered",
needsValue: true,

View File

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

View File

@@ -2,7 +2,6 @@ import { TSurveyElement } from "@formbricks/types/surveys/elements";
import {
TAddressField,
TContactInfoField,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
@@ -53,6 +52,7 @@ export const getRuleLabels = (t: (key: string) => string): Record<string, string
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"),
@@ -101,8 +101,7 @@ export const normalizeFileExtension = (value: string): string => {
export const parseRuleValue = (
ruleType: TValidationRuleType,
value: string,
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType],
currentParams: TValidationRule["params"]
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType]
): string | number => {
// Handle file extension formatting: auto-add dot if missing
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {

View File

@@ -138,7 +138,8 @@ describe("getAvailableRuleTypes", () => {
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("minRanked");
expect(available.length).toBe(1);
expect(available).toContain("rankAll");
expect(available.length).toBe(2);
});
test("should return file validation rules for fileUpload element", () => {
@@ -151,15 +152,13 @@ describe("getAvailableRuleTypes", () => {
expect(available).toContain("fileExtensionIsNot");
});
test("should return minSelections and maxSelections for pictureSelection element", () => {
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).toContain("minSelections");
expect(available).toContain("maxSelections");
expect(available.length).toBe(2);
expect(available).toEqual([]);
});
test("should return empty array for address element (no validation rules)", () => {

View File

@@ -221,6 +221,8 @@ export const createRuleParams = (
return { max: Number(value) || 3 };
case "minRanked":
return { min: Number(value) || 1 };
case "rankAll":
return {};
case "minRowsAnswered":
return { min: Number(value) || 1 };
case "fileExtensionIs":

View File

@@ -39,24 +39,59 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
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(() => {
if (value) {
setSelected(
value.map((val) => options.find((o) => o.value === val)).filter((o): o is TOption<T> => !!o)
);
const newSelected = value
.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]);
// 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(
(option: TOption<T>) => {
if (disabled) return;
setSelected((prev) => {
const newSelected = prev.filter((s) => s.value !== option.value);
onChange?.(newSelected.map((s) => s.value) as K);
return newSelected;
});
isUserInitiatedRef.current = true; // Mark as user-initiated
setSelected((prev) => prev.filter((s) => s.value !== option.value));
},
[onChange, disabled]
[disabled]
);
const handleKeyDown = React.useCallback(
@@ -65,10 +100,10 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
if (!input || disabled) return;
if ((e.key === "Delete" || e.key === "Backspace") && input.value === "") {
isUserInitiatedRef.current = true; // Mark as user-initiated
setSelected((prev) => {
const newSelected = [...prev];
newSelected.pop();
onChange?.(newSelected.map((s) => s.value) as K);
return newSelected;
});
}
@@ -109,8 +144,9 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
className={`relative overflow-visible bg-white ${disabled ? "cursor-not-allowed opacity-50" : ""}`}>
<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 ${disabled ? "pointer-events-none" : "focus-within:ring-ring"
}`}>
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"
}`}>
<div className="flex flex-wrap gap-1">
{selected.map((option) => (
<Badge key={option.value} className="rounded-md">
@@ -168,9 +204,8 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
}}
onSelect={() => {
if (disabled) return;
const newSelected = [...selected, option];
setSelected(newSelected);
onChange?.(newSelected.map((o) => o.value) as K);
isUserInitiatedRef.current = true; // Mark as user-initiated
setSelected((prev) => [...prev, option]);
setInputValue("");
}}
className="cursor-pointer">

View File

@@ -30,6 +30,7 @@ checksums:
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
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

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "تعليقك عالق :("
},
"errors": {
"all_options_must_be_ranked": "يرجى ترتيب جميع الخيارات",
"file_extension_must_be": "يجب أن يكون امتداد الملف {extension}",
"file_extension_must_not_be": "يجب ألا يكون امتداد الملف {extension}",
"file_input": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Your feedback is stuck :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Tu feedback está atascado :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Votre feedback est bloqué :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
},
"errors": {
"all_options_must_be_ranked": "कृपया सभी विकल्पों को रैंक करें",
"file_extension_must_be": "फ़ाइल एक्सटेंशन {extension} होना चाहिए",
"file_extension_must_not_be": "फ़ाइल एक्सटेंशन {extension} नहीं होना चाहिए",
"file_input": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "フィードバックが送信できません :("
},
"errors": {
"all_options_must_be_ranked": "すべてのオプションをランク付けしてください",
"file_extension_must_be": "ファイル拡張子は{extension}である必要があります",
"file_extension_must_not_be": "ファイル拡張子は{extension}であってはなりません",
"file_input": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Je feedback blijft hangen :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Seu feedback está preso :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Ваш отзыв застрял :("
},
"errors": {
"all_options_must_be_ranked": "Пожалуйста, расставьте все варианты по порядку",
"file_extension_must_be": "Расширение файла должно быть {extension}",
"file_extension_must_not_be": "Расширение файла не должно быть {extension}",
"file_input": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Din feedback fastnade :("
},
"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": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
},
"errors": {
"all_options_must_be_ranked": "Iltimos, barcha variantlarni tartiblang",
"file_extension_must_be": "Fayl kengaytmasi {extension} bolishi kerak",
"file_extension_must_not_be": "Fayl kengaytmasi {extension} bolmasligi kerak",
"file_input": {

View File

@@ -31,6 +31,7 @@
"your_feedback_is_stuck": "您的反馈卡住了 :("
},
"errors": {
"all_options_must_be_ranked": "请对所有选项进行排序",
"file_extension_must_be": "文件扩展名必须为{extension}",
"file_extension_must_not_be": "文件扩展名不能为{extension}",
"file_input": {

View File

@@ -149,10 +149,10 @@ export function BlockConditional({
): boolean => {
const isRequired = element.required;
const isValueArray = Array.isArray(response);
const allItemsRanked = isValueArray && response.length === element.choices.length;
const atLeastOneRanked = isValueArray && response.length >= 1;
// If required: all items must be ranked
if (isRequired && (!isValueArray || !allItemsRanked)) {
// If required: at least 1 option must be ranked
if (isRequired && (!isValueArray || !atLeastOneRanked)) {
form.requestSubmit();
return false;
}

View File

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

View File

@@ -118,11 +118,11 @@ export const validateElementResponse = (
// Special handling for ranking elements
if (element.type === TSurveyElementTypeEnum.Ranking) {
const isValueArray = Array.isArray(value);
const allItemsRanked = isValueArray && value.length === element.choices.length;
const atLeastOneRanked = isValueArray && value.length >= 1;
// If required: all items must be ranked (no partial ranking allowed)
// If required: at least 1 option must be ranked
// If not required: partial ranking is allowed (validation only checks if empty)
if (isEmpty(value) || (isValueArray && !allItemsRanked)) {
if (isEmpty(value) || !atLeastOneRanked) {
errors.push(createRequiredError(t));
}
}

View File

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

View File

@@ -456,6 +456,27 @@ export const validators: Record<TValidationRuleType, TValidator> = {
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,

View File

@@ -32,12 +32,13 @@ export const ZValidationRuleType = z.enum([
"isGreaterThan",
"isLessThan",
// Selection rules (MultiSelect, PictureSelection)
// Selection rules (MultiSelect)
"minSelections",
"maxSelections",
// Ranking rules
"minRanked",
"rankAll",
// Matrix rules
"minRowsAnswered",
@@ -139,6 +140,8 @@ 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),
});
@@ -175,6 +178,7 @@ export const ZValidationRuleParams = z.union([
ZValidationRuleParamsIsBetween,
ZValidationRuleParamsIsNotBetween,
ZValidationRuleParamsMinRanked,
ZValidationRuleParamsRankAll,
ZValidationRuleParamsMinRowsAnswered,
ZValidationRuleParamsFileExtensionIs,
ZValidationRuleParamsFileExtensionIsNot,
@@ -204,6 +208,7 @@ export type TValidationRuleParamsIsEarlierThan = z.infer<typeof ZValidationRuleP
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>;
@@ -245,12 +250,11 @@ const OPEN_TEXT_RULES = [
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"] 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;
const PICTURE_SELECTION_RULES = ["minSelections", "maxSelections"] as const;
// Address and Contact Info can use text-based validation rules on specific fields
const ADDRESS_RULES = [
"minLength",
@@ -285,7 +289,7 @@ export const APPLICABLE_RULES: Record<string, TValidationRuleType[]> = {
matrix: [...MATRIX_RULES],
ranking: [...RANKING_RULES],
fileUpload: [...FILE_UPLOAD_RULES],
pictureSelection: [...PICTURE_SELECTION_RULES],
pictureSelection: [],
address: [...ADDRESS_RULES],
contactInfo: [...CONTACT_INFO_RULES],
};
@@ -309,9 +313,6 @@ export type TValidationRulesForDate = TValidationRulesForElementType<typeof DATE
export type TValidationRulesForMatrix = TValidationRulesForElementType<typeof MATRIX_RULES>;
export type TValidationRulesForRanking = TValidationRulesForElementType<typeof RANKING_RULES>;
export type TValidationRulesForFileUpload = TValidationRulesForElementType<typeof FILE_UPLOAD_RULES>;
export type TValidationRulesForPictureSelection = TValidationRulesForElementType<
typeof PICTURE_SELECTION_RULES
>;
export type TValidationRulesForAddress = TValidationRulesForElementType<typeof ADDRESS_RULES>;
export type TValidationRulesForContactInfo = TValidationRulesForElementType<typeof CONTACT_INFO_RULES>;