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:
parent
2c746437c9
commit
fba2207e0f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
v-model="validation.conditions"
|
||||
class="mt-1 border-t border rounded-md"
|
||||
:form="form"
|
||||
:custom-validation="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue