Кастомные правила валидации

Библиотека Slim Select отвечает за визуальное оформление и управление <select>-элементами, однако полноценная бизнес-валидация почти всегда реализуется отдельно. В сложных интерфейсах стандартного атрибута required недостаточно. Возникают сценарии, где необходимо:

  • запрещать определённые комбинации значений;
  • ограничивать количество выбранных элементов;
  • проверять данные относительно других полей формы;
  • валидировать значения, загруженные с сервера;
  • блокировать временно недоступные опции;
  • проверять данные перед AJAX-отправкой;
  • реализовывать асинхронную проверку.

Кастомные правила позволяют превратить Slim Select в полноценный компонент сложной формы с гибкой логикой поведения.


Базовая схема кастомной валидации

Наиболее распространённая схема включает:

  1. получение выбранных значений;
  2. выполнение набора проверок;
  3. отображение ошибок;
  4. блокировку отправки формы.

Пример базовой структуры:

<form id="user-form">
  <select id="skills" multiple>
    <option value="js">JavaScript</option>
    <option value="ts">TypeScript</option>
    <option value="php">PHP</option>
    <option value="python">Python</option>
  </select>

  <div id="skills-error"></div>

  <button type="submit">Отправить</button>
</form>
const slim = new SlimSelect({
  select: '#skills'
})

const form = document.querySelector('#user-form')
const errorBlock = document.querySelector('#skills-error')

form.addEventListener('submit', (event) => {
  const values = slim.getSelected()

  if (values.length === 0) {
    event.preventDefault()

    errorBlock.textContent = 'Необходимо выбрать минимум один навык'
    return
  }

  errorBlock.textContent = ''
})

Централизация логики проверки

При большом количестве правил валидацию необходимо выносить в отдельные функции.

Выделение функции проверки

function validateSkills(values) {
  const errors = []

  if (values.length === 0) {
    errors.push('Список навыков не может быть пустым')
  }

  if (values.length > 3) {
    errors.push('Максимум 3 навыка')
  }

  return errors
}

Использование:

form.addEventListener('submit', (event) => {
  const values = slim.getSelected()
  const errors = validateSkills(values)

  if (errors.length > 0) {
    event.preventDefault()

    errorBlock.innerHTML = errors.join('<br>')
    return
  }

  errorBlock.textContent = ''
})

Проверка минимального и максимального количества значений

Минимальное количество

function validateMin(values, min) {
  return values.length >= min
}

Применение:

if (!validateMin(values, 2)) {
  errors.push('Нужно выбрать минимум 2 элемента')
}

Максимальное количество

function validateMax(values, max) {
  return values.length <= max
}

Пример:

if (!validateMax(values, 5)) {
  errors.push('Допустимо максимум 5 элементов')
}

Проверка запрещённых комбинаций

Иногда определённые значения несовместимы между собой.

Пример:

function validateForbiddenPairs(values) {
  const forbidden = [
    ['js', 'php'],
    ['python', 'ts']
  ]

  for (const pair of forbidden) {
    const hasAll = pair.every(item => values.includes(item))

    if (hasAll) {
      return false
    }
  }

  return true
}

Использование:

if (!validateForbiddenPairs(values)) {
  errors.push('Обнаружена запрещённая комбинация')
}

Валидация зависимых полей

Часто выбор одного значения влияет на другое поле формы.

Пример зависимости между странами и городами

<select id="country">
  <option value="kz">Казахстан</option>
  <option value="ru">Россия</option>
</select>

<select id="city">
  <option value="karaganda">Караганда</option>
  <option value="almaty">Алматы</option>
  <option value="moscow">Москва</option>
</select>
const countrySelect = new SlimSelect({
  select: '#country'
})

const citySelect = new SlimSelect({
  select: '#city'
})

Правило проверки:

function validateCity(country, city) {
  const allowed = {
    kz: ['karaganda', 'almaty'],
    ru: ['moscow']
  }

  return allowed[country]?.includes(city)
}

Использование:

const country = countrySelect.getSelected()[0]
const city = citySelect.getSelected()[0]

