Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 1m5s
Tests / frontend-test (push) Successful in 1m5s
This commit is contained in:
@@ -12,12 +12,18 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||
const FIELDS = {
|
||||
// Внутренние имена с префиксом группы колонок сайта (TansuSite) и «Request» —
|
||||
// так они заведены в SharePoint. Из них SharePoint выводит имена managed
|
||||
// properties для поиска: TansuSiteApproverOWSUSER и RequestStatusOWSCHCS
|
||||
// (см. MP_APPROVER/MP_STATUS в features/myRequests/myRequests.service.ts).
|
||||
// Внутренние имена с префиксом группы колонок сайта (TansuSite) там, где он
|
||||
// есть, — так они заведены в SharePoint. Из них SharePoint выводит имена
|
||||
// managed properties для поиска: TansuSiteApproverOWSUSER и
|
||||
// ApprovalStatusOWSCHCS (см. MP_APPROVER/MP_STATUS в
|
||||
// features/myRequests/myRequests.service.ts).
|
||||
//
|
||||
// ВАЖНО: колонка называется ApprovalStatus, а не Status и не RequestStatus —
|
||||
// это статус СТРОКИ маршрута (список «— согласование»); статус ЗАЯВКИ в
|
||||
// списке «— данных» лежит в отдельной колонке RequestStatus (см.
|
||||
// models/statuses.ts — там оба набора значений сведены в одну картину).
|
||||
approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId
|
||||
status: 'RequestStatus', // «Статус» (Choice)
|
||||
status: 'ApprovalStatus', // «Статус» (Choice) строки маршрута
|
||||
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
||||
date: 'Date', // «Дата» (DateTime)
|
||||
comment: 'Comment', // «Комментарий» (Text)
|
||||
|
||||
@@ -147,7 +147,7 @@ export class ApproverService {
|
||||
|
||||
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
||||
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
|
||||
Status: status,
|
||||
RequestStatus: status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,16 +45,21 @@ export function isEditableStatus(status: string | undefined): boolean {
|
||||
|
||||
// Наборов значений ДВА, и они не совпадают — это разные сущности:
|
||||
//
|
||||
// 1) статус ЗАЯВКИ — колонка Status в списке «… — данные». Пишется формой
|
||||
// (Черновик/На согласовании) и computeRequestStatus после каждого решения.
|
||||
// «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это состояния
|
||||
// отдельного шага маршрута, а не заявки целиком.
|
||||
// 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется
|
||||
// формой (Черновик/На согласовании) и computeRequestStatus после каждого
|
||||
// решения. «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это
|
||||
// состояния отдельного шага маршрута, а не заявки целиком.
|
||||
//
|
||||
// 2) статус СТРОКИ МАРШРУТА — колонка RequestStatus в списке «… — согласование».
|
||||
// 2) статус СТРОКИ МАРШРУТА — колонка ApprovalStatus в списке «… — согласование».
|
||||
// Пишется createSteps/activateNextQueued/setDecision. «Выполнение»/«Закрыта»
|
||||
// здесь не бывают: это агрегатные состояния заявки, у отдельного шага
|
||||
// смысла не имеют.
|
||||
//
|
||||
// Обе колонки переименованы (были Status в списке данных и RequestStatus в
|
||||
// списке согласования соответственно) — переименование внутреннего имени в
|
||||
// SharePoint не трогает уже сохранённые значения, отдельная миграция данных
|
||||
// не нужна.
|
||||
//
|
||||
// Держать их в синхроне с forms/*/provisioning.json и forms/*/approval/provisioning.json.
|
||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||
REQUEST_STATUS.draft,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user