Групповой выбор в ДП "Выбор нескольких задач из категории" (Multilookup) — SSRM¶
Зачем это нужно¶
Модальное окно Multilookup отображает реестр задач через Server-Side Row Model (SSRM) — ag-grid загружает строки постранично с сервера. В любой момент в браузере присутствует только текущая страница (по умолчанию 100 строк).
Проблема: пользователь раскрывает группу из 800 задач, ставит чекбокс на группе — и ожидает выбрать все 800. Но стандартный API грида видит только 100 загруженных строк и выбирает лишь их.
Решение: не пытаться загрузить все строки на клиент, а зафиксировать намерение пользователя и передать его серверу, который имеет доступ ко всему датасету.
Паттерн работает только в режиме мультиселекта (isMultiselect = true). Одиночный лукап использует стандартный API грида без этой логики.
Архитектура: три слоя¶
┌─────────────────────────────────────────────┐
│ Кеш намерений выбора │ ← состояние на клиенте
│ (группы, переопределения строк, выборы) │
├─────────────────────────────────────────────┤
│ Восстановление состояния в UI │ ← синхронизация кеша с гридом
│ (восстановление чекбоксов при скролле) │
├─────────────────────────────────────────────┤
│ Разрешение на сервере │ ← запрос → бэкенд при сохранении
│ (разворачивание групп в ID задач) │
└─────────────────────────────────────────────┘
Слой 1 — кеш намерений (что запомнил клиент)¶
Вместо списка выбранных ID кеш хранит описание намерения:
| Объект | Что содержит | Зачем |
|---|---|---|
| Запись о группе (Group Selection Entry) | groupKeys (путь в дереве), rowGroupCols, filterModel, sortModel, childCount, тип include/exclude |
Описывает выбранную/снятую группу |
| Переопределение строки (Row Override) | taskId, parentGroupKeys, тип include/exclude |
Индивидуальное переопределение строки внутри группы |
| Отдельные выборы (Individual Selections) | Список ID | Строки, выбранные вне контекста группировки |
Приоритет при определении состояния строки¶
Row Override > Group Selection > Individual Selection > Initial State
- Есть row override для строки → применяем его
- Нет override, но выбрана группа-предок → строка выбрана
- Нет группового контекста → смотрим individual selections и начальное состояние
Инварианты¶
- Выбор группы очищает все row overrides внутри неё (сброс к чистому состоянию)
- Снятие группы происходит только по явному клику пользователя — каскадные события грида игнорируются (см. раздел «Различение событий» ниже)
- Если выбрана родительская группа, дочерние записи не дублируются
Подсчёт выбранных строк¶
count = Σ(childCount для include-групп)
+ количество include row overrides
- количество exclude row overrides
+ количество individual selections
childCount берётся из node.allChildrenCount ag-grid — серверное значение, доступное на group node.
Слой 2 — восстановление состояния (синхронизация интерфейса)¶
Проблема¶
При каждом скролле, фильтрации или раскрытии группы SSRM подгружает новые строки. Их isSelected по умолчанию false. Необходимо восстановить чекбоксы по состоянию из intent cache.
Алгоритм (после каждого fetch)¶
для каждого node в загруженных строках:
override = cache.getRowOverride(node.taskId)
если override.exclude → снять выделение
если override.include → выделить
иначе:
если ancestor-группа выбрана → выделить
если node в individualSelections → выделить
если node в initialState → выделить
иначе → снять выделение (если ошибочно выделен)
Восстановление выполняется через setNodesSelected() API грида.
Подавление событий (флаг isRestoringSelection)¶
setNodesSelected() генерирует onRowSelected события асинхронно (через microtask queue). Без подавления эти события трактуются как пользовательский ввод и искажают intent cache.
Решение — guard-флаг с асинхронным сбросом:
isRestoringSelection = true
setNodesSelected(...)
setTimeout(() => isRestoringSelection = false)
// Синхронный сброс не работает — события ещё не доставлены в момент сброса
Различение событий (пользователь или каскад)¶
ag-grid при снятии чекбокса с дочерней строки каскадно снимает выделение с родительской группы (onRowSelected с isSelected = false). Это не должно очищать группу из intent cache.
Отличие каскадного события от пользовательского клика: каскадное событие не содержит нативного DOM-события event.
если node.group И node.deselected:
если event присутствует → пользователь явно снял группу → deselectGroup()
если event отсутствует → каскад от потомка → игнорировать
Слой 3 — разрешение на сервере (резолв на бэкенде)¶
Состав запроса (payload) при сохранении¶
{
"selectedGroups": [
{
"groupKeys": ["Отдел A"],
"rowGroupCols": [{"id": "dep", "field": "department"}],
"filterModel": {"status": {"type": "equals", "filter": "active"}},
"sortModel": []
}
],
"deselectedGroups": [],
"includeRows": [{"taskId": 42}],
"excludeRows": [{"taskId": 17}],
"selectedTaskIds": [100, 200],
"clearExisting": true
}
Алгоритм резолва (сервер)¶
result = clearExisting ? {} : текущее состояние
для каждой selectedGroup:
ids = executeQuery(groupKeys, filterModel, sortModel) // тот же SSRM-запрос
result += ids
для каждой deselectedGroup:
ids = executeQuery(...)
result -= ids
result += includeRows
result -= excludeRows
result += selectedTaskIds
save(result)
Сервер выполняет тот же запрос, что и грид при отображении этой группы — с теми же фильтрами и группировкой. Это гарантирует согласованность между тем, что видел пользователь, и тем, что записывается.
Запасной вариант: разрешение на клиенте¶
При создании новой сущности (нет серверного ID) server-side resolution недоступен. В этом случае клиент резолвит группы самостоятельно — запрашивает данные через data source API с параметрами группы и применяет exclude/include локально.
Два источника истины в мультиселекте¶
В режиме мультиселекта одновременно работают два независимых хранилища:
| Источник | Что хранит | Используется |
|---|---|---|
selectedItems (список) |
Индивидуально выбранные строки вне групп | Плоский выбор |
selectionCache (кеш намерений) |
Группы + переопределения строк | Выбор с учётом групп |
Итоговый count = selectedItems.length + selectionCache.selectedCount.
При сохранении оба источника сериализуются в единый payload: selectedTaskIds из списка, остальное из кеша.
Набор изначально выбранных записей (Initial State Set)¶
Отдельный Set хранит ID записей, выбранных на момент открытия диалога (загружены с сервера как isSelected: true). Используется для:
- Восстановления чекбоксов при скролле (если запись не в группе и не в individual selections)
- Корректного подсчёта при снятии галки с изначально выбранной записи
Снятие галки с записи из initial state удаляет её из Set и добавляет exclude override в кеш — предотвращает повторное выделение при следующем fetch.
Известные ограничения¶
-
childCount приблизительный —
allChildrenCountна group node может быть неточным при глубокой вложенности. Точный count доступен только после server-side resolution. -
groupKeys содержат display values — ключи группировки берутся из
node.key(отформатированное значение). Если бэкенд ожидает raw-значения, необходим маппинг. -
FilterModel — снимок на момент выбора — filterModel фиксируется при выборе группы. Если пользователь изменит фильтры после, группа резолвится по оригинальным фильтрам. Это намеренное поведение: пользователь выбрал конкретный срез данных.
-
Обновление родительского компонента — после server-side resolution UI обновляется через SignalR/WebSocket. При отсутствии соединения обновление происходит только при перезагрузке страницы.