if (!validateCity(country, city)) {
  errors.push('Выбранный город не соответствует стране')
}

Валидация по регулярным выражениям

Slim Select может работать с динамически добавляемыми значениями. Это особенно актуально при использовании тегов.

Проверка формата значения

function validateTag(tag) {
  const regex = /^[a-z0-9-_]{3,20}$/i

  return regex.test(tag)
}

Пример:

const tags = slim.getSelected()

for (const tag of tags) {
  if (!validateTag(tag)) {
    errors.push(`Некорректный тег: ${tag}`)
  }
}

Проверка уникальности значений

В сложных интерфейсах данные могут дублироваться после динамической загрузки.

Проверка массива на уникальность

function validateUnique(values) {
  return new Set(values).size === values.length
}

Пример:

if (!validateUnique(values)) {
  errors.push('Обнаружены дублирующиеся значения')
}

Асинхронная валидация

Иногда требуется проверить данные через сервер.

Например:

  • доступность категории;
  • существование идентификатора;
  • наличие ограничений;
  • валидность токена.

Асинхронная функция проверки

async function validateOnServer(values) {
  const response = await fetch('/api/validate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ values })
  })

  return await response.json()
}

Использование:

form.addEventListener('submit', async (event) => {
  event.preventDefault()

  const values = slim.getSelected()

  const result = await validateOnServer(values)

  if (!result.valid) {
    errorBlock.textContent = result.message
    return
  }

  form.submit()
})

Валидация во время изменения значений

Проверка только при отправке формы создаёт плохой UX. Намного эффективнее валидировать поле сразу после изменения.

Проверка через событие afterChange

const slim = new SlimSelect({
  select: '#skills',
  events: {
    afterChange() {
      validate()
    }
  }
})

Функция:

function validate() {
  const values = slim.getSelected()

  if (values.length === 0) {
    errorBlock.textContent = 'Выберите минимум один элемент'
    return
  }

  errorBlock.textContent = ''
}

Создание системы валидаторов

При большом проекте полезно формировать набор независимых валидаторов.

Массив правил

const validators = [
  validateRequired,
  validateUnique,
  validateForbiddenPairs
]

Обработка:

function runValidators(values) {
  const errors = []

  for (const validator of validators) {
    const result = validator(values)

    if (result !== true) {
      errors.push(result)
    }
  }

  return errors
}

Возврат текста ошибки

function validateRequired(values) {
  if (values.length === 0) {
    return 'Поле обязательно'
  }

  return true
}
function validateUnique(values) {
  if (new Set(values).size !== values.length) {
    return 'Обнаружены дубликаты'
  }

  return true
}

Универсальный валидатор Slim Select

Создание класса

class SlimValidator {
  constructor(slim, errorElement) {
    this.slim = slim
    this.errorElement = errorElement
    this.rules = []
  }

  addRule(rule) {
    this.rules.push(rule)
  }

  validate() {
    const values = this.slim.getSelected()

    for (const rule of this.rules) {
      const result = rule(values)

      if (result !== true) {
        this.showError(result)
        return false
      }
    }

    this.clearError()

    return true
  }

  showError(message) {
    this.errorElement.textContent = message
  }

  clearError() {
    this.errorElement.textContent = ''
  }
}

Использование класса

const validator = new SlimValidator(
  slim,
  document.querySelector('#skills-error')
)

validator.addRule((values) => {
  return values.length > 0 || 'Нужно выбрать минимум один элемент'
})

validator.addRule((values) => {
  return values.length <= 3 || 'Допустимо максимум 3 элемента'
})

Проверка:

form.addEventListener('submit', (event) => {
  if (!validator.validate()) {
    event.preventDefault()
  }
})

Визуальное отображение ошибок

Добавление CSS-класса

function setInvalid(element) {
  element.classList.add('is-invalid')
}

function clearInvalid(element) {
  element.classList.remove('is-invalid')
}

Пример:

if (errors.length > 0) {
  setInvalid(document.querySelector('.ss-main'))
}

Стили ошибок

