Перейти к содержанию

Групповой выбор в ДП "Выбор нескольких задач из категории" (Multilookup) — SSRM

Зачем это нужно

Модальное окно Multilookup отображает реестр задач через Server-Side Row Model (SSRM) — ag-grid загружает строки постранично с сервера. В любой момент в браузере присутствует только текущая страница (по умолчанию 100 строк).

Проблема: пользователь раскрывает группу из 800 задач, ставит чекбокс на группе — и ожидает выбрать все 800. Но стандартный API грида видит только 100 загруженных строк и выбирает лишь их.

Решение: не пытаться загрузить все строки на клиент, а зафиксировать намерение пользователя и передать его серверу, который имеет доступ ко всему датасету.

Паттерн работает только в режиме мультиселекта (isMultiselect = true). Одиночный лукап использует стандартный API грида без этой логики.


Архитектура: три слоя

┌─────────────────────────────────────────────┐
│            Selection Intent Cache           │  ← клиентский state
│  (groups, row overrides, individual picks)  │
├─────────────────────────────────────────────┤
│         Visual State Reconciliation         │  ← синхронизация cache → UI грида
│     (восстановление чекбоксов при скролле)  │
├─────────────────────────────────────────────┤
│         Server-Side Resolution              │  ← payload → backend при сохранении
│      (resolve groups → конкретные ID)       │
└─────────────────────────────────────────────┘

Слой 1 — Intent Cache (что запомнил клиент)

Вместо списка выбранных 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
  1. Есть row override для строки → применяем его
  2. Нет override, но выбрана группа-предок → строка выбрана
  3. Нет группового контекста → смотрим individual selections и начальное состояние

Инварианты

  • Выбор группы очищает все row overrides внутри неё (сброс к чистому состоянию)
  • Снятие группы происходит только по явному клику пользователя — каскадные события грида игнорируются (см. Event Discrimination ниже)
  • Если выбрана родительская группа, дочерние записи не дублируются

Подсчёт выбранных строк

count = Σ(childCount для include-групп)
      + количество include row overrides
      - количество exclude row overrides
      + количество individual selections

childCount берётся из node.allChildrenCount ag-grid — серверное значение, доступное на group node.


Слой 2 — Visual State Reconciliation (синхронизация UI)

Проблема

При каждом скролле, фильтрации или раскрытии группы SSRM подгружает новые строки. Их isSelected по умолчанию false. Необходимо восстановить чекбоксы по состоянию из intent cache.

Алгоритм (после каждого fetch)

для каждого node в загруженных строках:
  override = cache.getRowOverride(node.taskId)
  если override.exclude → снять выделение
  если override.include → выделить
  иначе:
    если ancestor-группа выбрана → выделить
    если node в individualSelections → выделить
    если node в initialState → выделить
    иначе → снять выделение (если ошибочно выделен)

Reconciliation выполняется через setNodesSelected() API грида.

Event Suppression (флаг isRestoringSelection)

setNodesSelected() генерирует onRowSelected события асинхронно (через microtask queue). Без подавления эти события трактуются как пользовательский ввод и искажают intent cache.

Решение — guard-флаг с асинхронным сбросом:

isRestoringSelection = true
setNodesSelected(...)
setTimeout(() => isRestoringSelection = false)
// Синхронный сброс не работает — события ещё не доставлены в момент сброса

Event Discrimination (пользователь vs каскад)

ag-grid при снятии чекбокса с дочерней строки каскадно снимает выделение с родительской группы (onRowSelected с isSelected = false). Это не должно очищать группу из intent cache.

Отличие каскадного события от пользовательского клика: каскадное событие не содержит нативного DOM-события event.

если node.group И node.deselected:
  если event присутствует  пользователь явно снял группу  deselectGroup()
  если event отсутствует  каскад от потомка  игнорировать

Слой 3 — Server-Side Resolution (резолв на бэкенде)

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)

Сервер выполняет тот же запрос, что и грид при отображении этой группы — с теми же фильтрами и группировкой. Это гарантирует согласованность между тем, что видел пользователь, и тем, что записывается.

Fallback: Client-Side Resolution

При создании новой сущности (нет серверного ID) server-side resolution недоступен. В этом случае клиент резолвит группы самостоятельно — запрашивает данные через data source API с параметрами группы и применяет exclude/include локально.


Два источника истины в мультиселекте

В режиме мультиселекта одновременно работают два независимых хранилища:

Источник Что хранит Используется
selectedItems (список) Индивидуально выбранные строки вне групп Flat selection
selectionCache (intent) Группы + row overrides Group-aware selection

Итоговый count = selectedItems.length + selectionCache.selectedCount.

При сохранении оба источника сериализуются в единый payload: selectedTaskIds из списка, остальное из кеша.

Initial State Set

Отдельный Set хранит ID записей, выбранных на момент открытия диалога (загружены с сервера как isSelected: true). Используется для:

  • Восстановления чекбоксов при скролле (если запись не в группе и не в individual selections)
  • Корректного подсчёта при снятии галки с изначально выбранной записи

Снятие галки с записи из initial state удаляет её из Set и добавляет exclude override в кеш — предотвращает повторное выделение при следующем fetch.


Известные ограничения

  1. childCount приблизительныйallChildrenCount на group node может быть неточным при глубокой вложенности. Точный count доступен только после server-side resolution.

  2. groupKeys содержат display values — ключи группировки берутся из node.key (отформатированное значение). Если бэкенд ожидает raw-значения, необходим маппинг.

  3. FilterModel — снимок на момент выбора — filterModel фиксируется при выборе группы. Если пользователь изменит фильтры после, группа резолвится по оригинальным фильтрам. Это намеренное поведение: пользователь выбрал конкретный срез данных.

  4. Обновление родительского компонента — после server-side resolution UI обновляется через SignalR/WebSocket. При отсутствии соединения обновление происходит только при перезагрузке страницы.