personnal/ecole/public/js/rc/rc-validation-utils.js

646 lines
18 KiB
JavaScript

(function () {
/**
* Normalise numeric input.
*/
function normalizeNumericInput(raw) {
if (raw == null) return '';
return String(raw).trim().replace(/\s+/g, '');
}
/**
* Parse loose number.
*/
function parseLooseNumber(raw) {
const normalized = normalizeNumericInput(raw).replace(',', '.');
if (!normalized) return NaN;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : NaN;
}
/**
* Formate french amount.
*/
function formatFrenchAmount(value, digits) {
const number = Number(value);
if (!Number.isFinite(number)) return '0.00';
return number.toLocaleString('fr-FR', {
minimumFractionDigits: digits,
maximumFractionDigits: digits
});
}
/**
* Synchronise floating labels.
*/
function syncFloatingLabels(root) {
const scope = root && root.querySelectorAll ? root : document;
const labels = scope.querySelectorAll('.rc-field-label[for]');
labels.forEach(function (label) {
const fieldId = label.getAttribute('for');
if (!fieldId) return;
const field = document.getElementById(fieldId);
if (!field) return;
const inputField = label.closest('.input-field');
const container = inputField || label.parentElement;
if (container) {
container.classList.add('rc-has-floating-label');
}
label.classList.add('rc-floating-label');
if (label.dataset.rcFloatBound === 'true') {
// Déjà lié: on force juste un resync visuel.
const hasValue = String(field.value || '').trim() !== '';
label.classList.toggle('active', hasValue || document.activeElement === field);
return;
}
const hasPrefix = Boolean(inputField && inputField.querySelector('.prefix'));
label.style.left = hasPrefix ? '3rem' : '0.75rem';
const syncState = function () {
const hasValue = String(field.value || '').trim() !== '';
label.classList.toggle('active', hasValue || document.activeElement === field);
};
field.addEventListener('focus', syncState);
field.addEventListener('blur', syncState);
field.addEventListener('input', syncState);
field.addEventListener('change', syncState);
label.dataset.rcFloatBound = 'true';
syncState();
});
if (window.M && typeof window.M.updateTextFields === 'function') {
window.M.updateTextFields();
}
}
/**
* Securise error slot.
*/
function ensureErrorSlot(field, customErrorId) {
if (!field) return null;
if (customErrorId) {
const found = document.getElementById(customErrorId);
if (found) return found;
}
if (field.id) {
const byConvention = document.getElementById(field.id + '-error');
if (byConvention) return byConvention;
}
if (field.dataset && field.dataset.errorTarget) {
const byData = document.getElementById(field.dataset.errorTarget);
if (byData) return byData;
}
let sibling = field.parentElement ? field.parentElement.querySelector('.rc-inline-error') : null;
if (sibling) return sibling;
sibling = document.createElement('span');
sibling.className = 'helper-text red-text rc-inline-error';
sibling.style.display = 'none';
field.insertAdjacentElement('afterend', sibling);
return sibling;
}
/**
* Gere pick field label.
*/
function pickFieldLabel(field, fallbackLabel) {
if (fallbackLabel) return fallbackLabel;
if (!field) return 'Champ';
if (field.dataset && field.dataset.rcLabel) return field.dataset.rcLabel;
const directLabel = field.id ? document.querySelector('label[for="' + field.id + '"]') : null;
if (directLabel && directLabel.textContent.trim()) return directLabel.textContent.trim();
const cardLabel = field.closest('.row, .input-field, td, .card-content')?.querySelector('.rc-field-label');
if (cardLabel && cardLabel.textContent.trim()) return cardLabel.textContent.trim();
const th = field.closest('td')?.parentElement?.querySelector('th');
if (th && th.textContent.trim()) return th.textContent.trim();
return field.name || field.id || 'Champ';
}
/**
* Met a jour field error.
*/
function setFieldError(field, errorSlot, message) {
if (!errorSlot) return;
errorSlot.textContent = message || '';
errorSlot.style.display = message ? 'block' : 'none';
if (field) {
if (message) {
field.classList.add('invalid');
} else {
field.classList.remove('invalid');
}
}
}
/**
* Verifie forbidden numeric chars.
*/
function hasForbiddenNumericChars(value) {
return /[A-Za-z€$£¥]/.test(value);
}
/**
* Gere validate numeric.
*/
function validateNumeric(value, options) {
const raw = normalizeNumericInput(value);
const decimals = Number.isInteger(options.decimals) ? options.decimals : 2;
const label = options.label || 'Champ';
const required = Boolean(options.required);
if (!raw) {
if (required) {
return { valid: false, message: label + ' est obligatoire.' };
}
return { valid: true };
}
if (hasForbiddenNumericChars(raw)) {
return { valid: false, message: label + ' doit contenir uniquement des chiffres, avec virgule ou point décimal.' };
}
if (/[^0-9.,-]/.test(raw)) {
return { valid: false, message: label + ' contient des caractères interdits.' };
}
if ((raw.match(/,/g) || []).length > 1 || (raw.match(/\./g) || []).length > 1) {
return { valid: false, message: label + ' a un format invalide.' };
}
if (raw.includes('-') && raw.indexOf('-') !== 0) {
return { valid: false, message: label + ' a un format invalide.' };
}
const decimalRegex = new RegExp('^-?\\d+(?:[.,]\\d{1,' + decimals + '})?$');
if (!decimalRegex.test(raw)) {
return { valid: false, message: label + ' doit être un nombre valide (max ' + decimals + ' décimales).' };
}
const number = parseLooseNumber(raw);
if (!Number.isFinite(number)) {
return { valid: false, message: label + ' doit être un nombre valide.' };
}
if (typeof options.min === 'number' && number < options.min) {
return { valid: false, message: label + ' doit être supérieur ou égal à ' + options.min + '.' };
}
if (typeof options.max === 'number' && number > options.max) {
return { valid: false, message: label + ' doit être inférieur ou égal à ' + options.max + '.' };
}
if (options.positive && number <= 0) {
return { valid: false, message: label + ' doit être strictement supérieur à 0.' };
}
return { valid: true, normalized: raw.replace(',', '.') };
}
/**
* Gere validate integer.
*/
function validateInteger(value, options) {
const raw = normalizeNumericInput(value);
const label = options.label || 'Champ';
const required = Boolean(options.required);
if (!raw) {
if (required) {
return { valid: false, message: label + ' est obligatoire.' };
}
return { valid: true };
}
if (!/^\d+$/.test(raw)) {
return { valid: false, message: label + ' doit contenir uniquement des chiffres entiers.' };
}
const number = Number(raw);
if (typeof options.min === 'number' && number < options.min) {
return { valid: false, message: label + ' doit être supérieur ou égal à ' + options.min + '.' };
}
if (typeof options.max === 'number' && number > options.max) {
return { valid: false, message: label + ' doit être inférieur ou égal à ' + options.max + '.' };
}
return { valid: true };
}
/**
* Gere validate immat.
*/
function validateImmat(value, options) {
const raw = String(value || '').trim().toUpperCase();
const label = options.label || 'Immatriculation';
if (!raw) {
if (options.required) {
return { valid: false, message: label + ' est obligatoire.' };
}
return { valid: true, normalized: '' };
}
if (!/^[A-Z0-9-]+$/.test(raw)) {
return { valid: false, message: label + ' doit contenir uniquement des lettres majuscules, chiffres et tirets.' };
}
return { valid: true, normalized: raw };
}
/**
* Gere validate text safe.
*/
function validateTextSafe(value, options) {
const raw = String(value || '').trim();
const label = options.label || 'Champ';
if (!raw) {
if (options.required) {
return { valid: false, message: label + ' est obligatoire.' };
}
return { valid: true };
}
if (/[<>;&"]/g.test(raw)) {
return { valid: false, message: label + ' contient des caractères interdits (<, >, &, ;, ").' };
}
const pattern = options.pattern || /^[A-Za-zÀ-ÖØ-öø-ÿ0-9\s'.,:/()\-_]+$/;
if (!pattern.test(raw)) {
return { valid: false, message: label + ' contient des caractères non autorisés.' };
}
return { valid: true };
}
/**
* Gere validate number or consult.
*/
function validateNumberOrConsult(raw, options) {
const value = String(raw || '').trim();
const lower = value.toLowerCase();
if (!value) {
if (options.required) {
return { valid: false, message: (options.label || 'Champ') + ' est obligatoire.' };
}
return { valid: true };
}
if (lower === 'nous consulter') {
return { valid: true };
}
return validateNumeric(value, options);
}
/**
* Gere evaluate rule.
*/
function evaluateRule(field, config) {
const profile = config.profile;
const label = pickFieldLabel(field, config.label);
const required = typeof config.requiredWhen === 'function' ? Boolean(config.requiredWhen(field)) : Boolean(config.required);
let result;
switch (profile) {
case 'numeric':
result = validateNumeric(field.value, {
label: label,
required: required,
decimals: config.decimals,
min: config.min,
max: config.max,
positive: config.positive
});
break;
case 'integer':
result = validateInteger(field.value, {
label: label,
required: required,
min: config.min,
max: config.max
});
break;
case 'immat':
result = validateImmat(field.value, {
label: label,
required: required
});
break;
case 'text':
result = validateTextSafe(field.value, {
label: label,
required: required,
pattern: config.pattern
});
break;
case 'number_or_consulter':
result = validateNumberOrConsult(field.value, {
label: label,
required: required,
decimals: config.decimals,
min: config.min,
max: config.max,
positive: config.positive
});
break;
default:
result = { valid: true };
}
if (result.normalized != null && config.normalize !== false && field.value !== result.normalized) {
field.value = result.normalized;
}
return {
valid: result.valid,
message: result.message || '',
label: label
};
}
/**
* Gere create summary element.
*/
function createSummaryElement(summaryId) {
if (!summaryId) return null;
return document.getElementById(summaryId);
}
/**
* Gere create guard.
*/
function createGuard(options) {
const summary = createSummaryElement(options.summaryId);
const summaryTitle = String(options.summaryTitle || 'Impossible de continuer car :');
const state = {
fieldRules: new Map(),
fieldErrors: new Map(),
externalChecks: new Map(),
targetButtons: [],
onChange: typeof options.onChange === 'function' ? options.onChange : null
};
const targetSelectors = Array.isArray(options.blockTargets) ? options.blockTargets : [];
targetSelectors.forEach(function (selector) {
document.querySelectorAll(selector).forEach(function (button) {
if (!button.dataset.rcOriginalDisabled) {
button.dataset.rcOriginalDisabled = button.disabled ? 'true' : 'false';
}
state.targetButtons.push(button);
});
});
/**
* Gere apply summary.
*/
function applySummary(messages) {
if (!summary) return;
if (!messages.length) {
summary.style.display = 'none';
summary.innerHTML = '';
return;
}
const uniqueMessages = Array.from(new Set(messages));
const list = uniqueMessages.map(function (msg) {
return '<li>' + msg + '</li>';
}).join('');
summary.innerHTML = [
'<div class="rc-blocking-title">' + summaryTitle + '</div>',
'<ul class="rc-blocking-list">',
list,
'</ul>'
].join('');
summary.style.display = 'block';
}
/**
* Gere apply blocking.
*/
function applyBlocking(hasErrors) {
state.targetButtons.forEach(function (button) {
if (!button) return;
if (hasErrors) {
button.disabled = true;
button.dataset.rcInvalid = 'true';
return;
}
if (button.dataset.rcInvalid === 'true') {
button.dataset.rcInvalid = 'false';
button.disabled = button.dataset.rcOriginalDisabled === 'true';
}
});
}
/**
* Gere recompute.
*/
function recompute() {
const messages = [];
state.fieldErrors.forEach(function (error) {
if (error && error.message) {
messages.push(error.message);
}
});
state.externalChecks.forEach(function (entry, key) {
try {
const checkResult = typeof entry.fn === 'function' ? entry.fn() : { valid: true };
if (!checkResult || checkResult.valid === false) {
const message = checkResult && checkResult.message ? checkResult.message : entry.message || key;
messages.push(message);
}
} catch (error) {
console.warn('[RC guard] Check externe en erreur:', key, error);
messages.push(entry.message || ('Validation en erreur: ' + key));
}
});
applySummary(messages);
applyBlocking(messages.length > 0);
if (state.onChange) {
state.onChange(messages);
}
return messages;
}
/**
* Gere validate field.
*/
function validateField(field) {
try {
const config = state.fieldRules.get(field);
if (!config) return;
if (!field || !field.isConnected || field.disabled) {
if (config.errorSlot) setFieldError(field, config.errorSlot, '');
state.fieldErrors.delete(config.key);
recompute();
return;
}
if (typeof config.activeWhen === 'function' && !config.activeWhen(field)) {
setFieldError(field, config.errorSlot, '');
state.fieldErrors.delete(config.key);
recompute();
return;
}
// Sécurité défaut: sans activeWhen explicite, on ignore un champ masqué.
if (typeof config.activeWhen !== 'function' && field.type !== 'hidden' && field.offsetParent === null) {
setFieldError(field, config.errorSlot, '');
state.fieldErrors.delete(config.key);
recompute();
return;
}
const result = evaluateRule(field, config);
setFieldError(field, config.errorSlot, result.valid ? '' : result.message);
if (result.valid) {
state.fieldErrors.delete(config.key);
} else {
state.fieldErrors.set(config.key, {
message: result.message
});
}
recompute();
} catch (error) {
const config = state.fieldRules.get(field);
const key = config && config.key ? config.key : 'champ-inconnu';
console.warn('[RC guard] Validation en erreur:', key, error);
if (config && config.errorSlot) {
setFieldError(field, config.errorSlot, 'Erreur de validation, rechargez la page.');
}
state.fieldErrors.set(key, {
message: 'Erreur de validation sur un champ du formulaire.'
});
recompute();
}
}
/**
* Gere attach field.
*/
function attachField(field, config) {
if (!field) return;
const key = config.key || (field.id ? field.id : (field.name ? field.name : ('field_' + state.fieldRules.size)));
if (field.dataset && field.dataset.rcGuardAttached === 'true' && state.fieldRules.has(field)) return;
const fieldConfig = Object.assign({}, config, {
key: key,
errorSlot: ensureErrorSlot(field, config.errorId)
});
state.fieldRules.set(field, fieldConfig);
if (field.dataset) {
field.dataset.rcGuardAttached = 'true';
}
const handler = function () {
validateField(field);
};
field.addEventListener('input', handler);
field.addEventListener('change', handler);
field.addEventListener('blur', handler);
validateField(field);
}
/**
* Enregistre field.
*/
function registerField(selectorOrElement, config) {
if (!selectorOrElement || !config || !config.profile) return;
if (typeof selectorOrElement === 'string') {
document.querySelectorAll(selectorOrElement).forEach(function (element) {
attachField(element, config);
});
return;
}
attachField(selectorOrElement, config);
}
/**
* Gere observe.
*/
function observe(selector, config) {
if (!selector || !config || !config.profile) return;
registerField(selector, config);
const observer = new MutationObserver(function () {
registerField(selector, config);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
return observer;
}
/**
* Enregistre external.
*/
function registerExternal(key, fn, fallbackMessage) {
if (!key || typeof fn !== 'function') return;
state.externalChecks.set(key, {
fn: fn,
message: fallbackMessage || ''
});
recompute();
}
/**
* Gere refresh.
*/
function refresh() {
state.fieldRules.forEach(function (_cfg, field) {
validateField(field);
});
return recompute();
}
return {
registerField: registerField,
observe: observe,
registerExternal: registerExternal,
refresh: refresh,
formatFrenchAmount: formatFrenchAmount,
parseLooseNumber: parseLooseNumber
};
}
window.RCValidationUtils = {
createGuard: createGuard,
parseLooseNumber: parseLooseNumber,
formatFrenchAmount: formatFrenchAmount,
syncFloatingLabels: syncFloatingLabels
};
})();