Переработка списков (#5)
Tests / frontend-test (push) Successful in 1m25s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
2026-08-11 04:23:05 +00:00
parent dcf3f06121
commit 70450aa66d
34 changed files with 2131 additions and 382 deletions
@@ -40,7 +40,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
// ключи блока ДО перестановки: после неё индексы уже другие.
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
const registerRow = useFlipRows(activeKeys);
const registerRow = useFlipRows(activeKeys, rowKeys);
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
@@ -12,6 +12,16 @@
white-space: nowrap;
}
// Компактный вариант — для ячейки таблицы (RequestsTableView/SearchView),
// повторяет размеры, что были у их собственной .status до объединения.
.sm {
padding: 3px 10px;
border-radius: $r-md;
font-size: $fs-caption;
font-weight: 600;
letter-spacing: normal;
}
.draft {
background: $gray-100;
color: $gray-700;
@@ -16,7 +16,25 @@ const VARIANT: Record<string, string> = {
[REQUEST_STATUS.closed]: styles.closed,
};
/** Цветная плашка статуса заявки (шапка формы / просмотра). */
export const StatusBadge: React.FC<{ status: string }> = ({ status }) => (
<span className={`${styles.badge} ${VARIANT[status] || styles.draft}`}>{status}</span>
export interface IStatusBadgeProps {
status: string;
/**
* 'md' (по умолчанию) — шапка формы/просмотра заявки; 'sm' — компактный
* вариант для ячейки таблицы (RequestsTableView/SearchView). Цвет статуса
* не зависит от размера — единственный источник цвета: VARIANT выше.
*/
size?: 'sm' | 'md';
}
/**
* Цветная плашка статуса заявки — ЕДИНСТВЕННОЕ место, где статус превращается
* в цвет. У таблиц раньше была своя копия этой раскраски (statusClass в
* RequestsTableView.tsx/SearchView.tsx) — она разошлась с этой (на согласовании
* и возвращена на доработку оказались перепутаны местами), поэтому обе копии
* убраны в пользу общего компонента.
*/
export const StatusBadge: React.FC<IStatusBadgeProps> = ({ status, size = 'md' }) => (
<span className={`${styles.badge} ${size === 'sm' ? styles.sm : ''} ${VARIANT[status] || styles.draft}`}>
{status}
</span>
);
@@ -67,19 +67,60 @@ function drop(row: HTMLTableRowElement): void {
});
}
/**
* Положение строк ОТНОСИТЕЛЬНО их <tbody>, а не окна.
*
* Это принципиально: getBoundingClientRect() отдаёт координату относительно
* ОКНА, а прокрутка страницы не вызывает ре-рендер — значит, запомненные с
* прошлого рендера позиции остаются от прежнего положения прокрутки. Стоит
* пользователю прокрутить страницу к маршруту и нажать стрелку, как в сдвиг
* попадает вся дельта прокрутки (сотни пикселей), и строки улетают за пределы
* таблицы. Именно так выглядел баг «первое нажатие ломает, дальше нормально»:
* повторные клики уже сравнивались с позициями, замеренными при текущей
* прокрутке, поэтому шли правильно.
*
* Относительная база от этого избавляет: и строка, и <tbody> замеряются в
* одном кадре, поэтому прокрутка сокращается. Заодно это чинит и случай, когда
* блок маршрута целиком уезжает из-за изменений НАД ним (например, свернулся
* блок вложений): tbody уезжает вместе со строками, и мнимого сдвига нет.
*/
function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, number> {
const tops = new Map<string, number>();
let firstRow: HTMLTableRowElement | undefined;
nodes.forEach((el) => {
if (!firstRow) firstRow = el;
});
if (!firstRow) return tops;
const container = (firstRow as HTMLTableRowElement).parentElement;
const base = container ? container.getBoundingClientRect().top : 0;
nodes.forEach((el, key) => tops.set(key, el.getBoundingClientRect().top - base));
return tops;
}
/**
* Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>.
* @param activeKeys ключи строк того шага, который двигает пользователь —
* только он подсвечивается и поднимается над остальными (см. ApprovalMatrix).
* Для прочих изменений состава (добавление/удаление) — пустой массив.
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
* которому решаем, действительно ли что-то переставили (см. ниже, почему
* геометрии для этого недостаточно).
*/
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
export function useFlipRows(activeKeys: string[]): (key: string) => (el: HTMLTableRowElement | null) => void {
export function useFlipRows(
activeKeys: string[],
rowKeys: string[]
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
): (key: string) => (el: HTMLTableRowElement | null) => void {
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
// Колбэк на один и тот же ключ должен быть стабильным, иначе React дёргал бы
// ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую.
const refCache = React.useRef(new Map<string, (el: HTMLTableRowElement | null) => void>());
const prevTops = React.useRef(new Map<string, number>());
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
const prevRowKeys = React.useRef<string[]>([]);
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
// вернул бы заодно чужие (CSS-переходы и т.п.).
const running = React.useRef<Animation[]>([]);
@@ -101,25 +142,45 @@ export function useFlipRows(activeKeys: string[]): (key: string) => (el: HTMLTab
// useEffect: инвертировать положение нужно до того, как браузер покажет кадр,
// иначе будет заметен скачок.
React.useLayoutEffect(() => {
// ВАЖНО: «строка сдвинулась по пикселям» — недостаточный повод анимировать.
// Пример живого бага: заявку отправили на согласование → таблица стала
// read-only → у строк пропали колонки со стрелками/кнопками → ширина
// текстовых колонок изменилась → где-то текст перенёсся по-другому →
// строки СМЕСТИЛИСЬ ПО ВЫСОТЕ, хотя их состав и порядок не менялись вовсе.
// Раньше это трактовалось как «переезд» и запускало translateY на
// случайную величину — на кадре это выглядело как строки, которые на
// мгновение пропадают (уезжают за пределы видимой области) и выскакивает
// скролл. Настоящая перестановка меняет НАБОР/ПОРЯДОК ключей — вот это и
// проверяем, а не сам факт смещения.
const orderChanged =
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
prevRowKeys.current = rowKeys;
if (!orderChanged) {
// Порядок тот же — просто обновляем базовую геометрию для следующего
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
prevTops.current = measureTops(nodes.current);
return;
}
// Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
// текущего положения на экране, иначе будет рывок.
const visualTops = new Map<string, number>();
nodes.current.forEach((el, key) => visualTops.set(key, el.getBoundingClientRect().top));
const visualTops = measureTops(nodes.current);
running.current.forEach((a) => a.cancel());
running.current = [];
nodes.current.forEach(drop);
const previous = prevTops.current;
const current = new Map<string, number>();
// После отмены анимаций transform снят — это истинные новые положения.
const current = measureTops(nodes.current);
const moved: Array<{ el: HTMLTableRowElement; key: string; shift: number }> = [];
const entered: HTMLTableRowElement[] = [];
nodes.current.forEach((el, key) => {
// После отмены анимаций transform снят — это истинное новое положение.
const layoutTop = el.getBoundingClientRect().top;
current.set(key, layoutTop);
const layoutTop = current.get(key);
if (layoutTop === undefined) return;
const before = previous.get(key);
if (before === undefined) {