adds validation
This commit is contained in:
@@ -29,7 +29,8 @@
|
||||
"mcp__playwright__browser_wait_for",
|
||||
"WebFetch(domain:www.bakertilly.nl)",
|
||||
"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\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -109,6 +110,8 @@ private function saveAnswers(Session $session, array $answers): void
|
||||
*/
|
||||
private function completeSession(Session $session): RedirectResponse
|
||||
{
|
||||
$this->validateSessionCompletion($session);
|
||||
|
||||
$scoringService = new ScoringService;
|
||||
$score = $scoringService->calculateScore($session);
|
||||
$result = $scoringService->determineResult($score);
|
||||
@@ -125,6 +128,76 @@ private function completeSession(Session $session): RedirectResponse
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({ value: null, text_value: '' }),
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -58,7 +62,10 @@ const updateTextValue = (event) => {
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Text-only question (no radio buttons) -->
|
||||
@@ -105,5 +112,19 @@ const updateTextValue = (event) => {
|
||||
</div>
|
||||
</Transition>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, reactive, ref, watch, nextTick } from 'vue'
|
||||
import { Head, useForm, router } from '@inertiajs/vue3'
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import AppButton from '@/Components/AppButton.vue'
|
||||
@@ -44,6 +44,11 @@ const initializeAnswers = () => {
|
||||
}
|
||||
initializeAnswers()
|
||||
|
||||
// Validation state
|
||||
const validationErrors = ref({})
|
||||
const showErrors = ref(false)
|
||||
const questionRefs = ref({})
|
||||
|
||||
// Save a single answer with partial reload including score
|
||||
let saveTimeout = null
|
||||
const saveAnswer = (questionId) => {
|
||||
@@ -82,9 +87,79 @@ const saveComments = () => {
|
||||
}, 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
|
||||
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
|
||||
router.put(`/sessions/${props.session.id}`, {
|
||||
complete: true,
|
||||
@@ -141,15 +216,21 @@ const hasScoredAnswers = computed(() => {
|
||||
<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]">
|
||||
<QuestionCard
|
||||
<div
|
||||
v-for="question in group.questions"
|
||||
:key="question.id"
|
||||
:ref="el => { if (el) questionRefs[question.id] = el }"
|
||||
:data-question-id="question.id"
|
||||
>
|
||||
<QuestionCard
|
||||
:question="question"
|
||||
:modelValue="answerData[question.id]"
|
||||
:error="showErrors ? validationErrors[question.id] : null"
|
||||
@update:modelValue="updateAnswer(question.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Comments -->
|
||||
<div class="bg-white/[0.03] border border-white/[0.06] rounded-xl p-8">
|
||||
@@ -164,8 +245,24 @@ const hasScoredAnswers = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complete button - now enabled -->
|
||||
<!-- Complete button with validation summary -->
|
||||
<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">
|
||||
<AppButton size="lg" @click="completeSession" data-cy="complete-session">
|
||||
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