Форма, которая знает слишком много: как превратить правила СХД в состояние UI


Меня зовут Виктория Добролежа, как фронтенд-разработчик в «Аэродиск», я создаю интерфейсы для систем хранения данных (далее СХД). В этой статье расскажу, как мы с командой разработали сложную форму, с помощью которой наши пользователи создают дисковые группы, и поделюсь опытом как, имея три разных сценария, мы избежали дублирования состояния и хаоса в обработчиках.
В чем суть формы
В СХД есть сущности – дисковые группы RDG, DDP и DDP2. Именно их пользователю необходимо создать с помощью нашей формы. Каждая сущность создается в своем разделе.
Пользователь может создавать три типа дисковых групп:
RDG — RAID Distributed Group: диски явно раскладываются по RAID-группам заданного размера.
DDP — Dynamic Disk Pool: диски собираются в единый pool, который не раскладывается по RAID-группам.
DDP2 — DDP-группа, которая имеет дополнительные правила конфигурации.

Суть формы в том, что пользователю нужно заполнить поля «Название дисковой группы» и «Тип RAID». Именно тип RAID будет определять то, как будет заполнена форма. В нём и есть её главная фишка и сложность. В зависимости от того, какой тип выбран, будут выстраиваться таблицы с дисками. Они могут принимать вид множества разных конфигураций.

Рассмотрим основные кейсы формы.
Группы, где размер фиксирован
Этот сценарий используется для создания RDG-групп, где диски требуется явно разбить на RAID-группы.
Представим, что пользователь выбрал 10 устройств, а размер группы равен четырём. UI покажет две полные группы и одну неполную.
Неполная группа подсвечивается как группа с ошибкой. Пользователь видит текущее состояние и понимает, чего не хватает для завершения конфигурации.
В упрощённом виде разбиение выглядит так:
function splitIntoGroups(disks: Disk[], size: number): SelectedGroup[] {
return chunk(disks, size).map(group => ({
id: createId(),
disks: group,
isInvalid: group.length < size,
}))
}И всё хорошо работает, если добавлять диски сразу группами. Для этого есть специальная кнопка «Добавить VDEV». Но как решить проблему, если пользователь будет добавлять диски по одному? (Это можно сделать, кликнув на диск в таблице) Нужно ли пересобирать группы в таком случае? Если пользователь добавляет один диск, полное переразбиение может неожиданно изменить расположение уже видимых групп. Формально конфигурация останется той же, но пользователь потеряет ощущение контроля над результатом своего действия.
Это был довольно неочевидный вопрос, который мы в итоге решили так: стратегия работает в два шага:
1. Сначала дополняются существующие неполные группы.
2. Затем оставшиеся диски разбиваются на новые группы.
Этот алгоритм заметно влияет на предсказуемость интерфейса.

Что происходит после изменения RAID
Если даже дополнение текущих групп – это довольно сложный процесс, что же тогда будет происходить при полном изменении RAID, когда группы реально нужно перегруппировать?
Например, пользователь выбрал шесть дисков и получил две группы по три устройства. Затем он поменял RAID, и новый размер группы становится равен не трём, а четырём. Таким образом, старая конфигурация перестаёт соответствовать правилам, и у интерфейса появляются три возможных варианта поведения:
Автоматически перегруппировать диски.
Сохранить текущие группы и показать ошибку.
Сбросить выбор.

Ни один из этих вариантов не является универсальным. Автоматическая перегруппировка может запутать и незаметно изменить конфигурацию. Сохранение старых групп оставляет пользователя в состоянии, которое трудно интерпретировать. Сброс заставляет собирать набор заново.
И из всех трёх вариантов мы выбрали сброс. Почему? Потому что, если меняются фундаментальные параметры группы, то пользователь может запутаться и сконфигурировать дисковую группу неверно.
Так как конфигурация — это по сути основная функциональность этой формы, при изменении RAID будет лучше, если пользователь сформирует конфигурацию заново. Это более предсказуемо, чем молча переставить диски по новым группам.
Не считая дисковые группы, для каждого зависимого поля мы заранее определили политику поведения:
остаются ли введённые данные корректными;
можно ли преобразовать их без потери смысла;
нужно ли предупредить пользователя;
требуется ли полный сброс.
Подобная политика всегда должна быть частью сценария, а не эффектом обработчика change.
Один диск — одна роль
В формах создания дисковых групп один диск может иметь только одну роль. Особенно ярко это видно в форме создания RDG. В ней нельзя одновременно добавить диск данных, диск кэша и диск ускорения — это взаимоисключающие роли в этой форме.

