Тесты и анимация кнопок #4
@@ -0,0 +1,40 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js 22
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 10000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
for i in 1 2 3; do
|
||||||
|
npm install --legacy-peer-deps && break
|
||||||
|
echo "Attempt $i failed, retrying in 20s..."
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
|
||||||
|
# heft test = сборка (sass + typescript + lint) и следом Jest. Это НЕ то же
|
||||||
|
# самое, что npm run build: там дополнительно пакуется .sppkg, а для проверки
|
||||||
|
# на PR упаковка не нужна.
|
||||||
|
- name: Run unit tests (Jest via heft)
|
||||||
|
run: npx heft test --clean
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
|
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
|
|
||||||
|
function request(over: Partial<IMyRequest> = {}): IMyRequest {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
title: 'Заявка №1',
|
||||||
|
status: 'Черновик',
|
||||||
|
authorEmail: 'ivanov@tnsu.kz',
|
||||||
|
authorName: 'Иванов Иван',
|
||||||
|
created: '2026-08-07T10:00:00Z',
|
||||||
|
requestTypeId: 1,
|
||||||
|
requestTypeName: 'Приём на работу',
|
||||||
|
section: 'Кадры',
|
||||||
|
formKey: 'hire',
|
||||||
|
kind: 'own',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(over: Partial<IPage> = {}): IPage {
|
||||||
|
return { rows: [request()], totalRows: 1, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pageKey', () => {
|
||||||
|
it('склеивает раздел и части ключа через разделитель', () => {
|
||||||
|
expect(pageKey('own', [1, 'Черновик'])).toBe('own|1|Черновик');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('раздел всегда первый — ключи разных таблиц не пересекаются', () => {
|
||||||
|
expect(pageKey('own', [1])).not.toBe(pageKey('approvals', [1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разные части дают разные ключи', () => {
|
||||||
|
expect(pageKey('own', [1])).not.toBe(pageKey('own', [2]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('раздел без частей — просто раздел', () => {
|
||||||
|
expect(pageKey('search', [])).toBe('search');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readPage / writePage', () => {
|
||||||
|
it('записанная страница читается по тому же ключу', () => {
|
||||||
|
const key = pageKey('own', ['read-write']);
|
||||||
|
const stored = page();
|
||||||
|
writePage(key, stored);
|
||||||
|
expect(readPage(key)).toBe(stored);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('по незнакомому ключу возвращается undefined', () => {
|
||||||
|
expect(readPage(pageKey('own', ['nothing-here']))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('повторная запись по тому же ключу перезаписывает страницу', () => {
|
||||||
|
const key = pageKey('own', ['overwrite']);
|
||||||
|
writePage(key, page({ totalRows: 1 }));
|
||||||
|
writePage(key, page({ totalRows: 2 }));
|
||||||
|
expect(readPage(key)?.totalRows).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('кеш переживает «размонтирование» — хранилище на уровне модуля, а не компонента', () => {
|
||||||
|
const key = pageKey('approvals', ['module-level']);
|
||||||
|
writePage(key, page({ totalRows: 7 }));
|
||||||
|
// Между записью и чтением компонент таблицы мог быть уничтожен целиком —
|
||||||
|
// на модульное хранилище это не влияет (ради этого оно и вынесено из useRef).
|
||||||
|
expect(readPage(key)?.totalRows).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pageSignature', () => {
|
||||||
|
it('одинаковое содержимое даёт одинаковую подпись (перерисовывать нечего)', () => {
|
||||||
|
expect(pageSignature(page())).toBe(pageSignature(page()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('изменение общего числа строк меняет подпись', () => {
|
||||||
|
expect(pageSignature(page({ totalRows: 1 }))).not.toBe(pageSignature(page({ totalRows: 2 })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('изменение статуса заявки меняет подпись', () => {
|
||||||
|
const before = page({ rows: [request({ status: 'Черновик' })] });
|
||||||
|
const after = page({ rows: [request({ status: 'На согласовании' })] });
|
||||||
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('изменение порядка строк меняет подпись', () => {
|
||||||
|
const a = request({ id: 1 });
|
||||||
|
const b = request({ id: 2 });
|
||||||
|
expect(pageSignature(page({ rows: [a, b], totalRows: 2 }))).not.toBe(
|
||||||
|
pageSignature(page({ rows: [b, a], totalRows: 2 }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('поля, которых нет в таблице, на подпись не влияют', () => {
|
||||||
|
// kind/section/formKey в колонках не показываются — их изменение не должно
|
||||||
|
// приводить к перерисовке (иначе таблица моргала бы на каждом фоновом обновлении).
|
||||||
|
const before = page({ rows: [request({ section: 'Кадры', kind: 'own' })] });
|
||||||
|
const after = page({ rows: [request({ section: 'ИТ', kind: 'approval' })] });
|
||||||
|
expect(pageSignature(before)).toBe(pageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустая страница имеет стабильную подпись', () => {
|
||||||
|
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
APPROVAL_PHASE,
|
||||||
|
APPROVAL_PHASE_CHOICES,
|
||||||
|
APPROVAL_ROW_STATUS_CHOICES,
|
||||||
|
EDITABLE_STATUSES,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
REQUEST_STATUS_CHOICES,
|
||||||
|
isEditableStatus,
|
||||||
|
} from './statuses';
|
||||||
|
|
||||||
|
describe('isEditableStatus', () => {
|
||||||
|
it('новая заявка (статуса ещё нет) — редактируема', () => {
|
||||||
|
expect(isEditableStatus(undefined)).toBe(true);
|
||||||
|
expect(isEditableStatus('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('черновик/возвращена/отклонена — редактируемы (можно доработать и переотправить)', () => {
|
||||||
|
expect(isEditableStatus(REQUEST_STATUS.draft)).toBe(true);
|
||||||
|
expect(isEditableStatus(REQUEST_STATUS.returned)).toBe(true);
|
||||||
|
expect(isEditableStatus(REQUEST_STATUS.rejected)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заявка в работе или завершённая — только чтение', () => {
|
||||||
|
expect(isEditableStatus(REQUEST_STATUS.onApproval)).toBe(false);
|
||||||
|
expect(isEditableStatus(REQUEST_STATUS.approved)).toBe(false);
|
||||||
|
expect(isEditableStatus(REQUEST_STATUS.execution)).toBe(false);
|
||||||
|
expect(isEditableStatus(REQUEST_STATUS.closed)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('незнакомый статус считается нередактируемым (безопасный дефолт)', () => {
|
||||||
|
expect(isEditableStatus('какой-то новый статус')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Наборы Choice-значений держатся в синхроне с provisioning.json списков —
|
||||||
|
// расхождение ломает запись (SharePoint отвергает значение вне Choice).
|
||||||
|
describe('наборы Choice-значений', () => {
|
||||||
|
it('все редактируемые статусы существуют в REQUEST_STATUS', () => {
|
||||||
|
const known = Object.keys(REQUEST_STATUS).map((k) => REQUEST_STATUS[k as keyof typeof REQUEST_STATUS]);
|
||||||
|
EDITABLE_STATUSES.forEach((s) => expect(known).toContain(s));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('редактируемые статусы — подмножество статусов ЗАЯВКИ', () => {
|
||||||
|
EDITABLE_STATUSES.forEach((s) => expect(REQUEST_STATUS_CHOICES).toContain(s));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('в статусах ЗАЯВКИ нет состояний отдельного шага маршрута', () => {
|
||||||
|
expect(REQUEST_STATUS_CHOICES).not.toContain(REQUEST_STATUS.queued);
|
||||||
|
expect(REQUEST_STATUS_CHOICES).not.toContain(REQUEST_STATUS.onExecution);
|
||||||
|
expect(REQUEST_STATUS_CHOICES).not.toContain(REQUEST_STATUS.executed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('в статусах СТРОКИ МАРШРУТА нет агрегатных состояний заявки', () => {
|
||||||
|
expect(APPROVAL_ROW_STATUS_CHOICES).not.toContain(REQUEST_STATUS.execution);
|
||||||
|
expect(APPROVAL_ROW_STATUS_CHOICES).not.toContain(REQUEST_STATUS.closed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('оба набора статусов без повторов', () => {
|
||||||
|
expect(new Set(REQUEST_STATUS_CHOICES).size).toBe(REQUEST_STATUS_CHOICES.length);
|
||||||
|
expect(new Set(APPROVAL_ROW_STATUS_CHOICES).size).toBe(APPROVAL_ROW_STATUS_CHOICES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('фазы маршрута перечислены полностью', () => {
|
||||||
|
expect(APPROVAL_PHASE_CHOICES).toEqual([APPROVAL_PHASE.approval, APPROVAL_PHASE.execution]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './approval.module.scss';
|
import styles from './approval.module.scss';
|
||||||
import { Icon } from '../../ui';
|
import { Icon } from '../../ui';
|
||||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
import { IUseMatrixEditor, matrixRowKeys } from '../useMatrixEditor';
|
||||||
import { ISiteUser } from '../models/ISiteUser';
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { UserPicker } from './UserPicker';
|
import { UserPicker } from './UserPicker';
|
||||||
|
import { useFlipRows } from './useFlipRows';
|
||||||
|
|
||||||
export interface IApprovalMatrixProps {
|
export interface IApprovalMatrixProps {
|
||||||
editor: IUseMatrixEditor;
|
editor: IUseMatrixEditor;
|
||||||
@@ -30,6 +31,17 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { rows, loading, error, editable, blocks } = editor;
|
const { rows, loading, error, editable, blocks } = editor;
|
||||||
|
|
||||||
|
// Ключи строк и анимация их перестановки. Ключи обязаны быть устойчивыми
|
||||||
|
// (не индекс) — иначе React переписывает строки на месте, и «переезжать»
|
||||||
|
// при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows).
|
||||||
|
const rowKeys = matrixRowKeys(rows);
|
||||||
|
// Шаг, который двигает пользователь: при обмене переезжают ОБА соседних шага,
|
||||||
|
// но подсветить и поднять надо только тот, по чьей стрелке кликнули, — иначе
|
||||||
|
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
||||||
|
// ключи блока ДО перестановки: после неё индексы уже другие.
|
||||||
|
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
||||||
|
const registerRow = useFlipRows(activeKeys);
|
||||||
|
|
||||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||||
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
|
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
|
||||||
@@ -39,6 +51,31 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const moveBlock = (rowIndex: number, direction: 'up' | 'down'): void => {
|
||||||
|
const b = blockByRow[rowIndex];
|
||||||
|
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
|
||||||
|
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
||||||
|
else editor.moveBlockDown(rowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Остальные правки состава — не перестановка: сбрасываем «активный» шаг,
|
||||||
|
// иначе подъём и подсветка достались бы случайной строке, которая сдвинулась
|
||||||
|
// из-за перенумерации после удаления.
|
||||||
|
const removeRow = (rowIndex: number): void => {
|
||||||
|
setActiveKeys([]);
|
||||||
|
editor.remove(rowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleParallel = (rowIndex: number): void => {
|
||||||
|
setActiveKeys([]);
|
||||||
|
editor.toggleParallel(rowIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addApprover = (user: ISiteUser): void => {
|
||||||
|
setActiveKeys([]);
|
||||||
|
editor.addApprover(user);
|
||||||
|
};
|
||||||
|
|
||||||
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
||||||
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||||
const hasDates = rows.some((r) => !!r.date);
|
const hasDates = rows.some((r) => !!r.date);
|
||||||
@@ -73,7 +110,11 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
const block = blockByRow[i];
|
const block = blockByRow[i];
|
||||||
const isBlockStart = block.start === i;
|
const isBlockStart = block.start === i;
|
||||||
return (
|
return (
|
||||||
<tr key={i} className={block.size > 1 ? styles.parallelRow : undefined}>
|
<tr
|
||||||
|
key={rowKeys[i]}
|
||||||
|
ref={registerRow(rowKeys[i])}
|
||||||
|
className={block.size > 1 ? styles.parallelRow : undefined}
|
||||||
|
>
|
||||||
{isBlockStart && (
|
{isBlockStart && (
|
||||||
<td className={styles.matrixStepCol} rowSpan={block.size}>
|
<td className={styles.matrixStepCol} rowSpan={block.size}>
|
||||||
<span className={styles.stepNo}>{r.order}</span>
|
<span className={styles.stepNo}>{r.order}</span>
|
||||||
@@ -104,7 +145,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
disabled={block.index === 0}
|
disabled={block.index === 0}
|
||||||
onClick={() => editor.moveBlockUp(i)}
|
onClick={() => moveBlock(i, 'up')}
|
||||||
title="Шаг выше"
|
title="Шаг выше"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||||
@@ -113,7 +154,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
disabled={block.index === blocks.length - 1}
|
disabled={block.index === blocks.length - 1}
|
||||||
onClick={() => editor.moveBlockDown(i)}
|
onClick={() => moveBlock(i, 'down')}
|
||||||
title="Шаг ниже"
|
title="Шаг ниже"
|
||||||
>
|
>
|
||||||
<Icon name="chevron-down" size="sm" />
|
<Icon name="chevron-down" size="sm" />
|
||||||
@@ -126,7 +167,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`}
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
onClick={() => editor.toggleParallel(i)}
|
onClick={() => toggleParallel(i)}
|
||||||
title={
|
title={
|
||||||
r.parallelWithPrev
|
r.parallelWithPrev
|
||||||
? 'Отцепить: выполнять после предыдущего шага'
|
? 'Отцепить: выполнять после предыдущего шага'
|
||||||
@@ -138,7 +179,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
onClick={() => editor.remove(i)}
|
onClick={() => removeRow(i)}
|
||||||
title="Удалить"
|
title="Удалить"
|
||||||
>
|
>
|
||||||
<Icon name="trash" size="sm" />
|
<Icon name="trash" size="sm" />
|
||||||
@@ -154,7 +195,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
)}
|
)}
|
||||||
{editable && !loading && (
|
{editable && !loading && (
|
||||||
<div className={styles.matrixPicker}>
|
<div className={styles.matrixPicker}>
|
||||||
<UserPicker search={search} onPick={editor.addApprover} placeholder={pickerPlaceholder} />
|
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,12 @@
|
|||||||
.matrix {
|
.matrix {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
||||||
border-collapse: collapse;
|
// separate, а НЕ collapse: при collapse границы принадлежат таблице, а не
|
||||||
|
// строкам, и во время анимации перестановки (см. ui/useFlipRows) уехавшая
|
||||||
|
// строка оставляла за собой полосы от собственных границ. Границы здесь и так
|
||||||
|
// заданы только снизу у th/td, поэтому при border-spacing: 0 вид не меняется.
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
|
|
||||||
th {
|
th {
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// Анимация перестановки строк маршрута по технике FLIP (First–Last–Invert–Play):
|
||||||
|
// вместо ручного «переезда» мы даём React переставить строки мгновенно, затем
|
||||||
|
// вспоминаем, где строка была до этого, и проигрываем её путь от старого
|
||||||
|
// положения к новому. Так анимация всегда совпадает с реальной раскладкой
|
||||||
|
// таблицы, даже когда у шага несколько параллельных строк разной высоты
|
||||||
|
// (см. rowSpan в ApprovalMatrix).
|
||||||
|
//
|
||||||
|
// Работает ТОЛЬКО при устойчивых ключах строк (см. matrixRowKeys в
|
||||||
|
// useMatrixEditor): с key={index} React не двигает <tr>, а переписывает их
|
||||||
|
// содержимое на месте — двигаться было бы нечему.
|
||||||
|
//
|
||||||
|
// Всё, что мы навешиваем на узлы, — это ИНЛАЙНОВЫЕ стили и анимации Web
|
||||||
|
// Animations API, и это принципиально: className у <tr> задаёт React (там
|
||||||
|
// живёт .parallelRow), и при перерисовке он затёр бы класс, добавленный руками.
|
||||||
|
// Свойство style у <tr>/<td> в разметке не задано, поэтому React его не трогает.
|
||||||
|
//
|
||||||
|
// Чего здесь намеренно НЕТ (проверено на макете — выглядело плохо):
|
||||||
|
// • scale у строки: ячейка «Шаг» объединена по вертикали (rowSpan) и живёт в
|
||||||
|
// ПЕРВОЙ строке блока, поэтому масштабирование каждой строки вокруг своего
|
||||||
|
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
||||||
|
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
||||||
|
// горизонтальной прокрутки.
|
||||||
|
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
|
||||||
|
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
|
||||||
|
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
||||||
|
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
||||||
|
// пользователь (activeKeys).
|
||||||
|
|
||||||
|
const MOVE_MS = 380;
|
||||||
|
const MOVE_EASING = 'cubic-bezier(.22, 1, .36, 1)';
|
||||||
|
const FLASH_MS = 620;
|
||||||
|
const ENTER_MS = 320;
|
||||||
|
const ENTER_STAGGER_MS = 45;
|
||||||
|
/** Смещение меньше этого — дребезг раскладки, а не перестановка: не анимируем. */
|
||||||
|
const MIN_SHIFT_PX = 1;
|
||||||
|
|
||||||
|
// Пока блок летит, он поднят над остальными и непрозрачен — иначе при обмене
|
||||||
|
// двух шагов строки пересекаются и текст накладывается друг на друга. Значения
|
||||||
|
// повторяют $white и (чуть плотнее) $sh-2 из styles/_design-tokens.scss —
|
||||||
|
// инлайновыми они заданы потому, что снимать их надо тоже из JS.
|
||||||
|
const LIFT_BG = '#fff';
|
||||||
|
const LIFT_SHADOW = '0 6px 18px rgba(15, 36, 64, .13)';
|
||||||
|
const FLASH_FROM = 'rgba(212, 160, 30, .22)'; // $gold-500
|
||||||
|
const FLASH_TO = 'rgba(212, 160, 30, 0)';
|
||||||
|
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lift(row: HTMLTableRowElement): void {
|
||||||
|
row.style.position = 'relative';
|
||||||
|
row.style.zIndex = '2';
|
||||||
|
Array.prototype.forEach.call(row.cells, (td: HTMLTableCellElement) => {
|
||||||
|
td.style.background = LIFT_BG;
|
||||||
|
td.style.boxShadow = LIFT_SHADOW;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(row: HTMLTableRowElement): void {
|
||||||
|
row.style.position = '';
|
||||||
|
row.style.zIndex = '';
|
||||||
|
Array.prototype.forEach.call(row.cells, (td: HTMLTableCellElement) => {
|
||||||
|
td.style.background = '';
|
||||||
|
td.style.boxShadow = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>.
|
||||||
|
* @param activeKeys ключи строк того шага, который двигает пользователь —
|
||||||
|
* только он подсвечивается и поднимается над остальными (см. ApprovalMatrix).
|
||||||
|
* Для прочих изменений состава (добавление/удаление) — пустой массив.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
||||||
|
export function useFlipRows(activeKeys: string[]): (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>());
|
||||||
|
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
||||||
|
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
||||||
|
const running = React.useRef<Animation[]>([]);
|
||||||
|
|
||||||
|
const register = React.useCallback((key: string) => {
|
||||||
|
let cb = refCache.current.get(key);
|
||||||
|
if (!cb) {
|
||||||
|
cb = (el: HTMLTableRowElement | null): void => {
|
||||||
|
if (el) nodes.current.set(key, el);
|
||||||
|
else nodes.current.delete(key);
|
||||||
|
};
|
||||||
|
refCache.current.set(key, cb);
|
||||||
|
}
|
||||||
|
return cb;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Без списка зависимостей — намеренно: измерять надо ПОСЛЕ КАЖДОГО коммита,
|
||||||
|
// строка могла переехать от любого изменения. useLayoutEffect, а не
|
||||||
|
// useEffect: инвертировать положение нужно до того, как браузер покажет кадр,
|
||||||
|
// иначе будет заметен скачок.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
// Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах
|
||||||
|
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
||||||
|
// текущего положения на экране, иначе будет рывок.
|
||||||
|
const visualTops = new Map<string, number>();
|
||||||
|
nodes.current.forEach((el, key) => visualTops.set(key, el.getBoundingClientRect().top));
|
||||||
|
|
||||||
|
running.current.forEach((a) => a.cancel());
|
||||||
|
running.current = [];
|
||||||
|
nodes.current.forEach(drop);
|
||||||
|
|
||||||
|
const previous = prevTops.current;
|
||||||
|
const current = new Map<string, number>();
|
||||||
|
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 before = previous.get(key);
|
||||||
|
if (before === undefined) {
|
||||||
|
entered.push(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Недоигранный сдвиг прошлой анимации + переезд из-за перестановки.
|
||||||
|
const leftover = (visualTops.get(key) ?? layoutTop) - layoutTop;
|
||||||
|
const shift = before - layoutTop + leftover;
|
||||||
|
if (Math.abs(shift) >= MIN_SHIFT_PX) moved.push({ el, key, shift });
|
||||||
|
});
|
||||||
|
|
||||||
|
prevTops.current = current;
|
||||||
|
|
||||||
|
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
|
||||||
|
|
||||||
|
moved.forEach(({ el, key, shift }) => {
|
||||||
|
const isActive = activeKeys.indexOf(key) !== -1;
|
||||||
|
if (isActive) lift(el);
|
||||||
|
|
||||||
|
running.current.push(
|
||||||
|
el.animate([{ transform: `translateY(${shift}px)` }, { transform: 'translateY(0)' }], {
|
||||||
|
duration: MOVE_MS,
|
||||||
|
easing: MOVE_EASING,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
// Вспышка по ячейкам, а не по <tr>: у параллельных строк фон задан на td
|
||||||
|
// (.parallelRow > td), и подсветка строки была бы им закрыта.
|
||||||
|
Array.prototype.forEach.call(el.cells, (td: HTMLTableCellElement) => {
|
||||||
|
running.current.push(
|
||||||
|
td.animate([{ backgroundColor: FLASH_FROM }, { backgroundColor: FLASH_TO }], {
|
||||||
|
duration: FLASH_MS,
|
||||||
|
easing: 'ease-out',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
entered.forEach((el, i) => {
|
||||||
|
running.current.push(
|
||||||
|
el.animate(
|
||||||
|
[
|
||||||
|
{ opacity: 0, transform: 'translateY(-6px)' },
|
||||||
|
{ opacity: 1, transform: 'translateY(0)' },
|
||||||
|
],
|
||||||
|
{ duration: ENTER_MS, delay: i * ENTER_STAGGER_MS, easing: 'ease-out', fill: 'backwards' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Приземлились — снимаем подъём. Отменённая анимация роняет finished
|
||||||
|
// с AbortError (это штатный путь при перехвате следующим кликом), поэтому
|
||||||
|
// ошибку гасим: подъём снимет уже тот проход, который её отменил.
|
||||||
|
const landing = running.current.map((a) => a.finished);
|
||||||
|
Promise.all(landing)
|
||||||
|
.then(() => nodes.current.forEach(drop))
|
||||||
|
.catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
running.current.forEach((a) => a.cancel());
|
||||||
|
running.current = [];
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return register;
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
import {
|
||||||
|
blockIndexOf,
|
||||||
|
blockRanges,
|
||||||
|
findUnassignedStep,
|
||||||
|
markParallel,
|
||||||
|
matrixRowKeys,
|
||||||
|
statusForNewRow,
|
||||||
|
swapBlocks,
|
||||||
|
withOrders,
|
||||||
|
} from './useMatrixEditor';
|
||||||
|
|
||||||
|
function row(over: Partial<IMatrixRow> = {}): IMatrixRow {
|
||||||
|
return {
|
||||||
|
order: 1,
|
||||||
|
who: 'Иванов Иван',
|
||||||
|
email: 'ivanov@tnsu.kz',
|
||||||
|
position: 'Инженер',
|
||||||
|
status: '—',
|
||||||
|
date: '',
|
||||||
|
comment: '',
|
||||||
|
allowReassign: false,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('blockRanges', () => {
|
||||||
|
it('каждая непараллельная строка — отдельный шаг', () => {
|
||||||
|
const rows = [row({ parallelWithPrev: false }), row({ parallelWithPrev: false })];
|
||||||
|
expect(blockRanges(rows)).toEqual([
|
||||||
|
{ start: 0, end: 0 },
|
||||||
|
{ start: 1, end: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('подряд идущие параллельные строки объединяются в один шаг', () => {
|
||||||
|
const rows = [
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
row({ parallelWithPrev: true }),
|
||||||
|
row({ parallelWithPrev: true }),
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
];
|
||||||
|
expect(blockRanges(rows)).toEqual([
|
||||||
|
{ start: 0, end: 2 },
|
||||||
|
{ start: 3, end: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('флаг parallelWithPrev у первой строки игнорируется — ей всё равно свой шаг', () => {
|
||||||
|
expect(blockRanges([row({ parallelWithPrev: true })])).toEqual([{ start: 0, end: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой маршрут — пустой список шагов', () => {
|
||||||
|
expect(blockRanges([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockIndexOf', () => {
|
||||||
|
const ranges = [
|
||||||
|
{ start: 0, end: 0 },
|
||||||
|
{ start: 1, end: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('находит шаг по строке внутри него и на его границах', () => {
|
||||||
|
expect(blockIndexOf(ranges, 0)).toBe(0);
|
||||||
|
expect(blockIndexOf(ranges, 1)).toBe(1);
|
||||||
|
expect(blockIndexOf(ranges, 2)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для строки вне диапазонов возвращает -1', () => {
|
||||||
|
expect(blockIndexOf(ranges, 5)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swapBlocks', () => {
|
||||||
|
it('меняет местами два шага, сохраняя порядок строк внутри каждого', () => {
|
||||||
|
const a = row({ who: 'A' });
|
||||||
|
const b1 = row({ who: 'B1' });
|
||||||
|
const b2 = row({ who: 'B2' });
|
||||||
|
const tail = row({ who: 'T' });
|
||||||
|
|
||||||
|
const result = swapBlocks([a, b1, b2, tail], { start: 0, end: 0 }, { start: 1, end: 2 });
|
||||||
|
|
||||||
|
expect(result.map((r) => r.who)).toEqual(['B1', 'B2', 'A', 'T']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает строки за пределами переставляемых шагов', () => {
|
||||||
|
const head = row({ who: 'H' });
|
||||||
|
const a = row({ who: 'A' });
|
||||||
|
const b = row({ who: 'B' });
|
||||||
|
const tail = row({ who: 'T' });
|
||||||
|
|
||||||
|
const result = swapBlocks([head, a, b, tail], { start: 1, end: 1 }, { start: 2, end: 2 });
|
||||||
|
|
||||||
|
expect(result.map((r) => r.who)).toEqual(['H', 'B', 'A', 'T']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withOrders', () => {
|
||||||
|
it('нумерует последовательные шаги подряд, начиная с 1', () => {
|
||||||
|
const rows = [row({ order: 99 }), row({ order: 99 }), row({ order: 99 })];
|
||||||
|
expect(withOrders(rows).map((r) => r.order)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('параллельная строка получает order предыдущей', () => {
|
||||||
|
const rows = [
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
row({ parallelWithPrev: true }),
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
];
|
||||||
|
expect(withOrders(rows).map((r) => r.order)).toEqual([1, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('первая строка не может быть параллельной — флаг снимается, order = 1', () => {
|
||||||
|
const result = withOrders([row({ parallelWithPrev: true }), row({ parallelWithPrev: false })]);
|
||||||
|
expect(result[0].parallelWithPrev).toBe(false);
|
||||||
|
expect(result[0].order).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('несколько параллельных подряд делят один order', () => {
|
||||||
|
const rows = [
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
row({ parallelWithPrev: true }),
|
||||||
|
row({ parallelWithPrev: true }),
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
];
|
||||||
|
expect(withOrders(rows).map((r) => r.order)).toEqual([1, 1, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markParallel', () => {
|
||||||
|
it('совпадение order с предыдущей строкой означает параллельный шаг', () => {
|
||||||
|
const rows = [row({ order: 1 }), row({ order: 1 }), row({ order: 2 })];
|
||||||
|
expect(markParallel(rows).map((r) => r.parallelWithPrev)).toEqual([false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('первая строка никогда не параллельна', () => {
|
||||||
|
expect(markParallel([row({ order: 1 })])[0].parallelWithPrev).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('обратен withOrders: разметка загруженных строк даёт те же флаги', () => {
|
||||||
|
const edited = withOrders([
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
row({ parallelWithPrev: true }),
|
||||||
|
row({ parallelWithPrev: false }),
|
||||||
|
]);
|
||||||
|
expect(markParallel(edited).map((r) => r.parallelWithPrev)).toEqual(
|
||||||
|
edited.map((r) => r.parallelWithPrev)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('statusForNewRow', () => {
|
||||||
|
it('в пустом маршруте — прочерк', () => {
|
||||||
|
expect(statusForNewRow([])).toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('если у всех строк статус одинаковый, новая получает его же', () => {
|
||||||
|
const rows = [row({ status: 'Черновик' }), row({ status: 'Черновик' })];
|
||||||
|
expect(statusForNewRow(rows)).toBe('Черновик');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('если статусы разные — прочерк (куда попадёт новый шаг, ещё не решено)', () => {
|
||||||
|
const rows = [row({ status: 'Согласовано' }), row({ status: 'Ждёт очереди' })];
|
||||||
|
expect(statusForNewRow(rows)).toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой статус приравнивается к прочерку', () => {
|
||||||
|
expect(statusForNewRow([row({ status: '' }), row({ status: '—' })])).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findUnassignedStep', () => {
|
||||||
|
it('возвращает undefined, когда у всех шагов есть исполнитель', () => {
|
||||||
|
expect(findUnassignedStep([row({ email: 'a@tnsu.kz' })])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('находит шаг без email', () => {
|
||||||
|
const rows = [row({ email: 'a@tnsu.kz', order: 1 }), row({ email: '', who: 'Роль без человека', order: 2 })];
|
||||||
|
expect(findUnassignedStep(rows)?.who).toBe('Роль без человека');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('из нескольких незаполненных возвращает самый ранний по order', () => {
|
||||||
|
const rows = [
|
||||||
|
row({ email: '', who: 'Поздний', order: 5 }),
|
||||||
|
row({ email: '', who: 'Ранний', order: 2 }),
|
||||||
|
];
|
||||||
|
expect(findUnassignedStep(rows)?.who).toBe('Ранний');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matrixRowKeys', () => {
|
||||||
|
it('ключом служит email, когда он есть', () => {
|
||||||
|
expect(matrixRowKeys([row({ email: 'a@tnsu.kz' })])).toEqual(['a@tnsu.kz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без email ключом становится ФИО', () => {
|
||||||
|
expect(matrixRowKeys([row({ email: '', who: 'Петров Пётр' })])).toEqual(['Петров Пётр']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('полные тёзки без email получают разные ключи', () => {
|
||||||
|
const rows = [row({ email: '', who: 'Тёзка' }), row({ email: '', who: 'Тёзка' })];
|
||||||
|
expect(matrixRowKeys(rows)).toEqual(['Тёзка', 'Тёзка#1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строка без email и ФИО получает ключ по позиции', () => {
|
||||||
|
expect(matrixRowKeys([row({ email: '', who: '' })])).toEqual(['row-0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ключи не зависят от позиции строки — при перестановке они переезжают вместе со строкой', () => {
|
||||||
|
const a = row({ email: 'a@tnsu.kz' });
|
||||||
|
const b = row({ email: 'b@tnsu.kz' });
|
||||||
|
expect(matrixRowKeys([a, b])).toEqual(['a@tnsu.kz', 'b@tnsu.kz']);
|
||||||
|
expect(matrixRowKeys([b, a])).toEqual(['b@tnsu.kz', 'a@tnsu.kz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ключи уникальны для любого набора строк', () => {
|
||||||
|
const rows = [
|
||||||
|
row({ email: '', who: 'Тёзка' }),
|
||||||
|
row({ email: '', who: 'Тёзка' }),
|
||||||
|
row({ email: '', who: 'Тёзка' }),
|
||||||
|
row({ email: 'a@tnsu.kz' }),
|
||||||
|
];
|
||||||
|
const keys = matrixRowKeys(rows);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сценарии целиком — как в UI: переставить шаг = swapBlocks + withOrders
|
||||||
|
// (см. moveBlockUp/moveBlockDown в useMatrixEditor).
|
||||||
|
describe('перестановка шага (swapBlocks + withOrders)', () => {
|
||||||
|
function moveBlockUp(rows: IMatrixRow[], rowIndex: number): IMatrixRow[] {
|
||||||
|
const ranges = blockRanges(rows);
|
||||||
|
const bi = blockIndexOf(ranges, rowIndex);
|
||||||
|
if (bi <= 0) return rows;
|
||||||
|
return withOrders(swapBlocks(rows, ranges[bi - 1], ranges[bi]));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('перемещение вверх меняет местами соседние шаги и пере-нумеровывает их', () => {
|
||||||
|
const rows = withOrders([row({ who: 'A' }), row({ who: 'B' })]);
|
||||||
|
const result = moveBlockUp(rows, 1);
|
||||||
|
|
||||||
|
expect(result.map((r) => r.who)).toEqual(['B', 'A']);
|
||||||
|
expect(result.map((r) => r.order)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('параллельный шаг переезжает целиком, сохраняя внутреннюю параллельность', () => {
|
||||||
|
const rows = withOrders([
|
||||||
|
row({ who: 'Один', parallelWithPrev: false }),
|
||||||
|
row({ who: 'Пара1', parallelWithPrev: false }),
|
||||||
|
row({ who: 'Пара2', parallelWithPrev: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = moveBlockUp(rows, 1);
|
||||||
|
|
||||||
|
expect(result.map((r) => r.who)).toEqual(['Пара1', 'Пара2', 'Один']);
|
||||||
|
expect(result.map((r) => r.order)).toEqual([1, 1, 2]);
|
||||||
|
expect(result.map((r) => r.parallelWithPrev)).toEqual([false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('первый шаг вверх не двигается', () => {
|
||||||
|
const rows = withOrders([row({ who: 'A' }), row({ who: 'B' })]);
|
||||||
|
expect(moveBlockUp(rows, 0)).toBe(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('перемещение параллельного шага на первое место не делает его строки параллельными первой', () => {
|
||||||
|
const rows = withOrders([
|
||||||
|
row({ who: 'Первый' }),
|
||||||
|
row({ who: 'Пара1', parallelWithPrev: false }),
|
||||||
|
row({ who: 'Пара2', parallelWithPrev: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = moveBlockUp(rows, 1);
|
||||||
|
|
||||||
|
// Строка, ставшая первой, обязана потерять флаг параллельности.
|
||||||
|
expect(result[0].parallelWithPrev).toBe(false);
|
||||||
|
expect(result[0].order).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ export function blockRanges(rows: IMatrixRow[]): IMatrixBlock[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Индекс шага, которому принадлежит строка (-1 — не найдено).
|
// Индекс шага, которому принадлежит строка (-1 — не найдено).
|
||||||
function blockIndexOf(ranges: IMatrixBlock[], row: number): number {
|
export function blockIndexOf(ranges: IMatrixBlock[], row: number): number {
|
||||||
for (let i = 0; i < ranges.length; i += 1) {
|
for (let i = 0; i < ranges.length; i += 1) {
|
||||||
if (row >= ranges[i].start && row <= ranges[i].end) return i;
|
if (row >= ranges[i].start && row <= ranges[i].end) return i;
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ function blockIndexOf(ranges: IMatrixBlock[], row: number): number {
|
|||||||
// Меняет местами два СОСЕДНИХ шага целиком. Флаги parallelWithPrev внутри шага
|
// Меняет местами два СОСЕДНИХ шага целиком. Флаги parallelWithPrev внутри шага
|
||||||
// остаются верными сами собой: у первой строки шага флаг снят, у остальных —
|
// остаются верными сами собой: у первой строки шага флаг снят, у остальных —
|
||||||
// стоит, а порядок строк внутри шага не меняется.
|
// стоит, а порядок строк внутри шага не меняется.
|
||||||
function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatrixRow[] {
|
export function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatrixRow[] {
|
||||||
return [
|
return [
|
||||||
...rows.slice(0, a.start),
|
...rows.slice(0, a.start),
|
||||||
...rows.slice(b.start, b.end + 1),
|
...rows.slice(b.start, b.end + 1),
|
||||||
@@ -60,7 +60,7 @@ function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatr
|
|||||||
// предыдущим, у последовательных — на единицу больше. Вызывается после КАЖДОГО
|
// предыдущим, у последовательных — на единицу больше. Вызывается после КАЖДОГО
|
||||||
// изменения состава/порядка, иначе order разъедется с тем, что видно на экране.
|
// изменения состава/порядка, иначе order разъедется с тем, что видно на экране.
|
||||||
// Первая строка параллельной быть не может — ей всегда достаётся order 1.
|
// Первая строка параллельной быть не может — ей всегда достаётся order 1.
|
||||||
function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
export function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
||||||
let order = 0;
|
let order = 0;
|
||||||
return rows.map((r, i) => {
|
return rows.map((r, i) => {
|
||||||
const parallel = i > 0 && !!r.parallelWithPrev;
|
const parallel = i > 0 && !!r.parallelWithPrev;
|
||||||
@@ -75,7 +75,7 @@ function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
|||||||
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
|
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
|
||||||
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
|
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
|
||||||
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
|
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
|
||||||
function statusForNewRow(rows: IMatrixRow[]): string {
|
export function statusForNewRow(rows: IMatrixRow[]): string {
|
||||||
if (rows.length === 0) return '—';
|
if (rows.length === 0) return '—';
|
||||||
const first = rows[0].status || '—';
|
const first = rows[0].status || '—';
|
||||||
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
||||||
@@ -83,13 +83,31 @@ function statusForNewRow(rows: IMatrixRow[]): string {
|
|||||||
|
|
||||||
// Обратное преобразование для загруженных строк: одинаковый order у соседей
|
// Обратное преобразование для загруженных строк: одинаковый order у соседей
|
||||||
// означает параллельный шаг.
|
// означает параллельный шаг.
|
||||||
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
export function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
||||||
return rows.map((r, i) => ({
|
return rows.map((r, i) => ({
|
||||||
...r,
|
...r,
|
||||||
parallelWithPrev: i > 0 && r.order === rows[i - 1].order,
|
parallelWithPrev: i > 0 && r.order === rows[i - 1].order,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Устойчивый ключ строки — НЕ индекс. Нужен сразу для двух вещей:
|
||||||
|
// 1) React при перестановке должен ПЕРЕМЕСТИТЬ существующий <tr>, а не
|
||||||
|
// переписать содержимое строк на месте (с key={i} он делает второе);
|
||||||
|
// 2) FLIP-анимация (ui/useFlipRows) опознаёт «та же строка на новом месте»
|
||||||
|
// именно по ключу — с индексами перемещения для неё просто не существует.
|
||||||
|
// Email уникален (addApprover не даёт добавить дубль), но у строк, загруженных
|
||||||
|
// из списка, он пустой — тогда падаем на ФИО, а на случай полных тёзок без
|
||||||
|
// email добавляем счётчик повторов.
|
||||||
|
export function matrixRowKeys(rows: IMatrixRow[]): string[] {
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
return rows.map((r, i) => {
|
||||||
|
const base = r.email || r.who || `row-${i}`;
|
||||||
|
const repeat = seen.get(base) ?? 0;
|
||||||
|
seen.set(base, repeat + 1);
|
||||||
|
return repeat === 0 ? base : `${base}#${repeat}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Первый шаг без назначенного человека (email пуст) — обычно шаг маршрута,
|
// Первый шаг без назначенного человека (email пуст) — обычно шаг маршрута,
|
||||||
// который внешний approval-route API вернул как роль без привязанного
|
// который внешний approval-route API вернул как роль без привязанного
|
||||||
// исполнителя. Писать такую строку в SharePoint нельзя (Person-поле требует
|
// исполнителя. Писать такую строку в SharePoint нельзя (Person-поле требует
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { IRoute, navigate, parseRoute, routeUrl } from './routing';
|
||||||
|
|
||||||
|
// buildQuery/navigate читают и пишут window.location через history API —
|
||||||
|
// сбрасываем адрес перед каждым тестом, чтобы они не зависели от порядка запуска.
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState({}, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRoute', () => {
|
||||||
|
it('пустой адрес — подача заявки в разделе «Новая заявка»', () => {
|
||||||
|
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rpnav задаёт раздел бокового меню', () => {
|
||||||
|
expect(parseRoute('?rpnav=approvals').nav).toBe('approvals');
|
||||||
|
expect(parseRoute('?rpnav=own').nav).toBe('own');
|
||||||
|
expect(parseRoute('?rpnav=search').nav).toBe('search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('незнакомый rpnav откатывается на раздел по умолчанию', () => {
|
||||||
|
expect(parseRoute('?rpnav=bogus').nav).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('полный набор параметров открывает заявку на согласование', () => {
|
||||||
|
expect(parseRoute('?rpmode=approve&rptype=hire&rpid=42')).toEqual({
|
||||||
|
mode: 'approve',
|
||||||
|
nav: 'new',
|
||||||
|
formKey: 'hire',
|
||||||
|
itemId: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('раздел меню сохраняется, когда поверх него открыта заявка', () => {
|
||||||
|
expect(parseRoute('?rpmode=approve&rptype=hire&rpid=42&rpnav=own').nav).toBe('own');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('режим редактирования собственной заявки разбирается так же, как согласование', () => {
|
||||||
|
expect(parseRoute('?rpmode=edit&rptype=hire&rpid=7')).toEqual({
|
||||||
|
mode: 'edit',
|
||||||
|
nav: 'new',
|
||||||
|
formKey: 'hire',
|
||||||
|
itemId: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без rpid заявка не открывается — остаётся обычная подача', () => {
|
||||||
|
expect(parseRoute('?rpmode=approve&rptype=hire').mode).toBe('submit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нечисловой rpid не открывает заявку', () => {
|
||||||
|
expect(parseRoute('?rpmode=approve&rptype=hire&rpid=abc').mode).toBe('submit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rpid=0 не считается заявкой (в SharePoint нумерация с единицы)', () => {
|
||||||
|
expect(parseRoute('?rpmode=approve&rptype=hire&rpid=0').mode).toBe('submit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rptype + rpsource — создание производной заявки с предзаполнением', () => {
|
||||||
|
expect(parseRoute('?rptype=workplace&rpsource=15')).toEqual({
|
||||||
|
mode: 'create',
|
||||||
|
nav: 'new',
|
||||||
|
formKey: 'workplace',
|
||||||
|
sourceId: 15,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('создание производной заявки всегда открывается в разделе «Новая заявка»', () => {
|
||||||
|
expect(parseRoute('?rptype=workplace&rpsource=15&rpnav=own').nav).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открытая заявка имеет приоритет над предзаполнением', () => {
|
||||||
|
expect(parseRoute('?rpmode=approve&rptype=hire&rpid=42&rpsource=15').mode).toBe('approve');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('routeUrl', () => {
|
||||||
|
it('раздел по умолчанию не засоряет адрес параметром', () => {
|
||||||
|
expect(routeUrl({ mode: 'submit', nav: 'new' })).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('непустой раздел попадает в адрес', () => {
|
||||||
|
expect(routeUrl({ mode: 'submit', nav: 'own' })).toContain('rpnav=own');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сохраняет посторонние параметры страницы (их ставит SharePoint)', () => {
|
||||||
|
window.history.replaceState({}, '', '/page.aspx?env=WebViewList');
|
||||||
|
const url = routeUrl({ mode: 'submit', nav: 'own' });
|
||||||
|
expect(url).toContain('env=WebViewList');
|
||||||
|
expect(url).toContain('rpnav=own');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заявка без formKey/itemId не добавляет параметров открытия', () => {
|
||||||
|
const url = routeUrl({ mode: 'approve', nav: 'new' } as IRoute);
|
||||||
|
expect(url).not.toContain('rpmode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('navigate', () => {
|
||||||
|
it('после перехода маршрут читается обратно тем же parseRoute (round-trip)', () => {
|
||||||
|
navigate({ mode: 'approve', nav: 'own', formKey: 'hire', itemId: 42 });
|
||||||
|
expect(parseRoute(window.location.search)).toEqual({
|
||||||
|
mode: 'approve',
|
||||||
|
nav: 'own',
|
||||||
|
formKey: 'hire',
|
||||||
|
itemId: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip для создания производной заявки', () => {
|
||||||
|
navigate({ mode: 'create', nav: 'new', formKey: 'workplace', sourceId: 15 });
|
||||||
|
expect(parseRoute(window.location.search)).toEqual({
|
||||||
|
mode: 'create',
|
||||||
|
nav: 'new',
|
||||||
|
formKey: 'workplace',
|
||||||
|
sourceId: 15,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возврат к подаче заявки убирает параметры открытой заявки из адреса', () => {
|
||||||
|
navigate({ mode: 'approve', nav: 'new', formKey: 'hire', itemId: 42 });
|
||||||
|
navigate({ mode: 'submit', nav: 'new' });
|
||||||
|
expect(window.location.search).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('добавляет запись в историю (pushState, а не replaceState)', () => {
|
||||||
|
const before = window.history.length;
|
||||||
|
navigate({ mode: 'submit', nav: 'own' });
|
||||||
|
expect(window.history.length).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('оповещает подписчиков своим событием (pushState сам popstate не порождает)', () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const onChange = (): void => {
|
||||||
|
seen.push(window.location.search);
|
||||||
|
};
|
||||||
|
window.addEventListener('rp-route-change', onChange);
|
||||||
|
|
||||||
|
navigate({ mode: 'submit', nav: 'approvals' });
|
||||||
|
|
||||||
|
window.removeEventListener('rp-route-change', onChange);
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0]).toContain('rpnav=approvals');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user