// /assets/js/validate_student.js import { validateNameLive } from './name_validation.js'; import { validateEmailLive } from './email_validation.js'; import { validatePhoneLive } from './phone_validation.js'; import { validateAgeLive } from './age_validation.js'; (function () { document.addEventListener('DOMContentLoaded', function () { var form = document.getElementById('studentRegistrationForm'); if (!form) return; // ---- Polyfills / helpers ---- var CSS_ESCAPE = (window.CSS && typeof window.CSS.escape === 'function') ? window.CSS.escape : function (s) { return String(s).replace(/[^a-zA-Z0-9_\-]/g, '\\$&'); }; function log() { try { console.log.apply(console, arguments); } catch (_) { } } function warn() { try { console.warn.apply(console, arguments); } catch (_) { } } function err() { try { console.error.apply(console, arguments); } catch (_) { } } // ---- Config ---- var MIN_AGE = 5; var MAX_AGE = 18; var maxStudents = parseInt(form.dataset.maxStudents, 10) || 6; var existingStudents = parseInt(form.dataset.existingStudents, 10) || 0; var maxEmergency = parseInt(form.dataset.maxEmergency, 10) || 1; var existingEmergencies = parseInt(form.dataset.existingEmergencies, 10) || 0; // Only allow emergency forms if there are NO registered students in DB var allowEmergency = (existingStudents === 0); // ---- DOM ---- var container = document.getElementById('studentFormsContainer'); var emergencyContainer = document.getElementById('emergencyFormsContainer'); var addStudentBtn = document.getElementById('addStudentBtn'); var addAnotherStudentBtn = document.getElementById('addAnotherStudentBtn'); var doneWithStudentBtn = document.getElementById('doneWithStudentBtn'); var saveFormBtn = document.getElementById('saveFormBtn'); // should be type="submit" var initialButtons = document.getElementById('initialButtons'); var formActionButtons = document.getElementById('formActionButtons'); var studentTmplWrap = document.getElementById('studentFormTemplate'); var contactTmplWrap = document.getElementById('emergencyFormTemplate'); var addedStudents = 0; var addedEmergencies = 0; var isSubmitting = false; // re-entrancy guard // ---- Group validators (at least one required; 'Other' requires text) ---- function ensureFeedback(container, idBase, message) { var fb = container.querySelector('.invalid-feedback[data-for="' + idBase + '"]'); if (!fb) { fb = document.createElement('div'); fb.className = 'invalid-feedback'; fb.setAttribute('data-for', idBase); container.appendChild(fb); } fb.textContent = message || ''; fb.style.display = message ? 'block' : 'none'; return fb; } function validateChecklistRequired(block, baseName, otherInputName, labelText) { var groupWrap = block.querySelector('[data-group="' + baseName + '"]') || block; // optional wrapper var boxes = block.querySelectorAll('input[type="checkbox"][data-base-name="' + baseName + '"]'); if (!boxes.length) return true; var idBase = baseName + '-' + (block.dataset.index || ''); var anyChecked = Array.prototype.some.call(boxes, function (b) { return b.checked; }); var firstBox = boxes[0]; var valid = true; var msg = ''; if (!anyChecked) { valid = false; msg = 'Please select at least one ' + (labelText || baseName.replace('_', ' ')) + '.'; } else { // If "Other" is checked, ensure text provided var otherChecked = Array.prototype.some.call(boxes, function (b) { return /Other$/i.test(b.value) && b.checked; }); if (otherChecked) { var otherInput = block.querySelector( 'input[name="' + otherInputName + '"], input[data-other-for="' + baseName + '"]' ); if (otherInput) { var val = (otherInput.value || '').trim(); if (!val) { valid = false; msg = "Please specify the 'Other' " + (labelText || baseName.replace('_', ' ')) + '.'; // mark 'other' input invalid too otherInput.classList.add('is-invalid'); otherInput.setCustomValidity(msg); } else { otherInput.classList.remove('is-invalid'); otherInput.setCustomValidity(''); } } } } // apply validity to first checkbox (so browser can focus it) if (!valid) { firstBox.classList.add('is-invalid'); firstBox.setCustomValidity(msg); try { firstBox.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) { } } else { boxes.forEach(function (b) { b.classList.remove('is-invalid'); b.setCustomValidity(''); }); var otherInput = block.querySelector( 'input[name="' + otherInputName + '"], input[data-other-for="' + baseName + '"]' ); if (otherInput) { otherInput.classList.remove('is-invalid'); otherInput.setCustomValidity(''); } } // show/hide feedback message ensureFeedback(groupWrap, idBase, msg); return valid; } // ---- Utilities ---- function cloneTemplateContent(wrapper) { var temp = document.createElement('div'); temp.innerHTML = (wrapper && wrapper.innerHTML ? wrapper.innerHTML : '').trim(); return temp.firstElementChild || null; } function showToast(message, type) { type = type || 'success'; var toast = document.createElement('div'); toast.className = 'alert alert-' + type + ' position-fixed top-0 end-0 m-3'; toast.style.zIndex = '9999'; toast.textContent = message; document.body.appendChild(toast); setTimeout(function () { if (toast && toast.parentNode) toast.parentNode.removeChild(toast); }, 3000); } function reindexForms() { var forms = container.querySelectorAll('.student-form'); forms.forEach(function (formEl, index) { formEl.querySelectorAll('[data-base-name]').forEach(function (input) { var base = input.dataset.baseName; if (input.type === 'radio') { input.name = base + '_' + index; input.id = base + '_' + index + '_' + input.value; } else if (input.type === 'checkbox') { input.name = base + '[' + index + '][]'; } else { input.name = base + '[' + index + ']'; } }); }); } function updateButtonStates() { var totalStudents = existingStudents + addedStudents; var remainingStudents = maxStudents - totalStudents; var hasStudentForms = container.querySelectorAll('.student-form').length > 0; // Reset if (initialButtons) initialButtons.classList.add('d-none'); if (formActionButtons) formActionButtons.classList.add('d-none'); if (saveFormBtn) saveFormBtn.classList.add('d-none'); if (doneWithStudentBtn) doneWithStudentBtn.classList.add('d-none'); // 1) no existing + no draft -> show initial Add if (existingStudents === 0 && !hasStudentForms) { if (initialButtons) initialButtons.classList.remove('d-none'); if (addStudentBtn) addStudentBtn.classList.remove('d-none'); return; } // 2) existing students present + no draft -> show "Add Another" if (existingStudents > 0 && !hasStudentForms) { if (formActionButtons) formActionButtons.classList.remove('d-none'); if (addAnotherStudentBtn) { addAnotherStudentBtn.classList.remove('d-none'); var atMax = (totalStudents >= maxStudents); addAnotherStudentBtn.disabled = atMax; addAnotherStudentBtn.title = atMax ? ('Maximum of ' + maxStudents + ' students reached') : ('Add student (' + remainingStudents + ' remaining)'); } return; } // 3) at least one draft student form if (formActionButtons) formActionButtons.classList.remove('d-none'); if (doneWithStudentBtn) doneWithStudentBtn.classList.remove('d-none'); var atMaxStudents = totalStudents >= maxStudents; if (addAnotherStudentBtn) { addAnotherStudentBtn.disabled = atMaxStudents; addAnotherStudentBtn.title = atMaxStudents ? ('Maximum of ' + maxStudents + ' students reached') : ('Add student (' + remainingStudents + ' remaining)'); if (atMaxStudents && !hasStudentForms) { addAnotherStudentBtn.classList.add('d-none'); } } } function initializeLiveValidation(scope) { scope.querySelectorAll('input[data-base-name="studentFirstName"], input[data-base-name="studentLastName"], input[data-base-name="emergency_firstname"], input[data-base-name="emergency_lastname"]').forEach(function (el) { el.addEventListener('input', function () { validateNameLive(el); }); el.title = 'Only letters, spaces, or dashes (2–30 chars)'; }); scope.querySelectorAll('input[data-base-name="emergency_email"]').forEach(function (el) { el.addEventListener('input', function () { validateEmailLive(el); }); el.title = 'Email must be in the format: user@example.com'; }); scope.querySelectorAll('input[data-base-name="emergency_phone"]').forEach(function (el) { el.addEventListener('input', function () { validatePhoneLive(el); }); el.title = 'Phone number must be 10 digits, e.g., 123-456-7890'; }); scope.querySelectorAll('input[data-base-name="dob"]').forEach(function (el) { el.addEventListener('input', function () { validateAgeLive(el, MIN_AGE, MAX_AGE); }); el.title = 'Age must be between ' + MIN_AGE + ' and ' + MAX_AGE; }); wireChecklistLiveValidation(scope); } function validateRadioGroup(scope, baseName) { var radios = scope.querySelectorAll('input[data-base-name="' + baseName + '"][type="radio"]'); if (!radios.length) return true; var names = Array.from(new Set(Array.from(radios).map(function (r) { return r.name; }))); var ok = true; names.forEach(function (n) { var group = scope.querySelectorAll('input[name="' + CSS_ESCAPE(n) + '"]'); var oneChecked = Array.from(group).some(function (r) { return r.checked; }); group.forEach(function (r) { r.setCustomValidity(oneChecked ? '' : 'Please select an option.'); }); ok = ok && oneChecked; }); return ok; } function findFirstInvalid(formEl) { var candidates = formEl.querySelectorAll('input, select, textarea'); for (var i = 0; i < candidates.length; i++) { var el = candidates[i]; if (!el.checkValidity()) return el; } return null; } function setDefaultsForNewStudentForm(formEl) { // Do NOT preselect any radio formEl.querySelectorAll('input[type="radio"][data-base-name="last_year"]').forEach(function (r) { r.checked = false; }); // Keep grade select disabled until a choice is made var gradeSelect = formEl.querySelector('.grade-select'); var gradeLabel = formEl.querySelector('.grade-label'); if (gradeSelect && gradeLabel) { gradeSelect.innerHTML = ''; gradeSelect.disabled = true; gradeLabel.innerHTML = 'Last Year Grade *'; } } function addStudentForm() { if (existingStudents + addedStudents >= maxStudents) { showToast('Maximum of ' + maxStudents + ' students reached', 'info'); updateButtonStates(); return false; } var clone = cloneTemplateContent(studentTmplWrap); if (!clone) return false; // ✅ ensure marker class exists if (!clone.classList.contains('student-form')) clone.classList.add('student-form'); container.classList.remove('d-none'); container.appendChild(clone); addedStudents++; reindexForms(); setDefaultsForNewStudentForm(clone); initializeLiveValidation(clone); updateButtonStates(); return true; } function addEmergencyForm() { if (!allowEmergency) return false; if (existingEmergencies + addedEmergencies >= maxEmergency) return false; var clone = cloneTemplateContent(contactTmplWrap); if (!clone) return false; // ✅ ensure marker class exists if (!clone.classList.contains('emergency-contact-form')) clone.classList.add('emergency-contact-form'); emergencyContainer.classList.remove('d-none'); emergencyContainer.appendChild(clone); addedEmergencies++; initializeLiveValidation(clone); updateButtonStates(); return true; } // Disable emergency inputs that shouldn’t participate in THIS submit function neutralizeEmergencyBlocksForSubmit(formEl, allow) { var blocks = formEl.querySelectorAll('.emergency-contact-form'); var disabledInputs = []; if (!blocks.length) return { disabledInputs: disabledInputs }; if (!allow) { blocks.forEach(function (block) { block.querySelectorAll('input, select, textarea').forEach(function (el) { if (!el.disabled) { el.disabled = true; disabledInputs.push(el); } el.setCustomValidity(''); }); }); return { disabledInputs: disabledInputs }; } // allow === true: disable completely empty blocks so 'required' won't trigger blocks.forEach(function (block) { var fields = block.querySelectorAll('input, select, textarea'); var anyFilled = Array.from(fields).some(function (el) { return (el.value || '').trim().length > 0; }); if (!anyFilled) { fields.forEach(function (el) { if (!el.disabled) { el.disabled = true; disabledInputs.push(el); } el.setCustomValidity(''); }); } }); return { disabledInputs: disabledInputs }; } // ---- Listeners ---- if (addStudentBtn && studentTmplWrap) { addStudentBtn.addEventListener('click', function () { if (addStudentForm()) updateButtonStates(); }); } if (addAnotherStudentBtn && studentTmplWrap) { addAnotherStudentBtn.addEventListener('click', function () { if (addStudentForm()) updateButtonStates(); }); } if (doneWithStudentBtn) { doneWithStudentBtn.addEventListener('click', function () { var hasEmergencyForms = emergencyContainer.querySelectorAll('.emergency-contact-form').length > 0; if (allowEmergency && !hasEmergencyForms) addEmergencyForm(); // first-time family if (saveFormBtn) saveFormBtn.classList.remove('d-none'); doneWithStudentBtn.classList.add('d-none'); }); } // --- Delete student (SAFE: only intercept draft .student-form) --- function handleRemoveStudentClick(e) { const btn = e.target.closest('.js-remove-student'); if (!btn) return; // If the button is NOT inside a draft student form, let native behavior proceed const draftBlock = btn.closest('.student-form'); if (!draftBlock) return; // Draft delete: prevent submit and remove the block e.preventDefault(); e.stopPropagation(); if (draftBlock.parentNode) draftBlock.parentNode.removeChild(draftBlock); addedStudents = Math.max(0, addedStudents - 1); reindexForms(); updateButtonStates(); if (!container.querySelectorAll('.student-form').length) { if (emergencyContainer) { emergencyContainer.innerHTML = ''; addedEmergencies = 0; updateButtonStates(); } } } // Attach listeners (capture = true to beat other handlers) if (container) container.addEventListener('click', handleRemoveStudentClick, true); document.addEventListener('click', handleRemoveStudentClick, true); if (emergencyContainer) { emergencyContainer.addEventListener('click', function (e) { var btn = e.target.closest('.js-remove-contact'); if (!btn) return; var block = btn.closest('.emergency-contact-form'); if (!block) return; block.remove(); addedEmergencies = Math.max(0, addedEmergencies - 1); updateButtonStates(); }); } // Enable grade select only after the last_year radio is chosen container.addEventListener('change', function (e) { var target = e.target; if (target && target.matches('input[type="radio"][data-base-name="last_year"]') && target.checked) { var studentForm = target.closest('.student-form'); if (!studentForm) return; if (typeof window.updateGradeOptions === 'function') { try { window.updateGradeOptions(studentForm, target.value); } catch (ex) { warn('updateGradeOptions error', ex); } } else { var gradeSelect = studentForm.querySelector('.grade-select'); if (gradeSelect) gradeSelect.disabled = false; } } }); // ---- Submit & Validation ---- form.addEventListener('submit', function (e) { try { console.log('submit handler: start', { action: form.action, method: form.method }); if (isSubmitting) { console.warn('submit handler: already submitting — ignored'); e.preventDefault(); return; } // Disable empty/blocked emergency inputs for THIS attempt const { disabledInputs } = neutralizeEmergencyBlocksForSubmit(form, allowEmergency); // ✅ Robust presence detection based on enabled fields inside containers const studentInputs = form.querySelectorAll( '#studentFormsContainer input, #studentFormsContainer select, #studentFormsContainer textarea' ); const emergencyInputs = form.querySelectorAll( '#emergencyFormsContainer input, #emergencyFormsContainer select, #emergencyFormsContainer textarea' ); const hasStudentForms = Array.from(studentInputs).some(el => !el.disabled); const hasEmergencyForms = allowEmergency && Array.from(emergencyInputs).some(el => !el.disabled); if (!hasStudentForms && !hasEmergencyForms) { const proceed = confirm('No student or emergency forms detected. Submit anyway?'); if (!proceed) { e.preventDefault(); // re-enable so user can edit disabledInputs.forEach(el => el.disabled = false); console.warn('submit handler: user canceled — no forms present', { studentInputs: studentInputs.length, studentEnabled: Array.from(studentInputs).filter(el => !el.disabled).length, emergencyInputs: emergencyInputs.length, emergencyEnabled: Array.from(emergencyInputs).filter(el => !el.disabled).length, allowEmergency }); return; } } let allValid = true; // Validate non-emergency inputs (per your existing rules) form.querySelectorAll('input[data-base-name]:not([data-base-name^="emergency_"])').forEach((input) => { const base = input.dataset.baseName; let valid = true; if (base === 'studentFirstName' || base === 'studentLastName') valid = validateNameLive(input); else if (base === 'dob') valid = validateAgeLive(input, MIN_AGE, MAX_AGE); input.setCustomValidity(valid ? '' : 'Please fix this field.'); allValid = allValid && valid; }); // Required selects (skip disabled) form.querySelectorAll('select[required]').forEach((sel) => { if (sel.disabled) { sel.setCustomValidity(''); return; } const ok = sel.value !== ''; sel.setCustomValidity(ok ? '' : 'Please choose an option.'); allValid = allValid && ok; }); // Radio groups per student (e.g., last_year) form.querySelectorAll('.student-form').forEach((sf) => { const radiosOk = validateRadioGroup(sf, 'last_year'); allValid = allValid && radiosOk; }); // ---- Validate allergies & medical conditions for each student ---- let groupOk = true; form.querySelectorAll('.student-form').forEach(function (block, idx) { block.dataset.index = String(idx); // used for feedback id base const ok1 = validateChecklistRequired(block, 'allergies', 'allergy_other[]', 'allergy'); const ok2 = validateChecklistRequired(block, 'medical_conditions', 'medical_condition_other[]', 'medical condition'); if (!ok1 || !ok2) groupOk = false; }); allValid = allValid && groupOk; // Emergency (allowed + non-empty blocks only) if (allowEmergency) { form.querySelectorAll('.emergency-contact-form').forEach(block => { const anyEnabled = block.querySelector('input:not(:disabled), select:not(:disabled), textarea:not(:disabled)'); if (!anyEnabled) return; // empty block was disabled above block.querySelectorAll('input[data-base-name]').forEach(input => { const base = input.dataset.baseName; let valid = true; if (base === 'emergency_firstname' || base === 'emergency_lastname') valid = validateNameLive(input); else if (base === 'emergency_email') valid = validateEmailLive(input); else if (base === 'emergency_phone') valid = validatePhoneLive(input); input.setCustomValidity(valid ? '' : 'Please fix this field.'); allValid = allValid && valid; }); }); } // Final check if (!form.checkValidity() || !allValid) { e.preventDefault(); const firstBad = (function findFirstInvalid(formEl) { const nodes = formEl.querySelectorAll('input, select, textarea'); for (const el of nodes) { if (!el.checkValidity()) return el; } return null; })(form); if (firstBad) { console.warn('First invalid:', firstBad, firstBad.name, firstBad.dataset && firstBad.dataset.baseName, firstBad.validationMessage); try { firstBad.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) { } } form.reportValidity(); disabledInputs.forEach(el => el.disabled = false); // keep editable console.warn('submit handler: blocked — invalid'); return; } // ✅ SUCCESS: post once, outside this tick (bypass submit handlers) e.preventDefault(); isSubmitting = true; console.log('submit handler: valid — posting via form.submit()'); setTimeout(function () { form.submit(); }, 0); } catch (ex) { console.error('submit handler: exception', ex); try { e.preventDefault(); } catch (_) { } } }); function revalidateChecklistGroup(el) { const block = el.closest('.student-form'); if (!block) return; // Ensure stable id base if (!block.dataset.index) { const list = Array.from(block.parentNode.querySelectorAll('.student-form')); block.dataset.index = String(list.indexOf(block)); } // Re-validate both groups (safe + simple) validateChecklistRequired(block, 'allergies', 'allergy_other[]', 'allergy'); validateChecklistRequired(block, 'medical_conditions', 'medical_condition_other[]', 'medical condition'); } function wireChecklistLiveValidation(scope) { // Check/uncheck boxes scope.addEventListener('change', function (e) { const t = e.target; if (!t) return; if (t.matches('input[type="checkbox"][data-base-name="allergies"]') || t.matches('input[type="checkbox"][data-base-name="medical_conditions"]')) { revalidateChecklistGroup(t); } }); // Typing in the "Other" input scope.addEventListener('input', function (e) { const t = e.target; if (!t) return; if (t.matches('input[name="allergy_other[]"], input[name="medical_condition_other[]"], input[data-other-for="allergies"], input[data-other-for="medical_conditions"]')) { revalidateChecklistGroup(t); } }); } // ---- Init ---- initializeLiveValidation(form); wireChecklistLiveValidation(form); updateButtonStates(); // If server repopulated radios, reflect in grade select container.querySelectorAll('.student-form').forEach(function (sf) { var checked = sf.querySelector('input[type="radio"][data-base-name="last_year"]:checked'); if (checked && typeof window.updateGradeOptions === 'function') { try { window.updateGradeOptions(sf, checked.value); } catch (ex) { warn('updateGradeOptions init error', ex); } } }); log('validate_student.js loaded', { maxStudents: maxStudents, existingStudents: existingStudents, maxEmergency: maxEmergency, existingEmergencies: existingEmergencies, allowEmergency: allowEmergency }); }); })();