diff --git a/api/app/Rules/FormPropertyLogicRule.php b/api/app/Rules/FormPropertyLogicRule.php index cfaee33b..a940993e 100644 --- a/api/app/Rules/FormPropertyLogicRule.php +++ b/api/app/Rules/FormPropertyLogicRule.php @@ -71,6 +71,18 @@ class FormPropertyLogicRule implements DataAwareRule, ValidationRule 'content_length_less_than_or_equal_to' => [ 'expected_type' => 'number', ], + 'matches_regex' => [ + 'expected_type' => 'string', + 'format' => [ + 'type' => 'regex' + ] + ], + 'does_not_match_regex' => [ + 'expected_type' => 'string', + 'format' => [ + 'type' => 'regex' + ] + ], ], ], 'matrix' => [ @@ -672,6 +684,8 @@ class FormPropertyLogicRule implements DataAwareRule, ValidationRule private $data = []; + private $operator = ''; + private function checkBaseCondition($condition) { @@ -712,6 +726,7 @@ private function checkBaseCondition($condition) $typeField = $condition['value']['property_meta']['type']; $operator = $condition['value']['operator']; + $this->operator = $operator; $value = $condition['value']['value']; if (!isset(self::CONDITION_MAPPING[$typeField])) { @@ -750,6 +765,19 @@ private function checkBaseCondition($condition) private function valueHasCorrectType($type, $value) { + if ($type === 'string' && isset(self::CONDITION_MAPPING[$this->field['type']]['comparators'][$this->operator]['format'])) { + $format = self::CONDITION_MAPPING[$this->field['type']]['comparators'][$this->operator]['format']; + if ($format['type'] === 'regex') { + try { + preg_match('/' . $value . '/', ''); + return true; + } catch (\Exception $e) { + $this->conditionErrors[] = 'invalid regex pattern'; + return false; + } + } + } + if ( ($type === 'string' && gettype($value) !== 'string') || ($type === 'boolean' && !is_bool($value)) || diff --git a/api/app/Service/Forms/FormLogicConditionChecker.php b/api/app/Service/Forms/FormLogicConditionChecker.php index 11fb1820..b2d6035f 100644 --- a/api/app/Service/Forms/FormLogicConditionChecker.php +++ b/api/app/Service/Forms/FormLogicConditionChecker.php @@ -306,6 +306,19 @@ private function textConditionMet(array $propertyCondition, $value): bool return $this->checkLength($propertyCondition, $value, '<'); case 'content_length_less_than_or_equal_to': return $this->checkLength($propertyCondition, $value, '<='); + case 'matches_regex': + try { + return (bool) preg_match('/' . $propertyCondition['value'] . '/', $value); + } catch (\Exception $e) { + ray('matches_regex_error', $e); + return false; + } + case 'does_not_match_regex': + try { + return !(bool) preg_match('/' . $propertyCondition['value'] . '/', $value); + } catch (\Exception $e) { + return true; + } } return false; diff --git a/api/resources/data/open_filters.json b/api/resources/data/open_filters.json index 3d72525e..55331bbb 100644 --- a/api/resources/data/open_filters.json +++ b/api/resources/data/open_filters.json @@ -228,6 +228,46 @@ }, "content_length_less_than_or_equal_to": { "expected_type": "number" + }, + "matches_regex": { + "expected_type": "string", + "format": { + "type": "regex" + } + }, + "does_not_match_regex": { + "expected_type": "string", + "format": { + "type": "regex" + } + } + } + }, + "matrix": { + "comparators": { + "equals": { + "expected_type": "object", + "format": { + "type": "object" + } + }, + "does_not_equal": { + "expected_type": "object", + "format": { + "type":"object" + } + }, + "contains": { + "expected_type": "object", + "format": { + "type":"object" + } + }, + "does_not_contain": { + "expected_type": "object", + "format": { + "type":"object" + } } } }, diff --git a/api/tests/Feature/Forms/FormLogicTest.php b/api/tests/Feature/Forms/FormLogicTest.php index 81d038c4..c3a18b6b 100644 --- a/api/tests/Feature/Forms/FormLogicTest.php +++ b/api/tests/Feature/Forms/FormLogicTest.php @@ -1,6 +1,7 @@ actingAsUser(); @@ -348,3 +349,198 @@ ] ]); }); + + +it('can submit form with passed regex validation condition', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $targetField = collect($form->properties)->where('name', 'Email')->first(); + + // Regex condition to check if email is from gmail.com domain + $condition = [ + 'actions' => [], + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => $targetField['id'], + 'value' => [ + 'operator' => 'matches_regex', + 'property_meta' => [ + 'id' => $targetField['id'], + 'type' => 'text', + ], + 'value' => '^[a-zA-Z0-9._%+-]+@gmail\.com$', + ], + ], + ], + ], + ]; + + $submissionData = []; + $validationMessage = 'Must be a Gmail address'; + + $form->properties = collect($form->properties)->map(function ($property) use (&$submissionData, &$condition, &$validationMessage, $targetField) { + if (in_array($property['name'], ['Name'])) { + $property['validation'] = ['error_conditions' => $condition, 'error_message' => $validationMessage]; + $submissionData[$targetField['id']] = 'test@gmail.com'; + } + return $property; + })->toArray(); + + $form->update(); + $formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData); + + $response = $this->postJson(route('forms.answer', $form->slug), $formData); + $response->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); +}); + +it('can not submit form with failed regex validation condition', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $targetField = collect($form->properties)->where('name', 'Email')->first(); + + // Regex condition to check if email is from gmail.com domain + $condition = [ + 'actions' => [], + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => $targetField['id'], + 'value' => [ + 'operator' => 'matches_regex', + 'property_meta' => [ + 'id' => $targetField['id'], + 'type' => 'text', + ], + 'value' => '^[a-zA-Z0-9._%+-]+@gmail\.com$', + ], + ], + ], + ], + ]; + + $submissionData = []; + $validationMessage = 'Must be a Gmail address'; + + $form->properties = collect($form->properties)->map(function ($property) use (&$submissionData, &$condition, &$validationMessage, $targetField) { + if (in_array($property['name'], ['Name'])) { + $property['validation'] = ['error_conditions' => $condition, 'error_message' => $validationMessage]; + $submissionData[$targetField['id']] = 'test@yahoo.com'; // Non-Gmail address should fail + } + return $property; + })->toArray(); + + $form->update(); + $formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData); + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertStatus(422) + ->assertJson([ + 'message' => $validationMessage, + ]); +}); + +it('can submit form with does not match regex validation condition', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $targetField = collect($form->properties)->where('name', 'Email')->first(); + + // Regex condition to check if email is NOT from gmail.com domain + $condition = [ + 'actions' => [], + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => $targetField['id'], + 'value' => [ + 'operator' => 'does_not_match_regex', + 'property_meta' => [ + 'id' => $targetField['id'], + 'type' => 'text', + ], + 'value' => '^[a-zA-Z0-9._%+-]+@gmail\.com$', + ], + ], + ], + ], + ]; + + $submissionData = []; + $validationMessage = 'Gmail addresses not allowed'; + + $form->properties = collect($form->properties)->map(function ($property) use (&$submissionData, &$condition, &$validationMessage, $targetField) { + if (in_array($property['name'], ['Name'])) { + $property['validation'] = ['error_conditions' => $condition, 'error_message' => $validationMessage]; + $submissionData[$targetField['id']] = 'test@yahoo.com'; // Non-Gmail address should pass + } + return $property; + })->toArray(); + + $form->update(); + $formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData); + + $response = $this->postJson(route('forms.answer', $form->slug), $formData); + $response->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); +}); + +it('handles invalid regex patterns gracefully', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $targetField = collect($form->properties)->where('name', 'Email')->first(); + + // Invalid regex pattern + $condition = [ + 'actions' => [], + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => $targetField['id'], + 'value' => [ + 'operator' => 'matches_regex', + 'property_meta' => [ + 'id' => $targetField['id'], + 'type' => 'text', + ], + 'value' => '[Invalid Regex)', // Invalid regex pattern + ], + ], + ], + ], + ]; + + $submissionData = []; + $validationMessage = 'Invalid regex pattern'; + + $form->properties = collect($form->properties)->map(function ($property) use (&$submissionData, &$condition, &$validationMessage, $targetField) { + if (in_array($property['name'], ['Name'])) { + $property['validation'] = ['error_conditions' => $condition, 'error_message' => $validationMessage]; + $submissionData[$targetField['id']] = 'test@gmail.com'; + } + return $property; + })->toArray(); + + $form->update(); + $formData = FormSubmissionDataFactory::generateSubmissionData($form, $submissionData); + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertStatus(422) + ->assertJson([ + 'message' => $validationMessage, + ]); +}); diff --git a/client/data/open_filters.json b/client/data/open_filters.json index 09826647..55331bbb 100644 --- a/client/data/open_filters.json +++ b/client/data/open_filters.json @@ -228,6 +228,18 @@ }, "content_length_less_than_or_equal_to": { "expected_type": "number" + }, + "matches_regex": { + "expected_type": "string", + "format": { + "type": "regex" + } + }, + "does_not_match_regex": { + "expected_type": "string", + "format": { + "type": "regex" + } } } }, diff --git a/client/lib/forms/FormLogicConditionChecker.js b/client/lib/forms/FormLogicConditionChecker.js index d75b891f..38368535 100644 --- a/client/lib/forms/FormLogicConditionChecker.js +++ b/client/lib/forms/FormLogicConditionChecker.js @@ -278,6 +278,20 @@ function textConditionMet(propertyCondition, value) { return checkLength(propertyCondition, value, "<") case "content_length_less_than_or_equal_to": return checkLength(propertyCondition, value, "<=") + case 'matches_regex': + try { + const regex = new RegExp(propertyCondition.value) + return regex.test(value) + } catch (e) { + return false + } + case 'does_not_match_regex': + try { + const regex = new RegExp(propertyCondition.value) + return !regex.test(value) + } catch (e) { + return true + } } return false }