adds validation
This commit is contained in:
@@ -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,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>
|
||||
|
||||
<div class="divide-y divide-white/[0.06]">
|
||||
<QuestionCard
|
||||
<div
|
||||
v-for="question in group.questions"
|
||||
:key="question.id"
|
||||
:question="question"
|
||||
:modelValue="answerData[question.id]"
|
||||
@update:modelValue="updateAnswer(question.id, $event)"
|
||||
/>
|
||||
: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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user