Каждая вкладка получает общее состояние конфигурации и на его основе вычисляет свой список доступных дисков. В упрощённом виде множество занятых устройств выглядит так:
const unavailableIds = computed(() => {
return new Set([
...selectedDataDiskIds.value,
...selectedCacheDiskIds.value,
])
})
const availableDisks = computed(() => {
return allDisks.value.filter(disk => !unavailableIds.value.has(disk.id))
})В реальном коде проекта вкладка исключает из доступного списка диски, занятые в других ролях. Выбор продолжает показываться в правой таблице, даже если эти устройства уже входят в общее множество занятых ID.
Здесь очень к месту оказался Set: он осуществляет быстрый поиск по идентификатору и точно описывает предметное правило. Есть множество уже занятых дисков, и для каждого кандидата нужно проверить принадлежность к этому множеству.
Это решает проблему излишнего обмена данными. Вкладкам не нужно обмениваться командами типа «удалил себя диск X». Они получают одно общее состояние и сами вычисляют допустимые представления. Это снижает количество событий и исключает целый класс ошибок синхронизации.
Единый пул
Для DDP или DDP2 разбиение на группы не требуется. Компонент ведёт себя немного по-другому: все выбранные диски хранятся в одной группе, а он проверяет только общие ограничения:
минимальное количество устройств;
максимальное количество устройств;
совместимость дисков.
Теперь рассмотрим как форма устроена на уровне передачи данных.

Как мы храним состояние
Представим условную модель состояния формы.
type DiskRole = 'data' | 'cache' | 'acceleration'
type SelectedGroup = {
id: string
diskIds: string[]
}
type SelectionState = {
dataGroups: SelectedGroup[]
cacheDiskIds: string[]
accelerationDiskIds: string[]
}Мы сделали так, чтобы из одного состояния можно было получить множество параметров: список занятых дисков, список доступных строк, локальную ошибку неполной группы, а также значение, которое передаётся в поле формы.
Напомним, что в RDG у UI-модели есть границы групп, по ним пользователь видит, какие диски вошли в конкретный VDEV. При этом в API передаётся тот контракт, который ожидает сервер, то есть массив стабильных идентификаторов.
Поток данных в этом сценарии выглядит так:

Очень важно понимать границы ответственности внутри формы: вычисляемые значения отвечают за состояние UI, а адаптер за перевод значений в формат формы.
Компонент выбора диско
Несмотря на то, что у каждой дисковой группы есть своя уникальная логика, для всех подобных форм в проекте используется один компонент выбора дисков. Это помогает сократить количество кода и избежать дублирования похожих компонентов. Но есть и минусы, использование одного компонента может привести к рассинхронизации, если неправильно хранить данные. Например:
диск уже оказался в выбранной группе, но всё ещё виден в таблице с исходными дисками;
диск не был отфильтрован в таблицах ниже, откуда его уже нельзя выбрать;
после удаления устройства обновился счётчик, а суммарный объём остался прежним;
ошибка продолжает отображаться после того, как пользователь уже исправил конфигурацию.
Таким образом, внутри компонента выбора дисков мы оставили только те данные, которые будут переиспользованы во всех похожих случаях.
Например:
какие диски выбраны;
как они распределены по группам;
какой режим активен;
что пользователь ввёл в локальные элементы управления.
Также в данном компоненте можно отключить распределение по группам, так как оно нужно только в одном из сценариев использования этого компонента.
Остальные данные у нас вычисляются на уровень выше, исходя из состояния переиспользуемого компонента. Самой форме достаточно массива стабильных идентификаторов, который можно собрать и передать в API.
Поэтому между компонентом и формой нужен адаптер:
watch(selectedGroups, groups => {
const ids = groups.flatMap(group => group.disks.map(disk => disk.id))
formField.setValue(ids)
})В результате таблица сохраняет удобную UI-модель, а форма получает простое значение. API не зависит от того, как именно компонент показывает группы, в каком порядке пользователь переносил диски и какие строки таблицы были раскрыты.
Для всех форм в проекте мы используем фреймворк Formkit. Все вычисления сходятся в одном файле – конфигурации формы. Там форма использует компонент с выбором дисков, а также узнает необходимую информацию для работы.
Два уровня валидации

