Валидация ФИО: запрет цифр, обязательность аватара, улучшения JS live validation, фиксы паттернов для отчества
This commit is contained in:
parent
2fbf0e29fc
commit
d370ef678d
@ -1,4 +1,5 @@
|
||||
from collections import defaultdict
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -53,18 +54,73 @@ class RegisterSerializer(serializers.Serializer):
|
||||
whatsapp = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
wechat = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
avatar = Base64ImageField(required=False, allow_null=True)
|
||||
avatar = Base64ImageField(required=True)
|
||||
patronymic = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
gender = serializers.ChoiceField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
choices=((0, "Не указан"), (1, "Мужской"), (2, "Женский")),
|
||||
required=True,
|
||||
choices=((1, "Мужской"), (2, "Женский")),
|
||||
error_messages={
|
||||
'required': 'Поле "Пол" обязательно для заполнения.',
|
||||
'invalid_choice': 'Укажите пол: 1 - Мужской, 2 - Женский.'
|
||||
}
|
||||
)
|
||||
|
||||
position = serializers.IntegerField(required=False, allow_null=True)
|
||||
skills = serializers.ListField(child=serializers.IntegerField(), required=False)
|
||||
|
||||
def validate_first_name(self, value: str) -> str:
|
||||
"""Валидация имени: только буквы, пробелы и дефисы."""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise serializers.ValidationError(
|
||||
"Имя обязательно для заполнения."
|
||||
)
|
||||
if re.search(r'\d', value):
|
||||
raise serializers.ValidationError(
|
||||
"Имя не должно содержать цифры."
|
||||
)
|
||||
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
|
||||
raise serializers.ValidationError(
|
||||
"Имя может содержать только буквы, пробелы, "
|
||||
"дефисы и апострофы."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_last_name(self, value: str) -> str:
|
||||
"""Валидация фамилии: только буквы, пробелы и дефисы."""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise serializers.ValidationError(
|
||||
"Фамилия обязательна для заполнения."
|
||||
)
|
||||
if re.search(r'\d', value):
|
||||
raise serializers.ValidationError(
|
||||
"Фамилия не должна содержать цифры."
|
||||
)
|
||||
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
|
||||
raise serializers.ValidationError(
|
||||
"Фамилия может содержать только буквы, пробелы, "
|
||||
"дефисы и апострофы."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_patronymic(self, value: str) -> str:
|
||||
"""Валидация отчества: только буквы, пробелы и дефисы."""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return value # Отчество опциональное
|
||||
if re.search(r'\d', value):
|
||||
raise serializers.ValidationError(
|
||||
"Отчество не должно содержать цифры."
|
||||
)
|
||||
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
|
||||
raise serializers.ValidationError(
|
||||
"Отчество может содержать только буквы, пробелы, "
|
||||
"дефисы и апострофы."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Глобальная валидация: контакты + телефон (alias) + нормализация E.164.
|
||||
|
||||
|
||||
@ -439,11 +439,10 @@ class RegisterAPIView(APIView):
|
||||
emp.whatsapp = v.get("whatsapp", "")
|
||||
emp.wechat = v.get("wechat", "")
|
||||
emp.birth_date = v["birth_date"]
|
||||
emp.gender = v["gender"] # обязательное поле
|
||||
|
||||
if v.get("patronymic"):
|
||||
emp.patronymic = v["patronymic"]
|
||||
if v.get("gender") is not None:
|
||||
emp.gender = v["gender"]
|
||||
|
||||
pos_id = v.get("position")
|
||||
if pos_id and Position.objects.filter(pk=pos_id).exists():
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django import forms
|
||||
@ -50,10 +51,8 @@ class RegistrationForm(forms.Form):
|
||||
widget=forms.TextInput(attrs={
|
||||
"type": "tel",
|
||||
"inputmode": "numeric",
|
||||
"pattern": r"^\+?\d{10,15}$",
|
||||
"autocomplete": "tel",
|
||||
"data-phone-only": "1",
|
||||
"title": "Введите номер в формате +79991234567 (10–15 цифр)",
|
||||
"title": "Введите номер в формате +79991234567",
|
||||
})
|
||||
)
|
||||
email = forms.EmailField(label="Email")
|
||||
@ -72,11 +71,66 @@ class RegistrationForm(forms.Form):
|
||||
|
||||
# Опциональные поля модели
|
||||
patronymic = forms.CharField(required=False, label="Отчество")
|
||||
gender = forms.IntegerField(required=False, label="Пол (0/1/2)")
|
||||
gender = forms.ChoiceField(
|
||||
required=True,
|
||||
label="Пол",
|
||||
choices=[(1, "Мужской"), (2, "Женский")],
|
||||
widget=forms.RadioSelect,
|
||||
error_messages={
|
||||
'required': 'Необходимо указать пол.',
|
||||
'invalid_choice': 'Выберите Мужской или Женский.',
|
||||
}
|
||||
)
|
||||
position = forms.IntegerField(required=False, label="ID должности")
|
||||
# skills — список ID через запятую
|
||||
skills = forms.CharField(required=False, label="Навыки (ID через запятую)")
|
||||
avatar = forms.ImageField(required=False, label="Аватар")
|
||||
avatar = forms.ImageField(required=True, label="Аватар")
|
||||
|
||||
def clean_first_name(self) -> str:
|
||||
"""Валидация имени: только буквы, пробелы и дефисы."""
|
||||
value = self.cleaned_data.get("first_name", "").strip()
|
||||
if not value:
|
||||
raise forms.ValidationError("Имя обязательно для заполнения.")
|
||||
if re.search(r'\d', value):
|
||||
raise forms.ValidationError(
|
||||
"Имя не должно содержать цифры."
|
||||
)
|
||||
# Разрешаем буквы (любых алфавитов), пробелы, дефисы, апострофы
|
||||
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
|
||||
raise forms.ValidationError(
|
||||
"Имя может содержать только буквы, пробелы, дефисы и апострофы."
|
||||
)
|
||||
return value
|
||||
|
||||
def clean_last_name(self) -> str:
|
||||
"""Валидация фамилии: только буквы, пробелы и дефисы."""
|
||||
value = self.cleaned_data.get("last_name", "").strip()
|
||||
if not value:
|
||||
raise forms.ValidationError("Фамилия обязательна для заполнения.")
|
||||
if re.search(r'\d', value):
|
||||
raise forms.ValidationError(
|
||||
"Фамилия не должна содержать цифры."
|
||||
)
|
||||
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
|
||||
raise forms.ValidationError(
|
||||
"Фамилия может содержать только буквы, пробелы, дефисы и апострофы."
|
||||
)
|
||||
return value
|
||||
|
||||
def clean_patronymic(self) -> str:
|
||||
"""Валидация отчества: только буквы, пробелы и дефисы."""
|
||||
value = self.cleaned_data.get("patronymic", "").strip()
|
||||
if not value:
|
||||
return value # Отчество опциональное
|
||||
if re.search(r'\d', value):
|
||||
raise forms.ValidationError(
|
||||
"Отчество не должно содержать цифры."
|
||||
)
|
||||
if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE):
|
||||
raise forms.ValidationError(
|
||||
"Отчество может содержать только буквы, пробелы, дефисы и апострофы."
|
||||
)
|
||||
return value
|
||||
|
||||
def clean(self) -> Dict[str, Any]:
|
||||
"""Глобальная валидация формы.
|
||||
@ -232,6 +286,7 @@ class RegistrationForm(forms.Form):
|
||||
"email": cd["email"],
|
||||
"birth_date": cd["birth_date"].isoformat(),
|
||||
"password": cd["password1"],
|
||||
"gender": int(cd["gender"]), # обязательное поле
|
||||
"telegram": cd.get("telegram") or "",
|
||||
"whatsapp": cd.get("whatsapp") or "",
|
||||
"wechat": cd.get("wechat") or "",
|
||||
@ -240,8 +295,6 @@ class RegistrationForm(forms.Form):
|
||||
# Доп. поля
|
||||
if cd.get("patronymic"):
|
||||
payload["patronymic"] = cd["patronymic"]
|
||||
if cd.get("gender") is not None:
|
||||
payload["gender"] = cd["gender"]
|
||||
if cd.get("position") is not None:
|
||||
payload["position"] = cd["position"]
|
||||
|
||||
|
||||
475
backend/static/js/components/liveValidationHandler.js
Normal file
475
backend/static/js/components/liveValidationHandler.js
Normal file
@ -0,0 +1,475 @@
|
||||
/**
|
||||
* @module liveValidationHandler
|
||||
* @description Валидация формы в реальном времени с подсветкой ошибок.
|
||||
* Проверяет поля по мере ввода и показывает feedback сразу.
|
||||
*
|
||||
* Использование:
|
||||
* import { initLiveValidation } from './liveValidationHandler.js';
|
||||
*
|
||||
* initLiveValidation({
|
||||
* formSelector: '#registrationForm',
|
||||
* rules: {
|
||||
* email: [
|
||||
* { type: 'required', message: 'Email обязателен' },
|
||||
* { type: 'email', message: 'Введите корректный email' }
|
||||
* ],
|
||||
* phone_number: [
|
||||
* { type: 'required', message: 'Телефон обязателен' },
|
||||
* { type: 'pattern', pattern: /^\+?\d{10,15}$/, message: 'Неверный формат телефона' }
|
||||
* ],
|
||||
* password1: [
|
||||
* { type: 'required', message: 'Пароль обязателен' },
|
||||
* { type: 'minLength', value: 6, message: 'Минимум 6 символов' }
|
||||
* ],
|
||||
* password2: [
|
||||
* { type: 'required', message: 'Подтвердите пароль' },
|
||||
* { type: 'match', field: 'password1', message: 'Пароли не совпадают' }
|
||||
* ],
|
||||
* gender: [
|
||||
* { type: 'required', message: 'Укажите пол' }
|
||||
* ]
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
|
||||
/**
|
||||
* Санитизация телефонного номера: оставляет только цифры и +.
|
||||
* @param {string} value - Исходное значение
|
||||
* @returns {string} Очищенное значение
|
||||
*/
|
||||
function sanitizePhone(value) {
|
||||
value = String(value || '');
|
||||
let output = '';
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i];
|
||||
|
||||
// Цифры пропускаем всегда
|
||||
if (char >= '0' && char <= '9') {
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Плюс только в начале
|
||||
if (char === '+' && output.length === 0) {
|
||||
output = '+';
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидаторы для различных типов проверок
|
||||
*/
|
||||
const validators = {
|
||||
required: (value, input) => {
|
||||
// Специальная обработка для file input
|
||||
if (input && input.type === 'file') {
|
||||
return input.files && input.files.length > 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
return value !== null && value !== undefined && value !== '';
|
||||
},
|
||||
|
||||
email: (value) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value);
|
||||
},
|
||||
|
||||
pattern: (value, pattern) => {
|
||||
return pattern.test(value);
|
||||
},
|
||||
|
||||
minLength: (value, minLen) => {
|
||||
return value.length >= minLen;
|
||||
},
|
||||
|
||||
maxLength: (value, maxLen) => {
|
||||
return value.length <= maxLen;
|
||||
},
|
||||
|
||||
match: (value, formData, fieldName) => {
|
||||
return value === formData[fieldName];
|
||||
},
|
||||
|
||||
// Специальная валидация для контактов (хотя бы один)
|
||||
anyContact: (value, formData, fieldName, fields) => {
|
||||
return fields.some(field => {
|
||||
const val = formData[field];
|
||||
return val && val.trim().length > 0;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Проверяет поле на соответствие правилам
|
||||
* @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля
|
||||
* @param {Array} rules - Массив правил валидации
|
||||
* @param {Object} formData - Данные всей формы
|
||||
* @returns {Object} { valid: boolean, message: string }
|
||||
*/
|
||||
function validateField(input, rules, formData) {
|
||||
if (!rules || rules.length === 0) {
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
const value = input.value;
|
||||
const fieldName = input.name;
|
||||
|
||||
for (const rule of rules) {
|
||||
let isValid = true;
|
||||
|
||||
switch (rule.type) {
|
||||
case 'required':
|
||||
isValid = validators.required(value, input);
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
// Email проверяем только если поле не пустое
|
||||
if (value.trim().length > 0) {
|
||||
isValid = validators.email(value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pattern':
|
||||
// Pattern проверяем только если поле не пустое
|
||||
if (value.trim().length > 0) {
|
||||
isValid = validators.pattern(value, rule.pattern);
|
||||
} else if (rule.required === false) {
|
||||
// Если поле опциональное и пустое - валидно
|
||||
isValid = true;
|
||||
} else {
|
||||
// Если поле обязательное, но пустое - будет проверено правилом required
|
||||
isValid = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'minLength':
|
||||
// MinLength проверяем только если поле не пустое
|
||||
if (value.trim().length > 0) {
|
||||
isValid = validators.minLength(value, rule.value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'maxLength':
|
||||
isValid = validators.maxLength(value, rule.value);
|
||||
break;
|
||||
|
||||
case 'match':
|
||||
isValid = validators.match(value, formData, rule.field);
|
||||
break;
|
||||
|
||||
case 'anyContact':
|
||||
isValid = validators.anyContact(value, formData, fieldName, rule.fields);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Кастомная функция валидации
|
||||
if (typeof rule.validate === 'function') {
|
||||
isValid = rule.validate(value, formData, input);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
return { valid: false, message: rule.message };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, message: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображает feedback для поля
|
||||
* @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля
|
||||
* @param {boolean} valid - Валидно ли поле
|
||||
* @param {string} message - Сообщение об ошибке
|
||||
*/
|
||||
function showFieldFeedback(input, valid, message) {
|
||||
// Очищаем HTML5 custom validity чтобы не конфликтовать
|
||||
input.setCustomValidity('');
|
||||
|
||||
// Удаляем старые классы
|
||||
input.classList.remove('is-valid', 'is-invalid');
|
||||
|
||||
// Ищем существующий feedback или создаём новый
|
||||
let feedbackEl = input.parentElement.querySelector('.invalid-feedback');
|
||||
|
||||
if (!feedbackEl) {
|
||||
feedbackEl = document.createElement('div');
|
||||
feedbackEl.className = 'invalid-feedback';
|
||||
input.parentElement.appendChild(feedbackEl);
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
input.classList.add('is-valid');
|
||||
feedbackEl.textContent = '';
|
||||
feedbackEl.style.display = 'none';
|
||||
} else {
|
||||
input.classList.add('is-invalid');
|
||||
feedbackEl.textContent = message;
|
||||
feedbackEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает feedback для поля
|
||||
* @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля
|
||||
*/
|
||||
function clearFieldFeedback(input) {
|
||||
input.classList.remove('is-valid', 'is-invalid');
|
||||
const feedbackEl = input.parentElement.querySelector('.invalid-feedback');
|
||||
if (feedbackEl) {
|
||||
feedbackEl.textContent = '';
|
||||
feedbackEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает данные формы в объект
|
||||
* @param {HTMLFormElement} form - Элемент формы
|
||||
* @returns {Object} Данные формы
|
||||
*/
|
||||
function getFormData(form) {
|
||||
const formData = {};
|
||||
const elements = form.elements;
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (element.name) {
|
||||
if (element.type === 'radio') {
|
||||
if (element.checked) {
|
||||
formData[element.name] = element.value;
|
||||
}
|
||||
} else if (element.type === 'checkbox') {
|
||||
formData[element.name] = element.checked;
|
||||
} else {
|
||||
formData[element.name] = element.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует live validation для формы
|
||||
* @param {Object} options - Настройки
|
||||
* @param {string} options.formSelector - CSS селектор формы
|
||||
* @param {Object} options.rules - Правила валидации для полей
|
||||
* @param {number} [options.debounce=300] - Задержка перед валидацией (мс)
|
||||
* @param {boolean} [options.validateOnBlur=true] - Валидировать при потере фокуса
|
||||
* @param {boolean} [options.validateOnInput=true] - Валидировать при вводе
|
||||
* @param {boolean} [options.validateOnSubmit=true] - Валидировать при отправке
|
||||
* @returns {Object} API для управления валидацией
|
||||
*/
|
||||
export function initLiveValidation(options = {}) {
|
||||
const {
|
||||
formSelector,
|
||||
rules = {},
|
||||
debounce = 300,
|
||||
validateOnBlur = true,
|
||||
validateOnInput = true,
|
||||
validateOnSubmit = true
|
||||
} = options;
|
||||
|
||||
const form = document.querySelector(formSelector);
|
||||
if (!form) {
|
||||
console.error(`Form not found: ${formSelector}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationTimers = {};
|
||||
const touchedFields = new Set();
|
||||
|
||||
/**
|
||||
* Валидирует конкретное поле
|
||||
* @param {HTMLInputElement|HTMLSelectElement} input
|
||||
*/
|
||||
const validateInput = (input) => {
|
||||
const fieldName = input.name;
|
||||
const fieldRules = rules[fieldName];
|
||||
|
||||
if (!fieldRules) return;
|
||||
|
||||
const formData = getFormData(form);
|
||||
const result = validateField(input, fieldRules, formData);
|
||||
|
||||
showFieldFeedback(input, result.valid, result.message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Валидирует поле с задержкой (debounce)
|
||||
* @param {HTMLInputElement|HTMLSelectElement} input
|
||||
*/
|
||||
const validateInputDebounced = (input) => {
|
||||
const fieldName = input.name;
|
||||
|
||||
// Очищаем предыдущий таймер
|
||||
if (validationTimers[fieldName]) {
|
||||
clearTimeout(validationTimers[fieldName]);
|
||||
}
|
||||
|
||||
// Устанавливаем новый таймер
|
||||
validationTimers[fieldName] = setTimeout(() => {
|
||||
validateInput(input);
|
||||
}, debounce);
|
||||
};
|
||||
|
||||
/**
|
||||
* Обработчик ввода в поле
|
||||
*/
|
||||
const handleInput = (e) => {
|
||||
const input = e.target;
|
||||
const fieldName = input.name;
|
||||
|
||||
// Санитизация телефонных номеров
|
||||
if (input.type === 'tel') {
|
||||
const previousValue = input.value;
|
||||
const sanitized = sanitizePhone(previousValue);
|
||||
|
||||
if (previousValue !== sanitized) {
|
||||
const cursorPosition = input.selectionStart || sanitized.length;
|
||||
const diff = previousValue.length - sanitized.length;
|
||||
|
||||
input.value = sanitized;
|
||||
|
||||
try {
|
||||
const newPosition = Math.max(0, cursorPosition - diff);
|
||||
input.setSelectionRange(newPosition, newPosition);
|
||||
} catch (err) {
|
||||
// Игнорируем ошибки setSelectionRange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateOnInput) return;
|
||||
|
||||
// Валидируем только если поле уже было "тронуто"
|
||||
if (touchedFields.has(fieldName)) {
|
||||
validateInputDebounced(input);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Обработчик потери фокуса
|
||||
*/
|
||||
const handleBlur = (e) => {
|
||||
const input = e.target;
|
||||
const fieldName = input.name;
|
||||
|
||||
// Помечаем поле как "тронутое"
|
||||
touchedFields.add(fieldName);
|
||||
|
||||
if (validateOnBlur && rules[fieldName]) {
|
||||
validateInput(input);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Обработчик отправки формы
|
||||
*/
|
||||
const handleSubmit = (e) => {
|
||||
if (!validateOnSubmit) return;
|
||||
|
||||
let isFormValid = true;
|
||||
const formData = getFormData(form);
|
||||
|
||||
// Валидируем все поля с правилами
|
||||
for (const fieldName in rules) {
|
||||
const input = form.elements[fieldName];
|
||||
if (!input) continue;
|
||||
|
||||
const result = validateField(input, rules[fieldName], formData);
|
||||
showFieldFeedback(input, result.valid, result.message);
|
||||
|
||||
if (!result.valid) {
|
||||
isFormValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Прерываем отправку если есть ошибки
|
||||
if (!isFormValid) {
|
||||
e.preventDefault();
|
||||
|
||||
// Фокусируем первое невалидное поле
|
||||
const firstInvalid = form.querySelector('.is-invalid');
|
||||
if (firstInvalid) {
|
||||
firstInvalid.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Навешиваем обработчики
|
||||
form.addEventListener('input', handleInput);
|
||||
form.addEventListener('blur', handleBlur, true); // true для capture phase
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
|
||||
/**
|
||||
* API для управления валидацией
|
||||
*/
|
||||
return {
|
||||
/**
|
||||
* Валидирует конкретное поле вручную
|
||||
*/
|
||||
validateField: (fieldName) => {
|
||||
const input = form.elements[fieldName];
|
||||
if (input) {
|
||||
touchedFields.add(fieldName);
|
||||
validateInput(input);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Валидирует всю форму
|
||||
*/
|
||||
validateForm: () => {
|
||||
let isValid = true;
|
||||
const formData = getFormData(form);
|
||||
|
||||
for (const fieldName in rules) {
|
||||
const input = form.elements[fieldName];
|
||||
if (!input) continue;
|
||||
|
||||
const result = validateField(input, rules[fieldName], formData);
|
||||
showFieldFeedback(input, result.valid, result.message);
|
||||
|
||||
if (!result.valid) {
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
},
|
||||
|
||||
/**
|
||||
* Очищает валидацию для всех полей
|
||||
*/
|
||||
clearValidation: () => {
|
||||
for (const fieldName in rules) {
|
||||
const input = form.elements[fieldName];
|
||||
if (input) {
|
||||
clearFieldFeedback(input);
|
||||
}
|
||||
}
|
||||
touchedFields.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Удаляет обработчики
|
||||
*/
|
||||
destroy: () => {
|
||||
form.removeEventListener('input', handleInput);
|
||||
form.removeEventListener('blur', handleBlur, true);
|
||||
form.removeEventListener('submit', handleSubmit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Экспорт для совместимости с неModular кодом
|
||||
if (typeof window !== 'undefined') {
|
||||
window.initLiveValidation = initLiveValidation;
|
||||
}
|
||||
@ -29,11 +29,13 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-4 form-floating">
|
||||
<input type="tel"
|
||||
<input type="text"
|
||||
name="username"
|
||||
class="form-control {% if form.username.errors %}is-invalid{% endif %}"
|
||||
id="id_username"
|
||||
placeholder="Номер телефона или email"
|
||||
value="{{ form.username.value|default:'' }}"
|
||||
autocomplete="username"
|
||||
required autofocus>
|
||||
<label for="id_username">Номер телефона или email</label>
|
||||
{% if form.username.errors %}
|
||||
@ -43,21 +45,27 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-floating position-relative">
|
||||
<input type="password"
|
||||
name="password"
|
||||
class="form-control {% if form.password.errors %}is-invalid{% endif %}"
|
||||
id="id_password"
|
||||
placeholder="Пароль"
|
||||
required>
|
||||
<label for="id_password">Пароль</label>
|
||||
<i class="bi bi-eye-slash password-toggle position-absolute top-50 end-0 translate-middle-y me-3"
|
||||
style="cursor: pointer; z-index: 5"></i>
|
||||
{% if form.password.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ form.password.errors|striptags }}
|
||||
<div class="mb-4 position-relative">
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
name="password"
|
||||
class="form-control {% if form.password.errors %}is-invalid{% endif %} pe-5"
|
||||
id="id_password"
|
||||
placeholder="Пароль"
|
||||
autocomplete="current-password"
|
||||
required>
|
||||
<label for="id_password">Пароль</label>
|
||||
{% if form.password.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ form.password.errors|striptags }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="password-toggle position-absolute top-50 end-0 translate-middle-y me-3"
|
||||
data-target="#id_password"
|
||||
style="cursor: pointer; z-index: 10; margin-top: -0.25rem;">
|
||||
<i class="bi bi-eye-slash"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
|
||||
@ -26,7 +26,6 @@
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.email show_label=False placeholder="E-mail" %}
|
||||
<small class="text-muted">Пример: ivanov@company.com</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %}
|
||||
@ -77,21 +76,8 @@
|
||||
{% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %}
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label d-block mb-2">Пол</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="gender" id="gender-m" value="1">
|
||||
<label class="form-check-label" for="gender-m">Мужской</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="gender" id="gender-f" value="2">
|
||||
<label class="form-check-label" for="gender-f">Женский</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="gender" id="gender-u" value="0" checked>
|
||||
<label class="form-check-label" for="gender-u">Не указан</label>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label d-block mb-2">Пол <span class="text-danger">*</span></label>
|
||||
{% bootstrap_field form.gender show_label=False %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -99,14 +85,18 @@
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6 position-relative">
|
||||
{% bootstrap_field form.password1 show_label=False placeholder="Пароль" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
|
||||
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password1.auto_id }}"></i>
|
||||
<span class="password-toggle position-absolute top-50 end-0 translate-middle-y me-3 pe-5"
|
||||
data-target="#{{ form.password1.auto_id }}"
|
||||
style="cursor:pointer; z-index:10;">
|
||||
<i class="bi bi-eye-slash"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6 position-relative">
|
||||
{% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
|
||||
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password2.auto_id }}"></i>
|
||||
<span class="password-toggle position-absolute top-50 end-0 translate-middle-y me-3 pe-5"
|
||||
data-target="#{{ form.password2.auto_id }}"
|
||||
style="cursor:pointer; z-index:10;">
|
||||
<i class="bi bi-eye-slash"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -146,7 +136,7 @@
|
||||
<script type="module">
|
||||
import { initPasswordToggle } from "{% static 'js/components/passwordToggleHandler.js' %}";
|
||||
import { initAvatarPreview } from "{% static 'js/components/avatarPreviewHandler.js' %}";
|
||||
import { initPhoneValidation } from "{% static 'js/components/phoneValidationHandler.js' %}";
|
||||
import { initLiveValidation } from "{% static 'js/components/liveValidationHandler.js' %}";
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Переключение видимости пароля
|
||||
@ -159,8 +149,112 @@
|
||||
placeholderId: 'avatarPlaceholder'
|
||||
});
|
||||
|
||||
// Валидация телефонных номеров
|
||||
initPhoneValidation();
|
||||
// Live validation - валидация в реальном времени
|
||||
const validator = initLiveValidation({
|
||||
formSelector: 'form',
|
||||
debounce: 500, // задержка 500мс после ввода
|
||||
rules: {
|
||||
email: [
|
||||
{ type: 'required', message: 'Email обязателен для заполнения' },
|
||||
{ type: 'email', message: 'Введите корректный email (example@company.com)' }
|
||||
],
|
||||
phone_number: [
|
||||
{ type: 'required', message: 'Номер телефона обязателен' },
|
||||
{
|
||||
type: 'pattern',
|
||||
pattern: /^\+\d{11,14}$/,
|
||||
message: 'Введите номер в формате +79991234567 (11-14 цифр с плюсом)'
|
||||
}
|
||||
],
|
||||
first_name: [
|
||||
{ type: 'required', message: 'Имя обязательно для заполнения' },
|
||||
{ type: 'minLength', value: 2, message: 'Минимум 2 символа' },
|
||||
{
|
||||
type: 'pattern',
|
||||
pattern: /^[^\d]+$/,
|
||||
message: 'Имя не должно содержать цифры'
|
||||
}
|
||||
],
|
||||
last_name: [
|
||||
{ type: 'required', message: 'Фамилия обязательна для заполнения' },
|
||||
{ type: 'minLength', value: 2, message: 'Минимум 2 символа' },
|
||||
{
|
||||
type: 'pattern',
|
||||
pattern: /^[^\d]+$/,
|
||||
message: 'Фамилия не должна содержать цифры'
|
||||
}
|
||||
],
|
||||
patronymic: [
|
||||
{
|
||||
type: 'pattern',
|
||||
pattern: /^[^\d]*$/,
|
||||
message: 'Отчество не должно содержать цифры',
|
||||
required: false // Поле опциональное
|
||||
}
|
||||
],
|
||||
birth_date: [
|
||||
{ type: 'required', message: 'Дата рождения обязательна' },
|
||||
{
|
||||
type: 'custom',
|
||||
validate: (value) => {
|
||||
const date = new Date(value);
|
||||
const today = new Date();
|
||||
const age = today.getFullYear() - date.getFullYear();
|
||||
return age >= 18 && age <= 100;
|
||||
},
|
||||
message: 'Возраст должен быть от 18 до 100 лет'
|
||||
}
|
||||
],
|
||||
gender: [
|
||||
{ type: 'required', message: 'Необходимо указать пол' }
|
||||
],
|
||||
avatar: [
|
||||
{ type: 'required', message: 'Необходимо загрузить фотографию' }
|
||||
],
|
||||
password1: [
|
||||
{ type: 'required', message: 'Пароль обязателен' },
|
||||
{ type: 'minLength', value: 6, message: 'Пароль должен содержать минимум 6 символов' }
|
||||
],
|
||||
password2: [
|
||||
{ type: 'required', message: 'Подтвердите пароль' },
|
||||
{
|
||||
type: 'match',
|
||||
field: 'password1',
|
||||
message: 'Пароли не совпадают'
|
||||
}
|
||||
],
|
||||
// Специальная проверка контактов (хотя бы один)
|
||||
telegram: [
|
||||
{
|
||||
type: 'anyContact',
|
||||
fields: ['telegram', 'whatsapp', 'wechat'],
|
||||
message: 'Заполните хотя бы один мессенджер (Telegram, WhatsApp или WeChat)'
|
||||
}
|
||||
],
|
||||
whatsapp: [
|
||||
{
|
||||
type: 'anyContact',
|
||||
fields: ['telegram', 'whatsapp', 'wechat'],
|
||||
message: 'Заполните хотя бы один мессенджер (Telegram, WhatsApp или WeChat)'
|
||||
}
|
||||
],
|
||||
wechat: [
|
||||
{
|
||||
type: 'anyContact',
|
||||
fields: ['telegram', 'whatsapp', 'wechat'],
|
||||
message: 'Заполните хотя бы один мессенджер (Telegram, WhatsApp или WeChat)'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// При изменении password1 - ревалидируем password2
|
||||
document.querySelector('[name="password1"]')?.addEventListener('input', () => {
|
||||
const password2 = document.querySelector('[name="password2"]');
|
||||
if (password2 && password2.value) {
|
||||
validator.validateField('password2');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user