Files
alrahma_sunday_school/public/assets/js/validate_student.js
T
2026-02-10 22:11:06 -05:00

612 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /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 (230 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 = '<option value="">Select Grade</option>';
gradeSelect.disabled = true;
gradeLabel.innerHTML = 'Last Year Grade <span class="text-danger">*</span>';
}
}
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 shouldnt 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
});
});
})();