From 38394d69ed72535c96e5379a2c8c73d1497eecb8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 7 Aug 2026 16:36:16 +0500 Subject: [PATCH 1/2] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/pageCache.test.ts | 105 +++++++ .../shared/approval/models/statuses.test.ts | 66 +++++ .../shared/approval/ui/ApprovalMatrix.tsx | 15 +- .../shared/approval/ui/useFlipRows.ts | 143 +++++++++ .../shared/approval/useMatrixEditor.test.ts | 279 ++++++++++++++++++ .../shared/approval/useMatrixEditor.ts | 28 +- .../requestPortal/shared/routing.test.ts | 144 +++++++++ 7 files changed, 773 insertions(+), 7 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/pageCache.test.ts create mode 100644 src/webparts/requestPortal/shared/approval/models/statuses.test.ts create mode 100644 src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts create mode 100644 src/webparts/requestPortal/shared/approval/useMatrixEditor.test.ts create mode 100644 src/webparts/requestPortal/shared/routing.test.ts diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts new file mode 100644 index 0000000..729e595 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -0,0 +1,105 @@ +import { IMyRequest } from './models/IMyRequest'; +import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; + +function request(over: Partial = {}): 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 { + 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 })); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts new file mode 100644 index 0000000..30f8d72 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts @@ -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]); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 2ae9fd0..dc23434 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import styles from './approval.module.scss'; import { Icon } from '../../ui'; -import { IUseMatrixEditor } from '../useMatrixEditor'; +import { IUseMatrixEditor, matrixRowKeys } from '../useMatrixEditor'; import { ISiteUser } from '../models/ISiteUser'; import { StatusBadge } from './StatusBadge'; import { UserPicker } from './UserPicker'; +import { useFlipRows } from './useFlipRows'; export interface IApprovalMatrixProps { editor: IUseMatrixEditor; @@ -30,6 +31,12 @@ export const ApprovalMatrix: React.FC = ({ }) => { const { rows, loading, error, editable, blocks } = editor; + // Ключи строк и анимация их перестановки. Ключи обязаны быть устойчивыми + // (не индекс) — иначе React переписывает строки на месте, и «переезжать» + // при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows). + const rowKeys = matrixRowKeys(rows); + const registerRow = useFlipRows(); + // Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку // «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже. const blockByRow: Array<{ index: number; start: number; size: number }> = []; @@ -73,7 +80,11 @@ export const ApprovalMatrix: React.FC = ({ const block = blockByRow[i]; const isBlockStart = block.start === i; return ( - 1 ? styles.parallelRow : undefined}> + 1 ? styles.parallelRow : undefined} + > {isBlockStart && ( {r.order} diff --git a/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts b/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts new file mode 100644 index 0000000..108c523 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/useFlipRows.ts @@ -0,0 +1,143 @@ +import * as React from 'react'; + +// Анимация перестановки строк маршрута по технике FLIP (First–Last–Invert–Play): +// вместо того чтобы анимировать «переезд» вручную, мы даём React переставить +// строки мгновенно, а затем ЗАПОМИНАЕМ, где строка была до этого, и проигрываем +// её путь задом наперёд — от старого положения к новому. Так анимация всегда +// совпадает с реальной раскладкой таблицы, даже когда у шага несколько +// параллельных строк разной высоты (см. rowSpan в ApprovalMatrix). +// +// Работает ТОЛЬКО при устойчивых ключах строк (см. matrixRowKeys в +// useMatrixEditor): с key={index} React не двигает , а переписывает их +// содержимое на месте — двигаться было бы нечему. +// +// Проигрываем через Web Animations API (el.animate), а не через CSS-классы: +// className у управляет React и затирает всё, что мы добавили бы руками, +// а инлайновый style/анимации WAAPI он не трогает (эти пропсы у не заданы). + +/** Длительности и «пружинистость» — вынесены, чтобы правились в одном месте. */ +const MOVE_MS = 460; +const MOVE_EASING = 'cubic-bezier(.34, 1.4, .5, 1)'; // с лёгким перелётом — «пружинка» +const FLASH_MS = 750; +const ENTER_MS = 320; +const ENTER_STAGGER_MS = 45; +/** Смещение меньше этого — не перестановка, а дребезг раскладки: не анимируем. */ +const MIN_SHIFT_PX = 1; + +function prefersReducedMotion(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** + * Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её . + * Колбэк на один и тот же ключ — стабильный (см. refCache): иначе React дёргал + * бы ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую. + */ +// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла +export function useFlipRows(): (key: string) => (el: HTMLTableRowElement | null) => void { + const nodes = React.useRef(new Map()); + const refCache = React.useRef(new Map void>()); + const prevRects = React.useRef(new Map()); + // Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations() + // вернул бы заодно чужие (CSS-переходы и т.п.). + const running = React.useRef([]); + + 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(() => { + // Прошлые анимации отменяем ДО замеров: getBoundingClientRect у элемента + // с активным transform вернул бы его текущее (промежуточное) положение, и + // новая анимация стартовала бы от неверной точки. + running.current.forEach((a) => a.cancel()); + running.current = []; + + const previous = prevRects.current; + const current = new Map(); + const moved: Array<{ el: HTMLTableRowElement; shift: number }> = []; + const entered: HTMLTableRowElement[] = []; + + nodes.current.forEach((el, key) => { + const top = el.getBoundingClientRect().top; + current.set(key, top); + + const before = previous.get(key); + if (before === undefined) { + entered.push(el); + return; + } + const shift = before - top; + if (Math.abs(shift) >= MIN_SHIFT_PX) moved.push({ el, shift }); + }); + + prevRects.current = current; + + // Первый проход (карта пустая) — это просто появление таблицы, а не + // перестановка: анимируем только выезд строк, и то по одному разу. + if (prefersReducedMotion()) return; + + moved.forEach(({ el, shift }) => { + // Строка едет из старого положения в новое и на середине пути чуть + // «раздувается» — глазу проще проследить, какой именно шаг переехал. + running.current.push( + el.animate( + [ + { transform: `translateY(${shift}px) scale(1)` }, + { transform: `translateY(${shift * 0.25}px) scale(1.02)`, offset: 0.45 }, + { transform: 'translateY(0) scale(1)' }, + ], + { duration: MOVE_MS, easing: MOVE_EASING } + ) + ); + + // Золотая вспышка по ячейкам (а не по ): у параллельных строк фон + // задан на td (.parallelRow > td), и подсветка строки была бы им закрыта. + Array.prototype.forEach.call(el.cells, (td: HTMLTableCellElement) => { + running.current.push( + td.animate( + [ + { backgroundColor: 'rgba(212, 160, 30, .30)' }, + { backgroundColor: 'rgba(212, 160, 30, 0)' }, + ], + { 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' } + ) + ); + }); + }); + + React.useEffect( + () => () => { + running.current.forEach((a) => a.cancel()); + running.current = []; + }, + [] + ); + + return register; +} diff --git a/src/webparts/requestPortal/shared/approval/useMatrixEditor.test.ts b/src/webparts/requestPortal/shared/approval/useMatrixEditor.test.ts new file mode 100644 index 0000000..e0c548c --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/useMatrixEditor.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts index 5c5d0f0..81f70ab 100644 --- a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts +++ b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts @@ -37,7 +37,7 @@ export function blockRanges(rows: IMatrixRow[]): IMatrixBlock[] { } // Индекс шага, которому принадлежит строка (-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) { if (row >= ranges[i].start && row <= ranges[i].end) return i; } @@ -47,7 +47,7 @@ function blockIndexOf(ranges: IMatrixBlock[], row: number): number { // Меняет местами два СОСЕДНИХ шага целиком. Флаги parallelWithPrev внутри шага // остаются верными сами собой: у первой строки шага флаг снят, у остальных — // стоит, а порядок строк внутри шага не меняется. -function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatrixRow[] { +export function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatrixRow[] { return [ ...rows.slice(0, a.start), ...rows.slice(b.start, b.end + 1), @@ -60,7 +60,7 @@ function swapBlocks(rows: IMatrixRow[], a: IMatrixBlock, b: IMatrixBlock): IMatr // предыдущим, у последовательных — на единицу больше. Вызывается после КАЖДОГО // изменения состава/порядка, иначе order разъедется с тем, что видно на экране. // Первая строка параллельной быть не может — ей всегда достаётся order 1. -function withOrders(rows: IMatrixRow[]): IMatrixRow[] { +export function withOrders(rows: IMatrixRow[]): IMatrixRow[] { let order = 0; return rows.map((r, i) => { 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 '—'; const first = rows[0].status || '—'; return rows.every((r) => (r.status || '—') === first) ? first : '—'; @@ -83,13 +83,31 @@ function statusForNewRow(rows: IMatrixRow[]): string { // Обратное преобразование для загруженных строк: одинаковый order у соседей // означает параллельный шаг. -function markParallel(rows: IMatrixRow[]): IMatrixRow[] { +export function markParallel(rows: IMatrixRow[]): IMatrixRow[] { return rows.map((r, i) => ({ ...r, parallelWithPrev: i > 0 && r.order === rows[i - 1].order, })); } +// Устойчивый ключ строки — НЕ индекс. Нужен сразу для двух вещей: +// 1) React при перестановке должен ПЕРЕМЕСТИТЬ существующий , а не +// переписать содержимое строк на месте (с key={i} он делает второе); +// 2) FLIP-анимация (ui/useFlipRows) опознаёт «та же строка на новом месте» +// именно по ключу — с индексами перемещения для неё просто не существует. +// Email уникален (addApprover не даёт добавить дубль), но у строк, загруженных +// из списка, он пустой — тогда падаем на ФИО, а на случай полных тёзок без +// email добавляем счётчик повторов. +export function matrixRowKeys(rows: IMatrixRow[]): string[] { + const seen = new Map(); + 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 пуст) — обычно шаг маршрута, // который внешний approval-route API вернул как роль без привязанного // исполнителя. Писать такую строку в SharePoint нельзя (Person-поле требует diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts new file mode 100644 index 0000000..cc673d5 --- /dev/null +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -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'); + }); +}); -- 2.34.1 From 41700cdd54c7e526e5f6a5b7d26a39754254c80f Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 7 Aug 2026 16:51:01 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=BF=D0=B0=D0=B9=D0=BF=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 40 +++++ .../shared/approval/ui/ApprovalMatrix.tsx | 42 ++++- .../shared/approval/ui/approval.module.scss | 7 +- .../shared/approval/ui/useFlipRows.ts | 165 ++++++++++++------ 4 files changed, 190 insertions(+), 64 deletions(-) create mode 100644 .gitea/workflows/test.yml diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..2977005 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index dc23434..0ed724e 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -35,7 +35,12 @@ export const ApprovalMatrix: React.FC = ({ // (не индекс) — иначе React переписывает строки на месте, и «переезжать» // при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows). const rowKeys = matrixRowKeys(rows); - const registerRow = useFlipRows(); + // Шаг, который двигает пользователь: при обмене переезжают ОБА соседних шага, + // но подсветить и поднять надо только тот, по чьей стрелке кликнули, — иначе + // подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем + // ключи блока ДО перестановки: после неё индексы уже другие. + const [activeKeys, setActiveKeys] = React.useState([]); + const registerRow = useFlipRows(activeKeys); // Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку // «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже. @@ -46,6 +51,31 @@ export const ApprovalMatrix: React.FC = ({ } }); + 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 hasDates = rows.some((r) => !!r.date); @@ -115,7 +145,7 @@ export const ApprovalMatrix: React.FC = ({ type="button" className={styles.iconBtn} disabled={block.index === 0} - onClick={() => editor.moveBlockUp(i)} + onClick={() => moveBlock(i, 'up')} title="Шаг выше" > @@ -124,7 +154,7 @@ export const ApprovalMatrix: React.FC = ({ type="button" className={styles.iconBtn} disabled={block.index === blocks.length - 1} - onClick={() => editor.moveBlockDown(i)} + onClick={() => moveBlock(i, 'down')} title="Шаг ниже" > @@ -137,7 +167,7 @@ export const ApprovalMatrix: React.FC = ({ type="button" className={`${styles.iconBtn} ${r.parallelWithPrev ? styles.iconBtnActive : ''}`} disabled={i === 0} - onClick={() => editor.toggleParallel(i)} + onClick={() => toggleParallel(i)} title={ r.parallelWithPrev ? 'Отцепить: выполнять после предыдущего шага' @@ -149,7 +179,7 @@ export const ApprovalMatrix: React.FC = ({