.is-invalid {
  border: 1px solid #d93025;
}

#error-message {
  margin-top: 5px;
  color: #d93025;
  font-size: 14px;
}

Валидация при динамической загрузке данных

Slim Select часто работает вместе с AJAX.

Проверка загруженных элементов

async function loadOptions() {
  const response = await fetch('/api/options')
  const data = await response.json()

  const validOptions = data.filter(item => {
    return item.active === true
  })

  slim.setData(validOptions)
}

Валидация пользовательских тегов

При использовании режима добавления новых элементов особенно важно фильтровать вводимые данные.

Проверка длины

function validateTagLength(tag) {
  return tag.length <= 15
}

Проверка запрещённых символов

function validateTagSymbols(tag) {
  return !/[<>]/g.test(tag)
}

Комплексная проверка

function validateTag(tag) {
  if (!validateTagLength(tag)) {
    return 'Слишком длинный тег'
  }

  if (!validateTagSymbols(tag)) {
    return 'Недопустимые символы'
  }

  return true
}

Валидация с debounce

При частой проверке через сервер необходимо ограничивать количество запросов.

Реализация debounce

function debounce(callback, delay) {
  let timer

  return (...args) => {
    clearTimeout(timer)

    timer = setTimeout(() => {
      callback(...args)
    }, delay)
  }
}

Использование:

const validateDebounced = debounce(async () => {
  const values = slim.getSelected()

  await validateOnServer(values)
}, 500)

Блокировка недопустимых значений

Иногда вместо вывода ошибки лучше предотвращать выбор значения.

Автоматическое удаление запрещённого элемента

function removeForbidden(values) {
  return values.filter(value => value !== 'admin')
}

Пример:

slim.settings.events.afterChange = () => {
  const values = slim.getSelected()

  slim.setSelected(removeForbidden(values))
}

Валидация перед отправкой AJAX-формы

Полный пример

async function submitForm() {
  const values = slim.getSelected()

  const errors = runValidators(values)

  if (errors.length > 0) {
    errorBlock.innerHTML = errors.join('<br>')
    return
  }

  const response = await fetch('/api/save', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      skills: values
    })
  })

  const result = await response.json()

  console.log(result)
}

Архитектура сложной системы правил

В крупных проектах правила делятся на категории:

Тип правила Назначение
Required Проверка обязательности
Range Проверка диапазонов
Dependency Проверка зависимостей
Async Серверная проверка
Security Фильтрация опасных данных
Business Бизнес-ограничения
UI Проверка пользовательского интерфейса

Типичные ошибки при создании кастомных правил

Проверка только интерфейса

Нельзя ограничиваться исключительно клиентской проверкой. Все правила должны дублироваться на сервере.


Отсутствие очистки ошибок

Частая ошибка — добавление сообщения без удаления предыдущего состояния.

Неправильно:

errorBlock.textContent = 'Ошибка'

Правильно:

errorBlock.textContent = ''

перед новой проверкой.


Смешивание UI и бизнес-логики

Плохой подход:

if (values.length === 0) {
  errorBlock.textContent = 'Ошибка'
  document.body.style.background = 'red'
}

Корректный подход — разделение:

  • валидатор отвечает за проверку;
  • UI отвечает за отображение.

Избыточные серверные запросы

Проверка после каждого изменения без debounce может перегружать API.

Проблемный вариант:

afterChange: async () => {
  await fetch('/validate')
}

Оптимальный вариант — debounce или batch-валидация.


Комбинирование нескольких правил

Комплексный пример

function validate(values) {
  const errors = []

  if (values.length === 0) {
    errors.push('Выберите минимум один элемент')
  }

  if (values.length > 5) {
    errors.push('Максимум 5 элементов')
  }

  if (!validateForbiddenPairs(values)) {
    errors.push('Запрещённая комбинация')
  }

  if (!validateUnique(values)) {
    errors.push('Дублирующиеся значения')
  }

  return errors
}

Использование:

const errors = validate(slim.getSelected())

if (errors.length > 0) {
  errorBlock.innerHTML = errors.join('<br>')
}