Add 'exists_in_submissions' and 'does_not_exist_in_submissions' valid… (#725)

* 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 <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala 2025-03-25 16:20:59 +05:30 committed by GitHub
parent 2c746437c9
commit fba2207e0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 329 additions and 8 deletions

View File

@ -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;

View File

@ -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
}
}
},

View File

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

View File

@ -14,6 +14,7 @@
v-model="validation.conditions"
class="mt-1 border-t border rounded-md"
:form="form"
:custom-validation="true"
/>
</div>

View File

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

View File

@ -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

View File

@ -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
}
}
},