From fba2207e0ffb1fda97b13f1e5aaf9848f7e7669b Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:20:59 +0530 Subject: [PATCH] =?UTF-8?q?Add=20'exists=5Fin=5Fsubmissions'=20and=20'does?= =?UTF-8?q?=5Fnot=5Fexist=5Fin=5Fsubmissions'=20valid=E2=80=A6=20(#725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add 'exists_in_submissions' and 'does_not_exist_in_submissions' validation conditions - Implement new validation conditions in FormLogicConditionChecker to check for existing submissions. - Update open_filters.json and client-side filters to include the new conditions. - Enhance FormLogicTest with test cases for the new validation conditions. - Modify UI components to support the new logic conditions in form validation. * Fix FormLogicConditionChecker * Improve custom_validation_only * fix test --------- Co-authored-by: Julien Nahum --- .../Forms/FormLogicConditionChecker.php | 52 ++++++++++ api/resources/data/open_filters.json | 80 +++++++++++++++ api/tests/Feature/Forms/FormLogicTest.php | 99 +++++++++++++++++++ .../components/CustomFieldValidation.vue | 1 + .../form-logic-components/ColumnCondition.vue | 17 ++-- .../ConditionEditor.client.vue | 8 ++ client/data/open_filters.json | 80 +++++++++++++++ 7 files changed, 329 insertions(+), 8 deletions(-) diff --git a/api/app/Service/Forms/FormLogicConditionChecker.php b/api/app/Service/Forms/FormLogicConditionChecker.php index 6dc26147..707b1ffc 100644 --- a/api/app/Service/Forms/FormLogicConditionChecker.php +++ b/api/app/Service/Forms/FormLogicConditionChecker.php @@ -2,6 +2,8 @@ namespace App\Service\Forms; +use App\Models\Forms\FormSubmission; + class FormLogicConditionChecker { public function __construct(private ?array $conditions, private ?array $formData) @@ -304,6 +306,48 @@ class FormLogicConditionChecker return false; } + private function checkExistsInSubmissions($condition, $fieldValue): bool + { + if (!$fieldValue || !isset($condition['property_meta']['id'])) { + return false; + } + + $formId = $this->formData['form']['id'] ?? null; + if (!$formId) { + return false; + } + + return FormSubmission::where('form_id', $formId) + ->where(function ($query) use ($condition, $fieldValue) { + $fieldId = $condition['property_meta']['id']; + + if (env('DB_CONNECTION') == 'mysql') { + // For scalar values + $query->where(function ($q) use ($fieldId, $fieldValue) { + $q->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(data, '$.\"$fieldId\"')) = ?", [$fieldValue]); + + // For array values + if (is_array($fieldValue)) { + $q->orWhereRaw("JSON_CONTAINS(JSON_EXTRACT(data, '$.\"$fieldId\"'), ?)", [json_encode($fieldValue)]); + } + }); + } else { + $query->where(function ($q) use ($fieldId, $fieldValue) { + // For scalar values + $q->whereRaw("data->? = ?::jsonb", [$fieldId, json_encode($fieldValue)]); + + // For array values + if (is_array($fieldValue)) { + $q->orWhereRaw("data->? @> ?::jsonb", [ + $fieldId, + json_encode($fieldValue) + ]); + } + }); + } + })->exists(); + } + private function textConditionMet(array $propertyCondition, $value): bool { switch ($propertyCondition['operator']) { @@ -348,6 +392,10 @@ class FormLogicConditionChecker } catch (\Exception $e) { return true; } + case 'exists_in_submissions': + return $this->checkExistsInSubmissions($propertyCondition, $value); + case 'does_not_exist_in_submissions': + return !$this->checkExistsInSubmissions($propertyCondition, $value); } return false; @@ -384,6 +432,10 @@ class FormLogicConditionChecker return $this->checkLength($propertyCondition, $value, '<'); case 'content_length_less_than_or_equal_to': return $this->checkLength($propertyCondition, $value, '<='); + case 'exists_in_submissions': + return $this->checkExistsInSubmissions($propertyCondition, $value); + case 'does_not_exist_in_submissions': + return !$this->checkExistsInSubmissions($propertyCondition, $value); } return false; diff --git a/api/resources/data/open_filters.json b/api/resources/data/open_filters.json index c1bf7077..7fa27267 100644 --- a/api/resources/data/open_filters.json +++ b/api/resources/data/open_filters.json @@ -66,6 +66,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -136,6 +152,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -206,6 +238,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -276,6 +324,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -362,6 +426,22 @@ }, "content_length_less_than_or_equal_to": { "expected_type": "number" + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, diff --git a/api/tests/Feature/Forms/FormLogicTest.php b/api/tests/Feature/Forms/FormLogicTest.php index 77a5e629..6b96e959 100644 --- a/api/tests/Feature/Forms/FormLogicTest.php +++ b/api/tests/Feature/Forms/FormLogicTest.php @@ -607,3 +607,102 @@ it('skips validation for fields hidden by logic conditions', function () { ], ]); }); + +it('cannot submit form with failed exists_in_submissions validation condition', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $targetField = collect($form->properties)->where('name', 'Email')->first(); + + // First set up the validation condition + $condition = [ + 'actions' => [], + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => $targetField['id'], + 'value' => [ + 'operator' => 'exists_in_submissions', + 'property_meta' => [ + 'id' => $targetField['id'], + 'type' => 'text', + ], + ], + ], + ], + ], + ]; + + $validationMessage = 'Email already exists in previous submissions'; + + $form->properties = collect($form->properties)->map(function ($property) use ($condition, $validationMessage, $targetField) { + if (in_array($property['name'], ['Name'])) { + $property['validation'] = ['error_conditions' => $condition, 'error_message' => $validationMessage]; + } + return $property; + })->toArray(); + + $form->update(); + + $formData = [$targetField['id'] => 'existing@test.com']; + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertStatus(422) + ->assertJson([ + 'message' => $validationMessage, + ]); +}); + +it('cannot submit form with failed does_not_exist_in_submissions validation condition', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $targetField = collect($form->properties)->where('name', 'Email')->first(); + + // First set up the validation condition + $condition = [ + 'actions' => [], + 'conditions' => [ + 'operatorIdentifier' => 'and', + 'children' => [ + [ + 'identifier' => $targetField['id'], + 'value' => [ + 'operator' => 'does_not_exist_in_submissions', + 'property_meta' => [ + 'id' => $targetField['id'], + 'type' => 'text', + ], + ], + ], + ], + ], + ]; + + $validationMessage = 'Email already exists in previous submissions'; + + $form->properties = collect($form->properties)->map(function ($property) use ($condition, $validationMessage, $targetField) { + if (in_array($property['name'], ['Name'])) { + $property['validation'] = ['error_conditions' => $condition, 'error_message' => $validationMessage]; + } + return $property; + })->toArray(); + + $form->update(); + + $formData = [$targetField['id'] => 'existing@test.com']; + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertStatus(422) + ->assertJson([ + 'message' => $validationMessage, + ]); +}); diff --git a/client/components/open/forms/components/CustomFieldValidation.vue b/client/components/open/forms/components/CustomFieldValidation.vue index 82c01789..570f3cc6 100644 --- a/client/components/open/forms/components/CustomFieldValidation.vue +++ b/client/components/open/forms/components/CustomFieldValidation.vue @@ -14,6 +14,7 @@ v-model="validation.conditions" class="mt-1 border-t border rounded-md" :form="form" + :custom-validation="true" /> diff --git a/client/components/open/forms/components/form-logic-components/ColumnCondition.vue b/client/components/open/forms/components/form-logic-components/ColumnCondition.vue index 56352265..e9311aa3 100644 --- a/client/components/open/forms/components/form-logic-components/ColumnCondition.vue +++ b/client/components/open/forms/components/form-logic-components/ColumnCondition.vue @@ -36,6 +36,7 @@ export default { components: {}, props: { modelValue: { type: Object, required: true }, + customValidation: { type: Boolean, default: false }, }, emits: ['update:modelValue'], @@ -102,14 +103,14 @@ export default { return componentData }, operators() { - return Object.keys( - this.available_filters[this.property.type].comparators, - ).map((key) => { - return { - value: key, - name: this.optionFilterNames(key), - } - }) + return Object.entries(this.available_filters[this.property.type].comparators) + .filter(([key, value]) => this.customValidation || (!this.customValidation && !value.custom_validation_only)) + .map(([key]) => { + return { + value: key, + name: this.optionFilterNames(key), + } + }) }, needsInput() { const operator = this.selectedOperator() diff --git a/client/components/open/forms/components/form-logic-components/ConditionEditor.client.vue b/client/components/open/forms/components/form-logic-components/ConditionEditor.client.vue index 44ea0b23..02268777 100644 --- a/client/components/open/forms/components/form-logic-components/ConditionEditor.client.vue +++ b/client/components/open/forms/components/form-logic-components/ConditionEditor.client.vue @@ -61,6 +61,7 @@ export default { props: { form: { type: Object, required: true }, modelValue: { type: Object, required: false }, + customValidation: { type: Boolean, default: false }, }, emits: ['update:modelValue'], @@ -79,12 +80,19 @@ export default { .map((property) => { const workspaceId = this.form.workspace_id const formSlug = this.form.slug + const customValidation = this.customValidation return { identifier: property.id, name: property.name, component: (function () { return defineComponent({ extends: ColumnCondition, + props: { + customValidation: { + type: Boolean, + default: customValidation + } + }, computed: { property() { return property diff --git a/client/data/open_filters.json b/client/data/open_filters.json index c1bf7077..7fa27267 100644 --- a/client/data/open_filters.json +++ b/client/data/open_filters.json @@ -66,6 +66,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -136,6 +152,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -206,6 +238,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -276,6 +324,22 @@ "format": { "type": "regex" } + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } }, @@ -362,6 +426,22 @@ }, "content_length_less_than_or_equal_to": { "expected_type": "number" + }, + "exists_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true + }, + "does_not_exist_in_submissions": { + "expected_type": "boolean", + "format": { + "type": "enum", + "values": [true] + }, + "custom_validation_only": true } } },