Групповой выбор в ДП "Выбор нескольких задач из категории" (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
- Есть 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 грида.
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 запрашиваются данные выбранных групп и, отдельным запросом по списку идентификаторов, задачи, отмеченные по одной (selectedTaskIds вместе с includeRows). Результаты объединяются с отбрасыванием дублей по задаче, после чего применяются excludeRows. Поэтому снятие галочки с отдельной строки убирает из выборки только эту строку — даже когда ни одна группа целиком не выбрана и весь выбор состоит из индивидуально отмеченных задач.
Где живёт клиентский резолв¶
Клиентское разрешение выборки описано одним методом — resolveGroupSelectionLocally сервиса ExtParamMultiselectSubcatTasksLogicService (файл ext-param-multiselect-subcat-tasks-logic.service.ts в папке компонента components/task/ui/ext-fields/ext-param-multiselect-subcat-tasks/). Это единственная реализация: и обычный мультилукап, и его табличный вид вызывают её, а не держат свои копии.
| Место вызова | Что идёт первым аргументом |
|---|---|
ext-param-multiselect-subcat-tasks.component.ts — обычный мультилукап |
this.epId |
multilookup-table/multilookup-table.component.ts — табличный вид |
this.extParamId |
Оба вызова стоят в ветке «задача ещё не создана»: пока у задачи нет идентификатора на сервере, сохранить выбор и развернуть его сервером нельзя, поэтому окно собирает строки до создания задачи. Идентификатор параметра передаётся аргументом — поэтому разница в названии поля (epId против extParamId) остаётся на стороне компонента и в сервис не попадает.
Контракт метода типизирован:
resolveGroupSelectionLocally(
extParamId: number,
payload: IMultiselectApplyPayload
): Observable<ITasksDataSourceResponse[]>
Что происходит внутри, по шагам:
- по каждой записи из
selectedGroupsвыполняется запрос данных источника (dataSourceRows('lookup', extParamId, …)) с еёgroupKeys,rowGroupCols,filterModelиsortModel— тем же запросом, что и грид при раскрытии группы; selectedTaskIdsи строки изincludeRowsсводятся в один набор; если набор непуст, к запросам добавляется ещё один — по этому списку, фильтромtaskIdтипаset;- ответы всех запросов склеиваются в карту по
taskId, поэтому дублей не остаётся; строки безtaskIdи строки изexcludeRowsотбрасываются; - если в payload нет ни групп, ни отмеченных задач, метод возвращает пустой массив и не делает ни одного запроса.
Проверки лежат рядом, в ext-param-multiselect-subcat-tasks-logic.service.spec.ts: пустой payload без запросов; склейка групп и отдельных отметок с дедупликацией и отбрасыванием excludeRows; загрузка только selectedTaskIds, когда групп нет. При изменении этой логики правка вносится в сервис и его тест — обе точки вызова остаются как были.
Ограничение для доработок. На одно разрешение выборки уходит столько запросов к источнику, сколько групп в payload, плюс один запрос на отдельные отметки; переиспользования потоков и кеша нет. Стоит ли это оптимизировать — открытый вопрос, отдельной задачей пока не заведён.
Серверная группировка по полю-маркеру «Сохраненные»¶
Строки окна выбора можно сгруппировать по синтетическому полю Сохраненные. Такого поля в источнике данных нет: его обрабатывает отдельная ветка серверной группировки, которая делит строки на две группы — «Сохраненные значения» и «Доступно для выбора». Первая группа всегда идёт выше.
Признак считается по фактически сохранённому в параметре набору: сервер читает связи ExtParamValueSelectedTask по дополнительному параметру и задаче из контекста грида, а не по текущим отметкам в окне. Поэтому группы не переставляются вслед за галками — строка переходит в «Сохраненные значения» только после сохранения параметра.
Граничные случаи. Если сохранённых значений нет, условие строится как заведомо ложное сравнение по колонке задачи, а не как константа: на MS SQL выражение группировки обязано ссылаться на колонку. На PostgreSQL результат приводится к varchar, потому что выражение со строковыми литералами получает тип text, а процедура ожидает varchar.
В окне выбора поле представлено колонкой «Выбор» — она скрыта по умолчанию и доступна в выборе столбцов. Сортировка и фильтр по ней не поддерживаются: поле работает только как группировка.
Поведение на форме создания новой задачи¶
При открытии окна выбора на форме создания задачи, когда идентификатора задачи ещё нет в контексте грида, сервер определяет контекст синтетической группировки по типу грида и идентификатору параметра, не требуя обязательного наличия TaskId. Набор ранее сохранённых задач считается пустым без обращения к базе данных, поэтому все доступные для выбора строки попадают в группу «Доступно для выбора». Синтетическое поле в этом случае не прорастает в фильтрацию как физическая колонка — иначе запрос падал бы на отсутствующей колонке.
Фильтр раскрытой группы¶
Раскрытие группы — отдельный запрос строк: клиент присылает ключ группы (groupKeys) и колонки группировки (rowGroupCols). Для обычной колонки сервер строит текстовое условие equals по её имени. Для поля-маркера «Сохраненные» так делать нельзя: колонки с таким именем в выборке нет, и условие по ней роняет запрос строк.
Поэтому фильтр раскрытой группы по полю-маркеру строится по номеру задачи — тем же набором, которым построена сама группа:
| Ключ группы | Условие на строки |
|---|---|
| «Сохраненные значения» | номера задач входят в сохранённый набор |
| «Доступно для выбора» | номера задач не входят в сохранённый набор |
| любой другой ключ | пустая группа |
Что из этого следует при отладке:
- Счётчик группы и состав её строк совпадают по построению. Набор читает один и тот же загрузчик, поэтому группа и её раскрытие не могут разойтись: после сохранения параметра и повторного открытия окна обе части смотрят на новый набор.
- Пустой сохранённый набор запрос не ломает. Для него условие строится как заведомо ложное сравнение по колонке задачи (
is null/is not null), а не как пустой список номеров. - Предел размера набора (базово 1000) к серверному набору не применяется. Он ограничивает только клиентский отбор по номерам задач; серверный набор уже целиком стоит в выражении группы, и повторная проверка отказывала бы ровно там, где группа построена. Признак «набор загружен сервером» из тела запроса не подделывается — его выставляет только сервер.
- Чужой ключ даёт пустую группу, а не ошибку. Такой запрос раньше отвечал отказом.
- Число ключей группы не может превысить число колонок группировки. Лишние ключи отбрасываются: раньше на этом был выход за границу списка.
Условие контекста. Ветка работает только для грида, открытого из карточки задачи: тип источника — «Лукап», в контексте есть идентификатор задачи и идентификатор параметра. В форме создания задачи задачи ещё нет, поле-маркер не распознаётся, и группировка по «Сохранённые» там не поддержана.
Два источника истины в мультиселекте¶
В режиме мультиселекта одновременно работают два независимых хранилища:
| Источник | Что хранит | Используется |
|---|---|---|
selectedItems (список) |
Индивидуально выбранные строки вне групп | Плоский выбор |
selectionCache (кеш намерений) |
Группы + переопределения строк | Выбор с учётом групп |
Итоговый count = selectedItems.length + selectionCache.selectedCount.
При сохранении оба источника сериализуются в единый payload: selectedTaskIds из списка, остальное из кеша.
Отдельный Set хранит ID записей, выбранных на момент открытия диалога (загружены с сервера как isSelected: true). Этот Initial State Set используется для восстановления чекбоксов при скролле (если запись не в группе и не в 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. При отсутствии соединения обновление происходит только при перезагрузке страницы.