adds validation
This commit is contained in:
@@ -29,7 +29,8 @@
|
|||||||
"mcp__playwright__browser_wait_for",
|
"mcp__playwright__browser_wait_for",
|
||||||
"WebFetch(domain:www.bakertilly.nl)",
|
"WebFetch(domain:www.bakertilly.nl)",
|
||||||
"mcp__playwright__browser_type",
|
"mcp__playwright__browser_type",
|
||||||
"mcp__playwright__browser_hover"
|
"mcp__playwright__browser_hover",
|
||||||
|
"mcp__playwright__browser_evaluate"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -109,6 +110,8 @@ private function saveAnswers(Session $session, array $answers): void
|
|||||||
*/
|
*/
|
||||||
private function completeSession(Session $session): RedirectResponse
|
private function completeSession(Session $session): RedirectResponse
|
||||||
{
|
{
|
||||||
|
$this->validateSessionCompletion($session);
|
||||||
|
|
||||||
$scoringService = new ScoringService;
|
$scoringService = new ScoringService;
|
||||||
$score = $scoringService->calculateScore($session);
|
$score = $scoringService->calculateScore($session);
|
||||||
$result = $scoringService->determineResult($score);
|
$result = $scoringService->determineResult($score);
|
||||||
@@ -125,6 +128,76 @@ private function completeSession(Session $session): RedirectResponse
|
|||||||
return redirect()->route('sessions.result', $session);
|
return redirect()->route('sessions.result', $session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that all required fields are answered before session completion.
|
||||||
|
*/
|
||||||
|
private function validateSessionCompletion(Session $session): void
|
||||||
|
{
|
||||||
|
$session->load(['category.questionGroups.questions', 'answers']);
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($session->category->questionGroups as $questionGroup) {
|
||||||
|
foreach ($questionGroup->questions as $question) {
|
||||||
|
$answer = $session->answers->firstWhere('question_id', $question->id);
|
||||||
|
|
||||||
|
$this->validateRadioAnswer($question, $answer, $errors);
|
||||||
|
$this->validateDetailsAnswer($question, $answer, $errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Arr::exists($errors, 0)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'complete' => $errors,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that radio button questions have an answer selected.
|
||||||
|
*/
|
||||||
|
private function validateRadioAnswer($question, $answer, array &$errors): void
|
||||||
|
{
|
||||||
|
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
|
||||||
|
|
||||||
|
if ($hasRadioButtons && (! $answer || $answer->value === null)) {
|
||||||
|
$errors[] = "Question '{$question->text}' requires an answer.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that questions with required details have text values provided.
|
||||||
|
*/
|
||||||
|
private function validateDetailsAnswer($question, $answer, array &$errors): void
|
||||||
|
{
|
||||||
|
$details = $question->details;
|
||||||
|
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
|
||||||
|
|
||||||
|
if ($details === 'required') {
|
||||||
|
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
|
||||||
|
$errors[] = "Question '{$question->text}' requires details to be provided.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($details === 'req_on_yes' && $answer && $answer->value === 'yes') {
|
||||||
|
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
|
||||||
|
$errors[] = "Question '{$question->text}' requires details when answered 'Yes'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($details === 'req_on_no' && $answer && $answer->value === 'no') {
|
||||||
|
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
|
||||||
|
$errors[] = "Question '{$question->text}' requires details when answered 'No'.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasRadioButtons && $details !== null && $details !== '') {
|
||||||
|
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
|
||||||
|
$errors[] = "Question '{$question->text}' requires a text response.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the final session result.
|
* Display the final session result.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ value: null, text_value: '' }),
|
default: () => ({ value: null, text_value: '' }),
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
@@ -58,7 +62,10 @@ const updateTextValue = (event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="py-5 first:pt-0">
|
<div
|
||||||
|
class="py-5 first:pt-0 transition-all duration-200"
|
||||||
|
:class="{ 'border-l-2 border-red-400/60 pl-4 -ml-4': error }"
|
||||||
|
>
|
||||||
<p class="text-white font-medium leading-relaxed mb-4">{{ question.text }}</p>
|
<p class="text-white font-medium leading-relaxed mb-4">{{ question.text }}</p>
|
||||||
|
|
||||||
<!-- Text-only question (no radio buttons) -->
|
<!-- Text-only question (no radio buttons) -->
|
||||||
@@ -105,5 +112,19 @@ const updateTextValue = (event) => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-1"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 -translate-y-1"
|
||||||
|
>
|
||||||
|
<p v-if="error" class="text-red-400 text-sm mt-2 bg-red-500/10 px-3 py-2 rounded-md">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive } from 'vue'
|
import { computed, reactive, ref, watch, nextTick } from 'vue'
|
||||||
import { Head, useForm, router } from '@inertiajs/vue3'
|
import { Head, useForm, router } from '@inertiajs/vue3'
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||||
import AppButton from '@/Components/AppButton.vue'
|
import AppButton from '@/Components/AppButton.vue'
|
||||||
@@ -44,6 +44,11 @@ const initializeAnswers = () => {
|
|||||||
}
|
}
|
||||||
initializeAnswers()
|
initializeAnswers()
|
||||||
|
|
||||||
|
// Validation state
|
||||||
|
const validationErrors = ref({})
|
||||||
|
const showErrors = ref(false)
|
||||||
|
const questionRefs = ref({})
|
||||||
|
|
||||||
// Save a single answer with partial reload including score
|
// Save a single answer with partial reload including score
|
||||||
let saveTimeout = null
|
let saveTimeout = null
|
||||||
const saveAnswer = (questionId) => {
|
const saveAnswer = (questionId) => {
|
||||||
@@ -82,9 +87,79 @@ const saveComments = () => {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session completion
|
// Validation function
|
||||||
|
const validate = () => {
|
||||||
|
const errors = {}
|
||||||
|
|
||||||
|
props.questionGroups.forEach(group => {
|
||||||
|
group.questions.forEach(question => {
|
||||||
|
const answer = answerData[question.id]
|
||||||
|
const hasRadioButtons = question.has_yes || question.has_no || question.has_na
|
||||||
|
|
||||||
|
// Rule 1: Radio button questions must have a selection
|
||||||
|
if (hasRadioButtons && answer.value === null) {
|
||||||
|
errors[question.id] = 'Please select an answer'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: Required text fields based on details
|
||||||
|
if (question.details === 'required' && !answer.text_value?.trim()) {
|
||||||
|
errors[question.id] = 'Please provide details'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.details === 'req_on_yes' && answer.value === 'yes' && !answer.text_value?.trim()) {
|
||||||
|
errors[question.id] = 'Please provide details'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.details === 'req_on_no' && answer.value === 'no' && !answer.text_value?.trim()) {
|
||||||
|
errors[question.id] = 'Please provide details'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: Text-only questions (no radio buttons, has details)
|
||||||
|
if (!hasRadioButtons && question.details && !answer.text_value?.trim()) {
|
||||||
|
errors[question.id] = 'Please enter a response'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
validationErrors.value = errors
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch answerData for changes and revalidate when errors are showing
|
||||||
|
watch(answerData, () => {
|
||||||
|
if (showErrors.value) {
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Error count for summary banner
|
||||||
|
const errorCount = computed(() => {
|
||||||
|
return Object.values(validationErrors.value).filter(err => err !== null).length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Session completion with validation
|
||||||
let completing = false
|
let completing = false
|
||||||
const completeSession = () => {
|
const completeSession = async () => {
|
||||||
|
showErrors.value = true
|
||||||
|
|
||||||
|
if (!validate()) {
|
||||||
|
// Scroll to first error
|
||||||
|
await nextTick()
|
||||||
|
const firstErrorQuestionId = Object.keys(validationErrors.value)[0]
|
||||||
|
const firstErrorElement = questionRefs.value[firstErrorQuestionId]
|
||||||
|
|
||||||
|
if (firstErrorElement) {
|
||||||
|
firstErrorElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
completing = true
|
completing = true
|
||||||
router.put(`/sessions/${props.session.id}`, {
|
router.put(`/sessions/${props.session.id}`, {
|
||||||
complete: true,
|
complete: true,
|
||||||
@@ -141,13 +216,19 @@ const hasScoredAnswers = computed(() => {
|
|||||||
<p v-if="group.scoring_instructions" class="text-amber-400 text-sm italic mb-4">{{ group.scoring_instructions }}</p>
|
<p v-if="group.scoring_instructions" class="text-amber-400 text-sm italic mb-4">{{ group.scoring_instructions }}</p>
|
||||||
|
|
||||||
<div class="divide-y divide-white/[0.06]">
|
<div class="divide-y divide-white/[0.06]">
|
||||||
<QuestionCard
|
<div
|
||||||
v-for="question in group.questions"
|
v-for="question in group.questions"
|
||||||
:key="question.id"
|
:key="question.id"
|
||||||
:question="question"
|
:ref="el => { if (el) questionRefs[question.id] = el }"
|
||||||
:modelValue="answerData[question.id]"
|
:data-question-id="question.id"
|
||||||
@update:modelValue="updateAnswer(question.id, $event)"
|
>
|
||||||
/>
|
<QuestionCard
|
||||||
|
:question="question"
|
||||||
|
:modelValue="answerData[question.id]"
|
||||||
|
:error="showErrors ? validationErrors[question.id] : null"
|
||||||
|
@update:modelValue="updateAnswer(question.id, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,8 +245,24 @@ const hasScoredAnswers = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Complete button - now enabled -->
|
<!-- Complete button with validation summary -->
|
||||||
<div class="mt-12 pt-8 border-t border-white/[0.06]">
|
<div class="mt-12 pt-8 border-t border-white/[0.06]">
|
||||||
|
<!-- Validation summary banner -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-2"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 -translate-y-2"
|
||||||
|
>
|
||||||
|
<div v-if="showErrors && errorCount > 0" class="bg-red-500/10 border border-red-400/20 rounded-lg px-5 py-4 mb-6">
|
||||||
|
<p class="text-red-400 text-sm font-medium">
|
||||||
|
Please complete all required fields before submitting. {{ errorCount }} {{ errorCount === 1 ? 'question requires' : 'questions require' }} your attention.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<AppButton size="lg" @click="completeSession" data-cy="complete-session">
|
<AppButton size="lg" @click="completeSession" data-cy="complete-session">
|
||||||
Complete
|
Complete
|
||||||
|
|||||||
BIN
validation-banner.png
Normal file
BIN
validation-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
validation-summary.png
Normal file
BIN
validation-summary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Reference in New Issue
Block a user