обработка ошибок
This commit is contained in:
+15
-2
@@ -64,6 +64,14 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
onAddApprover,
|
||||
onReorder
|
||||
}) => {
|
||||
// Подсветка строки при наведении — на уровне БЛОКА, а не через CSS
|
||||
// tbody tr:hover (см. .rowHover в ManageRequestsScreen.module.scss): ячейки
|
||||
// с rowSpan (номер шага, стрелки перестановки) физически принадлежат <tr>
|
||||
// только первой строки блока, поэтому обычный :hover при наведении на
|
||||
// вторую/третью строку параллельного блока их не подсвечивал — получался
|
||||
// "вырез" в жёлтом фоне ровно на границе объединённой ячейки.
|
||||
const [hoveredBlock, setHoveredBlock] = React.useState<number | undefined>(undefined);
|
||||
|
||||
const withParallel = React.useMemo(() => markParallel(rows) as Row[], [rows]);
|
||||
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
|
||||
const lockedBlock = React.useMemo(
|
||||
@@ -131,7 +139,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
{rows.length === 0 && <div className={tableStyles.wrap}><div className={tableStyles.empty}>{texts.manageRequests.emptyPhase}</div></div>}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className={tableStyles.wrap}>
|
||||
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
|
||||
<div className={tableStyles.scroll}>
|
||||
<table className={tableStyles.table}>
|
||||
<thead>
|
||||
@@ -161,7 +169,12 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
const isGhost = r.id < 0;
|
||||
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<tr
|
||||
key={r.id}
|
||||
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
|
||||
onMouseEnter={() => setHoveredBlock(blockIndex)}
|
||||
onMouseLeave={() => setHoveredBlock(undefined)}
|
||||
>
|
||||
{isBlockStart && (
|
||||
<td className={styles.stepCol} rowSpan={block.end - block.start + 1}>
|
||||
{r.parallelWithPrev || block.end > block.start ? (
|
||||
|
||||
+17
@@ -49,6 +49,23 @@
|
||||
svg { transform: scaleY(-1); }
|
||||
}
|
||||
|
||||
// Подсветка строки при наведении здесь — своя, НЕ штатный DataTable.module.scss
|
||||
// tbody tr:hover: та подсветка вешается через :hover на конкретный <tr>, а
|
||||
// ячейки с rowSpan (номер шага, стрелки перестановки) физически принадлежат
|
||||
// <tr> только первой строки блока — при наведении на вторую/третью строку
|
||||
// параллельного блока эти ячейки не подсвечивались, получался видимый "вырез"
|
||||
// в жёлтом фоне ровно на границе объединённой ячейки. Поэтому подсветка здесь
|
||||
// — на уровне блока через React-состояние (см. hoveredBlock в
|
||||
// ManageRequestsPhaseTable), а не через CSS :hover, а штатный tr:hover для
|
||||
// этой конкретной таблицы отключаем, чтобы они не накладывались друг на друга.
|
||||
.blockAwareWrap :global(tbody tr:hover) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rowHover td {
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
// IconButton по умолчанию (Button.module.scss/.iconButton) прозрачная — рамка
|
||||
// появляется только по hover. В этой таблице кнопок много и часть из них не
|
||||
// самоочевидна по смыслу (параллельность, перестановка шага), поэтому здесь
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { findEmployeeByEmail } from '../../utils/employee';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
import { IManageRequestRouteParams, replaceRoute } from '../../routing';
|
||||
import {
|
||||
@@ -32,7 +33,8 @@ import {
|
||||
PageHeader,
|
||||
Panel,
|
||||
PrimaryCell,
|
||||
Select
|
||||
Select,
|
||||
Toast
|
||||
} from '../ui';
|
||||
|
||||
export interface IManageRequestsScreenProps {
|
||||
@@ -240,7 +242,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
return same ? prev : { ...prev, selectedRequest: m.request };
|
||||
});
|
||||
})
|
||||
.catch((e: Error) => setMatrixError(e.message))
|
||||
.catch((e: Error) => setMatrixError(reportError(e, 'reloadMatrix: getMatrix')))
|
||||
.finally(() => {
|
||||
if (!silent) setMatrixLoading(false);
|
||||
});
|
||||
@@ -307,11 +309,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// гарантированно удалена, а нумерация/активация в SharePoint в худшем
|
||||
// случае разойдётся с тем, что видно на экране (починится обычной
|
||||
// перестановкой любого шага той же фазы).
|
||||
return requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => setSaveError(e.message));
|
||||
return requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => setSaveError(reportError(e, 'confirmDelete: reorderRows')));
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
setSaveError(reportError(e, 'confirmDelete: removeRow'));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -319,8 +321,21 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
// от статуса отдельных строк (см. isFullyClosedRequestStatus).
|
||||
const readOnly = matrix ? isFullyClosedRequestStatus(matrix.request.status) : true;
|
||||
|
||||
const approvalRows = matrix ? matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution) : [];
|
||||
const executionRows = matrix ? matrix.rows.filter(r => r.phase === APPROVAL_PHASE.execution) : [];
|
||||
// Сортировка по order — ОБЯЗАТЕЛЬНА, а не косметика: ManageRequestsPhaseTable
|
||||
// считает блоки и параллельность по ФИЗИЧЕСКОМУ порядку строк в массиве
|
||||
// (markParallel сравнивает order с предыдущей строкой), а оптимистичные
|
||||
// обновления (handleReorder/confirmDelete) патчут только значения order,
|
||||
// оставляя порядок элементов массива прежним. Без пересортировки после
|
||||
// первой же перестановки массив перестаёт быть отсортированным, и следующий
|
||||
// клик по стрелке даёт пустой diffOrders — то есть кнопка молча перестаёт
|
||||
// работать до перезагрузки матрицы с сервера.
|
||||
//
|
||||
// Сортировка стабильная (ES2019+), поэтому строки параллельного шага
|
||||
// (одинаковый order) сохраняют относительный порядок.
|
||||
const sortByOrder = (rows: ManagedApprovalRow[]): ManagedApprovalRow[] => [...rows].sort((a, b) => a.order - b.order);
|
||||
|
||||
const approvalRows = matrix ? sortByOrder(matrix.rows.filter(r => r.phase !== APPROVAL_PHASE.execution)) : [];
|
||||
const executionRows = matrix ? sortByOrder(matrix.rows.filter(r => r.phase === APPROVAL_PHASE.execution)) : [];
|
||||
|
||||
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
|
||||
const closeModal = (): void => setModal(undefined);
|
||||
@@ -344,7 +359,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
closeModal();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setSaveError(e.message);
|
||||
setSaveError(reportError(e, 'saveModal: reassignApprover'));
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
@@ -399,7 +414,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
.then(() => reloadMatrix(true))
|
||||
.catch((e: Error) => {
|
||||
setMatrix(previous);
|
||||
setSaveError(e.message);
|
||||
setSaveError(reportError(e, 'handleAddApprover: addRow'));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -432,7 +447,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
|
||||
const writeUpdates = mergeRowUpdates(updates, activationUpdates);
|
||||
requestManage.reorderRows(matrix, writeUpdates).catch((e: Error) => {
|
||||
setSaveError(e.message);
|
||||
setSaveError(reportError(e, 'handleReorder: reorderRows'));
|
||||
reloadMatrix(true);
|
||||
});
|
||||
};
|
||||
@@ -477,7 +492,6 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
|
||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||
{matrixError && <Alert kind="err">{texts.manageRequests.loadError}: {matrixError}</Alert>}
|
||||
{saveError && <Alert kind="err">{texts.manageRequests.saveError}: {saveError}</Alert>}
|
||||
|
||||
{matrixLoading && <Panel>{texts.common.loading}</Panel>}
|
||||
|
||||
@@ -544,6 +558,10 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// position: fixed относительно viewport — веб-часть встроена в страницу
|
||||
// SharePoint, а не в отдельное модальное окно, поэтому уголок экрана, а не
|
||||
// уголок какого-то контейнера.
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
max-width: 380px;
|
||||
padding: 14px 16px;
|
||||
border-radius: $r-md;
|
||||
background: $red-50;
|
||||
color: $red-500;
|
||||
border: 1px solid #FECACA;
|
||||
box-shadow: $sh-3;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
animation: toastIn .15s ease-out;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: .6;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Toast.module.scss';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface IToastProps {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AUTO_HIDE_MS = 6000;
|
||||
|
||||
/**
|
||||
* Всплывающее сообщение об ошибке в углу экрана — для действий, которые не
|
||||
* ломают уже отрисованный экран (переназначение/перестановка/добавление шага):
|
||||
* пользователь и так видит, что изменение не применилось (см. reloadMatrix
|
||||
* после ошибки в ManageRequestsScreen), поэтому постоянный Alert-блок сверху
|
||||
* страницы был бы избыточен. Автоматически прячется через AUTO_HIDE_MS, либо
|
||||
* закрывается вручную.
|
||||
*/
|
||||
export const Toast: React.FC<IToastProps> = ({ message, onClose }) => {
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.toast} role="alert">
|
||||
<Icon name="warning" size="sm" />
|
||||
<span className={styles.message}>{message}</span>
|
||||
<button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ export * from './Button';
|
||||
export * from './IconButton';
|
||||
export * from './Badge';
|
||||
export * from './Alert';
|
||||
export * from './Toast';
|
||||
export * from './Panel';
|
||||
export * from './Breadcrumbs';
|
||||
export * from './PageHeader';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// PnPjs (getById(...).update(...) и т.п.) при ошибке SharePoint кладёт в
|
||||
// Error.message не только код HTTP, но и ВЕСЬ сырой текст ответа сервера —
|
||||
// это JSON вида {"odata.error":{"code":"...","message":{"lang":"ru-RU","value":"..."}}}.
|
||||
// Если просто вывести e.message пользователю, он увидит фигурные скобки,
|
||||
// "odata.error" и escape-последовательности \uXXXX прямо как текст (это НЕ
|
||||
// баг кодировки — JSON.parse их корректно раскрывает в кириллицу, но e.message
|
||||
// это просто СТРОКА, а не распарсенный объект, вот они и остаются как есть).
|
||||
|
||||
interface ISharePointErrorEnvelope {
|
||||
'odata.error'?: { message?: { value?: string } };
|
||||
}
|
||||
|
||||
/** Читаемый текст ошибки — вытаскивает odata.error.message.value, если он есть. */
|
||||
export function describeError(e: unknown): string {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const jsonStart = message.indexOf('{');
|
||||
if (jsonStart === -1) return message;
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(jsonStart)) as ISharePointErrorEnvelope;
|
||||
return parsed['odata.error']?.message?.value || message;
|
||||
} catch {
|
||||
// Не JSON (или обрезан) — отдаём как есть, лучше сырой текст, чем ничего.
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Полная ошибка — в консоль (для диагностики), пользователю возвращается только читаемый текст. */
|
||||
export function reportError(e: unknown, context: string): string {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[ApprovalAdmin] ${context}`, e);
|
||||
return describeError(e);
|
||||
}
|
||||
Reference in New Issue
Block a user