Conditioned field validation (#418)

* wip: validation condition input

* form custom validation condition

* Default message on form condition validation

* field validation condition test

* fix linting

* update tests,  add pass test

* Polish UI

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Favour Olayinka 2024-05-29 10:40:14 +01:00 committed by GitHub
parent f9dacd0a74
commit 6673dff504
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 236 additions and 5 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\Forms\Form; use App\Models\Forms\Form;
use App\Rules\CustomFieldValidationRule;
use App\Rules\StorageFile; use App\Rules\StorageFile;
use App\Rules\ValidHCaptcha; use App\Rules\ValidHCaptcha;
use App\Rules\ValidPhoneInputRule; use App\Rules\ValidPhoneInputRule;
@ -50,18 +51,17 @@ class AnswerFormRequest extends FormRequest
*/ */
public function rules() public function rules()
{ {
$selectionFields = collect($this->form->properties)->filter(function ($pro) {
return in_array($pro['type'], ['select', 'multi_select']);
});
foreach ($this->form->properties as $property) { foreach ($this->form->properties as $property) {
$rules = []; $rules = [];
/*if (!$this->form->is_pro) { // If not pro then not check logic /*if (!$this->form->is_pro) { // If not pro then not check logic
$property['logic'] = false; $property['logic'] = false;
}*/ }*/
// For get values instead of Id for select/multi select options // For get values instead of Id for select/multi select options
$data = $this->toArray(); $data = $this->toArray();
$selectionFields = collect($this->form->properties)->filter(function ($pro) {
return in_array($pro['type'], ['select', 'multi_select']);
});
foreach ($selectionFields as $field) { foreach ($selectionFields as $field) {
if (isset($data[$field['id']]) && is_array($data[$field['id']])) { if (isset($data[$field['id']]) && is_array($data[$field['id']])) {
$data[$field['id']] = array_map(function ($val) use ($field) { $data[$field['id']] = array_map(function ($val) use ($field) {
@ -87,6 +87,11 @@ class AnswerFormRequest extends FormRequest
$rules[] = 'nullable'; $rules[] = 'nullable';
} }
// User custom validation
if(!(Str::of($property['type'])->startsWith('nf-')) && isset($property['validation'])) {
$rules[] = (new CustomFieldValidationRule($property['validation'], $data));
}
// Clean id to escape "." // Clean id to escape "."
$propertyId = $property['id']; $propertyId = $property['id'];
if (in_array($property['type'], ['multi_select'])) { if (in_array($property['type'], ['multi_select'])) {

View File

@ -0,0 +1,40 @@
<?php
namespace App\Rules;
use App\Service\Forms\FormLogicConditionChecker;
use Illuminate\Contracts\Validation\Rule;
class CustomFieldValidationRule implements Rule
{
/**
* Create a new rule instance.
*
* @return void
*/
public function __construct(public array $validation, public array $formData)
{
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
{
return FormLogicConditionChecker::conditionsMet($this->validation['error_conditions']['conditions'], $this->formData);
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return isset($this->validation['error_message']) ? $this->validation['error_message'] : 'Invalid input';
}
}

View File

@ -0,0 +1,87 @@
<template>
<collapse
v-model="show"
class="p-2 w-full"
>
<template #title>
<h3 class="font-semibold block text-lg">
Validation
</h3>
<p class="text-gray-400 text-xs mb-3">
Add some custom validation (save form before testing)
</p>
</template>
<div class="py-2">
<p class="font-semibold text-sm text-gray-700">
Conditions for this field to be accepted
</p>
<condition-editor
ref="filter-editor"
v-model="validation.conditions"
class="mt-1 border-t border rounded-md mb-3"
:form="form"
/>
<text-input
name="error_message"
class=""
:form="field.validation"
label="Error message when validation fails"
/>
</div>
</collapse>
</template>
<script>
import ConditionEditor from "./form-logic-components/ConditionEditor.client.vue"
import { default as _has } from "lodash/has"
export default {
name: 'FormValidation',
components: {ConditionEditor},
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
}
},
data() {
return {
show: false,
validation: this.field.validation?.error_conditions || {
conditions: null,
actions: [],
},
error_message: this.field.validation?.error_message || ''
}
},
watch: {
logic: {
handler() {
this.field.validation.error_conditions = this.validation
},
deep: true,
},
"field.id": {
handler() {
// On field change, reset validation
this.validation = this.field.validation.error_conditions || {
conditions: null,
actions: [],
}
},
},
},
mounted() {
if (!_has(this.field, "validation")) {
this.field.validation = {
error_conditions: this.validation,
error_message: this.error_message }
}
},
methods:{}
}
</script>

View File

@ -566,6 +566,12 @@
:form="form" :form="form"
:field="field" :field="field"
/> />
<custom-field-validation
class="py-2 px-4 border-b"
:form="form"
:field="field"
/>
</div> </div>
</template> </template>
@ -574,12 +580,13 @@ import timezones from '~/data/timezones.json'
import countryCodes from '~/data/country_codes.json' import countryCodes from '~/data/country_codes.json'
import CountryFlag from 'vue-country-flag-next' import CountryFlag from 'vue-country-flag-next'
import FormBlockLogicEditor from '../../components/form-logic-components/FormBlockLogicEditor.vue' import FormBlockLogicEditor from '../../components/form-logic-components/FormBlockLogicEditor.vue'
import CustomFieldValidation from '../../components/CustomFieldValidation.vue'
import { format } from 'date-fns' import { format } from 'date-fns'
import { default as _has } from 'lodash/has' import { default as _has } from 'lodash/has'
export default { export default {
name: 'FieldOptions', name: 'FieldOptions',
components: { CountryFlag, FormBlockLogicEditor }, components: { CountryFlag, FormBlockLogicEditor, CustomFieldValidation },
props: { props: {
field: { field: {
type: Object, type: Object,

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Models\Forms\Form;
use Tests\Helpers\FormSubmissionDataFactory; use Tests\Helpers\FormSubmissionDataFactory;
it('can answer a form', function () { it('can answer a form', function () {
@ -145,3 +146,94 @@ it('can not submit form with future dates', function () {
'message' => 'The Date must be a date before tomorrow.', 'message' => 'The Date must be a date before tomorrow.',
]); ]);
}); });
it('can submit form with passed custom validation condition', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$targetField = collect($form->properties)->where('name', 'Number')->first();
$condition = [
'actions' => [],
'conditions' => [
'operatorIdentifier' => 'or',
'children' => [
[
'identifier' => $targetField['id'],
'value' => [
'operator' => 'greater_than',
'property_meta' => [
'id' => $targetField['id'],
'type' => 'number',
],
'value' => 20,
],
],
],
],
];
$submissionData = [];
$validationMessage = 'Number too low';
$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']] = 100;
}
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 custom validation condition', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
$targetField = collect($form->properties)->where('name', 'Email')->first();
$condition = [
'actions' => [],
'conditions' => [
'operatorIdentifier' => 'and',
'children' => [
[
'identifier' => $targetField['id'],
'value' => [
'operator' => 'equals',
'property_meta' => [
'id' => $targetField['id'],
'type' => 'email',
],
'value' => 'test@gmail.com',
],
],
],
],
];
$submissionData = [];
$validationMessage = 'Can only use test@gmail.com';
$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']] = 'fail@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,
]);
});