Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
@@ -129,6 +129,7 @@ export class ApprovalListService {
|
||||
toWrite.forEach(({ a, order }, i) => {
|
||||
// void: промис от add() на batched-инстансе нарочно не ждём поштучно —
|
||||
// он разрешится вместе со всеми остальными после execute() ниже.
|
||||
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||
void batchedList.items.add({
|
||||
Title: a.position || a.email,
|
||||
[`${FIELDS.approver}Id`]: approverIds[i],
|
||||
|
||||
@@ -51,7 +51,10 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
||||
// ключи блока ДО перестановки: после неё индексы уже другие.
|
||||
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
||||
const registerRow = useFlipRows(activeKeys, rowKeys);
|
||||
// Обёртка таблицы: её высоту useFlipRows анимирует вместе с переездом строк,
|
||||
// чтобы пикер и следующая карточка не подпрыгивали при удалении шага.
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const registerRow = useFlipRows(activeKeys, rowKeys, scrollRef);
|
||||
|
||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||
@@ -110,7 +113,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
<div className={styles.matrixNote}>{emptyText}</div>
|
||||
)}
|
||||
{!waiting && !loading && !error && rows.length > 0 && (
|
||||
<div className={styles.matrixScroll}>
|
||||
<div className={styles.matrixScroll} ref={scrollRef}>
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -95,6 +95,19 @@
|
||||
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
||||
.matrixScroll {
|
||||
overflow-x: auto;
|
||||
// overflow-y ОБЯЗАН быть задан явно. По спецификации, если одна ось получает
|
||||
// прокрутку, вторая с `visible` вычисляется в `auto` — то есть один
|
||||
// overflow-x: auto включал прокрутку и по вертикали тоже. А анимация
|
||||
// перестановки (см. ui/useFlipRows) стартует с translateY на высоту строки:
|
||||
// при удалении строки нижние сначала отрисованы СМЕЩЁННЫМИ ВНИЗ, вылезают за
|
||||
// нижнюю границу контейнера — на кадр появляется вертикальная полоса
|
||||
// прокрутки, из-за неё контейнер сужается, текст в колонках перевёрстывается,
|
||||
// строки съезжают, а через 380 мс всё это откатывается назад. Отсюда и
|
||||
// дрожание интерфейса на каждое удаление позиции.
|
||||
//
|
||||
// hidden, а не clip: `clip` не создаёт scroll container, и тогда вертикальный
|
||||
// вылет анимации утёк бы наружу, прокручивая уже саму страницу.
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ import * as React from 'react';
|
||||
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
|
||||
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
|
||||
// горизонтальной прокрутки.
|
||||
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
|
||||
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
|
||||
// • пружинистый перелёт (cubic-bezier с >1): вылет за край .matrixScroll
|
||||
// обрезается (там overflow-y: hidden — см. approval.module.scss), так что
|
||||
// перелёт был бы виден только как срезанная строка.
|
||||
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
|
||||
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
|
||||
// пользователь (activeKeys).
|
||||
@@ -108,10 +109,16 @@ function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, numbe
|
||||
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
|
||||
* которому решаем, действительно ли что-то переставили (см. ниже, почему
|
||||
* геометрии для этого недостаточно).
|
||||
* @param containerRef прокручиваемая обёртка таблицы (.matrixScroll). Её высоту
|
||||
* анимируем в такт переезду строк: без этого React схлопывает высоту таблицы
|
||||
* мгновенно, а строки едут 380 мс — и всё, что стоит НИЖЕ маршрута (пикер
|
||||
* «Добавить согласующего», следующая карточка), рывком подпрыгивает на высоту
|
||||
* удалённой строки. Необязателен: без него анимируются только строки.
|
||||
*/
|
||||
export function useFlipRows(
|
||||
activeKeys: string[],
|
||||
rowKeys: string[]
|
||||
rowKeys: string[],
|
||||
containerRef?: React.RefObject<HTMLElement>
|
||||
// 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>());
|
||||
@@ -121,6 +128,10 @@ export function useFlipRows(
|
||||
const prevTops = React.useRef(new Map<string, number>());
|
||||
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
|
||||
const prevRowKeys = React.useRef<string[]>([]);
|
||||
// Высота обёртки на прошлый коммит. Нужна именно запомненная: layout-эффект
|
||||
// работает УЖЕ ПОСЛЕ коммита, и к этому моменту высота на экране новая —
|
||||
// «откуда ехать» из текущей раскладки не узнать.
|
||||
const prevHeight = React.useRef<number | undefined>(undefined);
|
||||
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
||||
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
||||
const running = React.useRef<Animation[]>([]);
|
||||
@@ -156,10 +167,13 @@ export function useFlipRows(
|
||||
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
|
||||
prevRowKeys.current = rowKeys;
|
||||
|
||||
const container = containerRef ? containerRef.current : null;
|
||||
|
||||
if (!orderChanged) {
|
||||
// Порядок тот же — просто обновляем базовую геометрию для следующего
|
||||
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
|
||||
prevTops.current = measureTops(nodes.current);
|
||||
prevHeight.current = container ? container.getBoundingClientRect().height : undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,6 +181,10 @@ export function useFlipRows(
|
||||
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
||||
// текущего положения на экране, иначе будет рывок.
|
||||
const visualTops = measureTops(nodes.current);
|
||||
// То же и для высоты обёртки: если анимация высоты ещё идёт, здесь мы
|
||||
// получим её промежуточное значение (после cancel ниже высота схлопнется к
|
||||
// натуральной, и «откуда ехать» было бы уже не восстановить).
|
||||
const visualHeight = container ? container.getBoundingClientRect().height : undefined;
|
||||
|
||||
running.current.forEach((a) => a.cancel());
|
||||
running.current = [];
|
||||
@@ -195,7 +213,38 @@ export function useFlipRows(
|
||||
|
||||
prevTops.current = current;
|
||||
|
||||
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
|
||||
// Высота: «куда» — натуральная (анимации уже отменены). «Откуда» — то, что
|
||||
// было видно: промежуточное значение недоигранной анимации, если она шла
|
||||
// (visualHeight разошёлся с натуральным), иначе высота прошлого коммита.
|
||||
const heightTo = container ? container.getBoundingClientRect().height : undefined;
|
||||
const heightFrom =
|
||||
visualHeight !== undefined && heightTo !== undefined && Math.abs(visualHeight - heightTo) >= MIN_SHIFT_PX
|
||||
? visualHeight
|
||||
: prevHeight.current;
|
||||
prevHeight.current = heightTo;
|
||||
const heightChanged =
|
||||
!!container &&
|
||||
heightFrom !== undefined &&
|
||||
heightTo !== undefined &&
|
||||
Math.abs(heightFrom - heightTo) >= MIN_SHIFT_PX;
|
||||
|
||||
// heightChanged в условии — отдельным слагаемым: при удалении ПОСЛЕДНЕЙ
|
||||
// строки ехать некому (moved и entered пусты), но высота всё равно
|
||||
// меняется, и без анимации нижняя карточка снова подпрыгнула бы.
|
||||
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0 && !heightChanged)) return;
|
||||
|
||||
if (heightChanged && container) {
|
||||
// Та же длительность и easing, что у переезда строк: нижняя граница
|
||||
// обёртки должна идти ровно за последней строкой, иначе рассинхрон видно.
|
||||
// Обрезку лишнего на время анимации берёт на себя overflow-y: hidden
|
||||
// (см. .matrixScroll в approval.module.scss).
|
||||
running.current.push(
|
||||
container.animate([{ height: `${heightFrom as number}px` }, { height: `${heightTo as number}px` }], {
|
||||
duration: MOVE_MS,
|
||||
easing: MOVE_EASING,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
moved.forEach(({ el, key, shift }) => {
|
||||
const isActive = activeKeys.indexOf(key) !== -1;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { SPHttpClient } from '@microsoft/sp-http';
|
||||
|
||||
/**
|
||||
* ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им
|
||||
@@ -22,10 +23,12 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
*/
|
||||
|
||||
let impersonatedEmail = '';
|
||||
let impersonatedDisplayName = '';
|
||||
|
||||
/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */
|
||||
export function setImpersonatedEmail(email: string | undefined): void {
|
||||
export function setImpersonatedIdentity(email: string | undefined, displayName?: string): void {
|
||||
impersonatedEmail = (email || '').trim();
|
||||
impersonatedDisplayName = (displayName || '').trim();
|
||||
}
|
||||
|
||||
/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */
|
||||
@@ -33,7 +36,89 @@ export function getImpersonatedEmail(): string {
|
||||
return impersonatedEmail;
|
||||
}
|
||||
|
||||
/** ФИО подменённого сотрудника, если его удалось найти поиском по имени ('' — для e-mail, введённого напрямую). */
|
||||
export function getImpersonatedDisplayName(): string {
|
||||
return impersonatedDisplayName;
|
||||
}
|
||||
|
||||
/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */
|
||||
export function effectiveUserEmail(context: WebPartContext): string {
|
||||
return impersonatedEmail || context.pageContext.user.email || '';
|
||||
}
|
||||
|
||||
export interface IResolvedDebugUser {
|
||||
email: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface IPeopleSearchHit {
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** Сырой ответ ClientPeoplePickerSearchUser — value внутри двойного JSON. */
|
||||
interface IPeoplePickerEntity {
|
||||
DisplayText?: string;
|
||||
Description?: string;
|
||||
EntityData?: { Email?: string };
|
||||
}
|
||||
|
||||
async function searchPeopleByText(context: WebPartContext, queryText: string): Promise<IPeopleSearchHit[]> {
|
||||
const url = `${context.pageContext.web.absoluteUrl}/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser`;
|
||||
const body = JSON.stringify({
|
||||
queryParams: {
|
||||
AllowEmailAddresses: true,
|
||||
AllowMultipleEntities: false,
|
||||
MaximumEntitySuggestions: 5,
|
||||
PrincipalSource: 15, // All
|
||||
PrincipalType: 1, // User
|
||||
QueryString: queryText
|
||||
}
|
||||
});
|
||||
|
||||
const response = await context.spHttpClient.post(url, SPHttpClient.configurations.v1, {
|
||||
headers: {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-Type': 'application/json;odata=nometadata'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const json: { value?: string } = await response.json();
|
||||
if (!json.value) return [];
|
||||
|
||||
let entries: IPeoplePickerEntity[];
|
||||
try {
|
||||
entries = JSON.parse(json.value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries
|
||||
.map((e) => ({ displayName: e.DisplayText || '', email: e.EntityData?.Email || e.Description || '' }))
|
||||
.filter((e) => !!e.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет сотрудника по имени для отладочной подмены (см. property pane —
|
||||
* PropertyPaneDebugUser/DebugUserField). Порядок «Фамилия Имя» или «Имя
|
||||
* Фамилия» не важен: если прямой запрос ничего не находит, пробуем с
|
||||
* переставленными первыми двумя словами.
|
||||
*/
|
||||
export async function resolveDebugUser(context: WebPartContext, nameQuery: string): Promise<IResolvedDebugUser | undefined> {
|
||||
const text = nameQuery.trim();
|
||||
if (!text) return undefined;
|
||||
|
||||
let hits = await searchPeopleByText(context, text);
|
||||
if (hits.length === 0) {
|
||||
const tokens = text.split(/\s+/).filter(Boolean);
|
||||
if (tokens.length >= 2) {
|
||||
const swapped = [tokens[1], tokens[0], ...tokens.slice(2)].join(' ');
|
||||
hits = await searchPeopleByText(context, swapped);
|
||||
}
|
||||
}
|
||||
|
||||
return hits[0];
|
||||
}
|
||||
|
||||
@@ -4,12 +4,21 @@
|
||||
// достаточно для нечастых событий (сохранение формы, решение согласующего).
|
||||
const EVENT_NAME = 'rp-requests-changed';
|
||||
|
||||
export function emitRequestsChanged(): void {
|
||||
window.dispatchEvent(new Event(EVENT_NAME));
|
||||
/**
|
||||
* force=true — повторный клик по УЖЕ открытому пункту меню (см. goto в
|
||||
* RequestPortal.tsx): пользователь явно просит обновить список, поэтому таблица
|
||||
* прячет прежние строки и показывает «Загрузка…», пока не придёт свежий ответ.
|
||||
* force=false/не задан — фоновые обновления (создание заявки, решение
|
||||
* согласующего, десятиминутный автообновление): строки остаются на экране, чтобы
|
||||
* не моргать, если данные не изменились.
|
||||
*/
|
||||
export function emitRequestsChanged(force?: boolean): void {
|
||||
window.dispatchEvent(new CustomEvent<{ force?: boolean }>(EVENT_NAME, { detail: { force } }));
|
||||
}
|
||||
|
||||
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
|
||||
export function onRequestsChanged(handler: () => void): () => void {
|
||||
window.addEventListener(EVENT_NAME, handler);
|
||||
return () => window.removeEventListener(EVENT_NAME, handler);
|
||||
export function onRequestsChanged(handler: (force?: boolean) => void): () => void {
|
||||
const listener = (e: Event): void => handler((e as CustomEvent<{ force?: boolean }>).detail?.force);
|
||||
window.addEventListener(EVENT_NAME, listener);
|
||||
return () => window.removeEventListener(EVENT_NAME, listener);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user