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

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

Документ описывает механизм группового выбора в модальном окне Multilookup: зачем он нужен, как устроен на клиенте и на сервере, какие инварианты поддерживаются и какие ограничения есть. Для фронтенд-разработчиков гридов, поддержки и интеграторов. Паттерн применяется только в режиме мультиселекта (isMultiselect = true).

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

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

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

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

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

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

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

┌─────────────────────────────────────────────┐
│            Кеш намерений выбора             │  ← состояние на клиенте
│   (группы, переопределения строк, выборы)   │
├─────────────────────────────────────────────┤
│      Восстановление состояния в UI          │  ← синхронизация кеша с гридом
│     (восстановление чекбоксов при скролле)  │
├─────────────────────────────────────────────┤
│         Разрешение на сервере               │  ← запрос → бэкенд при сохранении
│      (разворачивание групп в ID задач)      │
└─────────────────────────────────────────────┘
flowchart TD
    A["Пользователь ставит чекбокс на группе (например, 800 задач)"] --> B["Кеш намерений: запись о группе (groupKeys, filterModel, sortModel), а не список ID"]
    B --> C["При скролле — восстановление чекбоксов из кеша (setNodesSelected)"]
    B --> D["Сохранение → payload: selectedGroups, include/exclude rows, selectedTaskIds, clearExisting"]
    D --> E{"Есть серверный ID сущности?"}
    E -- "да" --> F["Сервер выполняет тот же SSRM-запрос по группе → разворачивает в ID задач"]
    E -- "нет, новая сущность" --> G["Клиент сам резолвит группы через data source API"]
    F --> H["Применить include/exclude/individual, сохранить; UI обновляется по SignalR"]
    G --> H

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

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, алгоритм резолва и запасной вариант

Состав запроса (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 из списка, остальное из кеша.

Отдельный Set хранит ID записей, выбранных на момент открытия диалога (загружены с сервера как isSelected: true). Этот Initial State Set используется для восстановления чекбоксов при скролле (если запись не в группе и не в 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. При отсутствии соединения обновление происходит только при перезагрузке страницы.