В форме с такими сложными и специфическими условиями необходима валидация. Мы, как и общую логику формы, разделили её на два уровня.
Первый уровень валидации находится внутри компонента. Он отвечает на локальные вопросы, например, заполнена ли группа, не превышен ли лимит, можно ли добавить ещё один диск, соответствует ли текущий выбор активной стратегии.
Второй уровень принадлежит всей форме: выбраны ли обязательные диски, совместимы ли устройства из разных категорий, разрешена ли конфигурация для выбранного режима, можно ли уже сейчас отправлять данные.
Мы настроили ошибки в FormKit так, что локальная ошибка внутри компонента выбора дисков становится глобальной блокирующей ошибкой формы. Сообщения показываются рядом с таблицей, где пользователь может исправить проблему, а не только в верхней части формы.
После такого разделения компонент не пытается знать обо всех операциях, а форма не делает вид, что ей достаточно проверить наличие значения в поле.
Как мы оптимизировали сложность модели формы
Часто во фронте под оптимизацией понимают сокращение числа перерисовок. Но для сложной формы не менее важно уменьшить количество состояний и зависимостей, которые разработчик должен удерживать в голове.
Мы стремились к тому, чтобы форма была поддерживаемой, независимо от того, кто из команды возьмётся за дальнейшую работу над ней. Нам помогли три практики.
Тактика использования множества ID, при которой доступность конкретного диска постоянно проверяется по его идентификатору. Set делает эту операцию короткой и читаемой. Словарь ID дисков строится один раз и перестраивается только при изменении зависимостей — это лучше, чем каждый раз обходить все группы в поисках нужного объекта.
Тактика вычисляемых представлений, где статистика, отфильтрованные строки, доступные действия выводятся из одного основного состояния. Их не нужно обновлять вручную после каждого события.
Тактика раннего разделения сценариев. Вместо одной функции с десятками условий, мы решили, что удобнее сначала определить стратегию, а затем вызвать соответствующий алгоритм:
const strategies = {
fixedGroups: groupByFixedSize,
singlePool: groupAsSinglePool,
incremental: fillExistingGroupsFirst,
}После выделения стратегий можно отдельно тестировать сценарии вроде «добавить диск в неполную группу» или «пересобрать конфигурацию после изменения RAID». Для этого не нужно монтировать Vue-компонент и эмулировать работу таблиц.
Когда общий компонент знает слишком много, и куда идти дальше
Переиспользование одного компонента избавило нас от нескольких почти одинаковых таблиц. Но он со временем стал накапливать правила, которые не относятся к его основной задаче:
особенности RAID;
правила динамического пула;
логику кэша;
ограничения разных форм;
синхронизацию с библиотекой форм;
поиск и отображение таблиц.
Проблема развивается постепенно. Сначала общий компонент убирает дублирование. Затем в него начинают попадать доменные правила каждого нового сценария. В какой-то момент добавление режима означает уже не новый параметр, а ещё одно исключение в существующих обработчиках.
В следующей версии решения мы бы разделили ответственность на несколько уровней:
Компонент двух таблиц.
Модель выбранных групп.
Стратегии группировки.
Набор доменных ограничений.
Адаптер к библиотеке форм.
Чистые функции валидации.

Например, публичный контракт доменного слоя мог бы выглядеть так:
type GroupingResult = {
groups: SelectedGroup[]
errors: ValidationError[]
}
interface SelectionStrategy {
add(current: SelectedGroup[], disks: Disk[]): GroupingResult
remove(current: SelectedGroup[], diskId: string): GroupingResult
rebuild(disks: Disk[], constraints: Constraints): GroupingResult
}В этой схеме Vue-компонент в основном отображает состояние и передаёт пользовательские события. Правила СХД, группировка и проверка ограничений остаются в коде, который можно запускать и тестировать без браузера и Vue.
Итоговая модель
Сложность формы, которую мы разобрали в статье, создают не таблицы сами по себе и не библиотека управления состоянием. Сложность в зависимостях между параметрами:
RAID определяет размер группы;
размер группы влияет на валидность выбора;
выбранные диски меняют доступность устройств в других ролях;
одна вкладка ограничивает содержимое другой;
локальная ошибка должна блокировать отправку всей формы.
Для нас рабочей оказалась следующая цепочка:

Пока эта цепочка остаётся явной, даже сложную форму можно развивать без хаоса. Проблемы начинаются, когда правила оказываются спрятаны в обработчиках кликов, случайных watch и локальных исключениях, о которых знают только отдельные части интерфейса.
Что я вынесла как разработчик из опыта создания формы
При проектировании формы я выделила для себя несколько правил, которые отправила бы себе в прошлое, чтобы разработка формы была менее тернистой.
Вместо правил используй ограничения. Сначала нужно определить рамки, например, допустимый размер и состав группы, а затем уже применять операции, такие как добавление, удаление и т.д.
Промежуточно невалидные состояния – это нормально. Пользователю нужно завершить действия и увидеть, что конкретно не так. При этом нельзя отправлять невалидные данные на сервер.
Нужна четкая политика изменения зависимых полей. Сброс, преобразование или сохранение с ошибкой должны быть осознанным решением для каждого параметра.
Локальная и глобальная валидация должны жить раздельно. Компонент знает о составе своих групп, а форма, о том корректна ли вся операция.
Доменные алгоритмы вон из компонентов! Если правила можно проверить без браузера, их нужно оформить как чистую функцию.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.