дебаг логи выключены
This commit is contained in:
@@ -12,6 +12,7 @@ import * as strings from 'RequestPortalWebPartStrings';
|
|||||||
import RequestPortal from './components/RequestPortal';
|
import RequestPortal from './components/RequestPortal';
|
||||||
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
||||||
import { initSP } from './shared/pnp/pnpjs';
|
import { initSP } from './shared/pnp/pnpjs';
|
||||||
|
import { ErrorOverlay } from './shared/ui';
|
||||||
|
|
||||||
export interface IRequestPortalWebPartProps {
|
export interface IRequestPortalWebPartProps {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -23,16 +24,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
private _environmentMessage: string = '';
|
private _environmentMessage: string = '';
|
||||||
|
|
||||||
public render(): void {
|
public render(): void {
|
||||||
const element: React.ReactElement<IRequestPortalProps> = React.createElement(
|
const element: React.ReactElement = React.createElement(
|
||||||
RequestPortal,
|
ErrorOverlay,
|
||||||
{
|
undefined,
|
||||||
|
React.createElement<IRequestPortalProps>(RequestPortal, {
|
||||||
description: this.properties.description,
|
description: this.properties.description,
|
||||||
isDarkTheme: this._isDarkTheme,
|
isDarkTheme: this._isDarkTheme,
|
||||||
environmentMessage: this._environmentMessage,
|
environmentMessage: this._environmentMessage,
|
||||||
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
||||||
userDisplayName: this.context.pageContext.user.displayName,
|
userDisplayName: this.context.pageContext.user.displayName,
|
||||||
context: this.context
|
context: this.context
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
ReactDom.render(element, this.domElement);
|
ReactDom.render(element, this.domElement);
|
||||||
|
|||||||
@@ -120,9 +120,6 @@ export async function getPendingApproval(
|
|||||||
const { types, index, debug } = await buildPendingIndex(context, scope);
|
const { types, index, debug } = await buildPendingIndex(context, scope);
|
||||||
|
|
||||||
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «На согласовании» (scope=${scope}): всего найдено ${index.length}, страница ${page} — ${pageRows.length} строк, дочитываем автора для них`
|
|
||||||
);
|
|
||||||
await hydrateAuthors(context, types, pageRows);
|
await hydrateAuthors(context, types, pageRows);
|
||||||
|
|
||||||
return { rows: pageRows, totalRows: index.length, debug };
|
return { rows: pageRows, totalRows: index.length, debug };
|
||||||
@@ -228,9 +225,6 @@ async function indexApprovalsForType(
|
|||||||
r.RequestStatus === REQUEST_STATUS.onApproval ||
|
r.RequestStatus === REQUEST_STATUS.onApproval ||
|
||||||
r.RequestStatus === REQUEST_STATUS.onExecution
|
r.RequestStatus === REQUEST_STATUS.onExecution
|
||||||
);
|
);
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
|
||||||
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
|
||||||
@@ -240,13 +234,8 @@ async function indexApprovalsForType(
|
|||||||
// зависимости от того, успел ли Search её проиндексировать к моменту
|
// зависимости от того, успел ли Search её проиндексировать к моменту
|
||||||
// REST-окна).
|
// REST-окна).
|
||||||
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
|
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, RequestStatus: r.RequestStatus })));
|
|
||||||
debug.approvalRowCount = stepRows.length;
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
|
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
|
||||||
@@ -341,10 +330,6 @@ async function getInvolvedRowsViaSearch(
|
|||||||
.map((r) => Number(r.ListItemID))
|
.map((r) => Number(r.ListItemID))
|
||||||
.filter((id) => !isNaN(id) && id > 0);
|
.filter((id) => !isNaN(id) && id > 0);
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] Search «${type.name}» (involved): KQL = ${kql}, найдено: ${total}, годных Id: ${ids.length}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (total > SEARCH_ROW_LIMIT) {
|
if (total > SEARCH_ROW_LIMIT) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
|
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
|
||||||
@@ -575,12 +560,6 @@ export async function getOwnRequests(
|
|||||||
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
|
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
|
||||||
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
|
||||||
|
|
||||||
if (onlyFresh.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`[Текущие заявки] «Мои заявки»: довесили ${onlyFresh.length} свежих заявок (< 10 мин), которых Search ещё не нашёл`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = onlyFresh
|
const merged = onlyFresh
|
||||||
.concat(searchPage.rows)
|
.concat(searchPage.rows)
|
||||||
.sort((a, b) => b.created.localeCompare(a.created))
|
.sort((a, b) => b.created.localeCompare(a.created))
|
||||||
@@ -621,8 +600,6 @@ export async function searchOwnRequests(
|
|||||||
SortList: [{ Property: 'Created', Direction: 1 }],
|
SortList: [{ Property: 'Created', Direction: 1 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Текущие заявки] Search «Мои заявки»: KQL = ${kql}, всего найдено: ${results.TotalRows}`);
|
|
||||||
console.table(results.PrimarySearchResults);
|
|
||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
@@ -670,8 +647,6 @@ export async function filterPendingByQuery(
|
|||||||
TrimDuplicates: false,
|
TrimDuplicates: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Текущие заявки] Search «На согласовании»: KQL = ${kql}, найдено: ${results.TotalRows}`);
|
|
||||||
console.table(results.PrimarySearchResults);
|
|
||||||
|
|
||||||
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
// ListItemID уникален только внутри своего списка — сверяем пару тип+Id.
|
||||||
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
|
const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`));
|
||||||
@@ -721,8 +696,6 @@ async function searchOwnAll(
|
|||||||
TrimDuplicates: false,
|
TrimDuplicates: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Текущие заявки] Search «Поиск» (мои заявки): KQL = ${kql}, найдено: ${results.TotalRows}`);
|
|
||||||
console.table(results.PrimarySearchResults);
|
|
||||||
|
|
||||||
const entries = hitsToEntries(results, types);
|
const entries = hitsToEntries(results, types);
|
||||||
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Пока по маршруту не принято ни одного решения (заявка не отправлена на
|
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
||||||
// согласование — черновик, доработка), колонки «Статус» и «Дата» пустые у всех
|
// Условия РАЗНЫЕ, потому что заполняются эти поля в разные моменты: статус
|
||||||
// строк: показывать столбец из одних прочерков смысла нет. Как только
|
// есть у строки сразу (у черновика — «Черновик»), а дата появляется только
|
||||||
// появляется первое решение — колонки возвращаются.
|
// вместе с решением согласующего. У черновика поэтому виден статус, но не дата.
|
||||||
const hasDecisions = rows.some((r) => (r.status && r.status !== '—') || r.date);
|
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||||
|
const hasDates = rows.some((r) => !!r.date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.matrixWrap}>
|
<div className={styles.matrixWrap}>
|
||||||
@@ -60,8 +61,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<th className={styles.matrixStepCol}>Шаг</th>
|
<th className={styles.matrixStepCol}>Шаг</th>
|
||||||
<th>{roleLabel}</th>
|
<th>{roleLabel}</th>
|
||||||
<th>Должность</th>
|
<th>Должность</th>
|
||||||
{hasDecisions && <th>Статус</th>}
|
{hasStatuses && <th>Статус</th>}
|
||||||
{hasDecisions && <th>Дата</th>}
|
{hasDates && <th>Дата</th>}
|
||||||
{editable && <th />}
|
{editable && <th />}
|
||||||
{editable && <th />}
|
{editable && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -82,10 +83,36 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
)}
|
)}
|
||||||
<td>{r.who}</td>
|
<td>{r.who}</td>
|
||||||
<td>{r.position}</td>
|
<td>{r.position}</td>
|
||||||
{hasDecisions && (
|
{hasStatuses && (
|
||||||
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||||
)}
|
)}
|
||||||
{hasDecisions && <td>{r.date}</td>}
|
{hasDates && <td>{r.date}</td>}
|
||||||
|
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||||
|
переезжают вместе), поэтому стрелки живут в отдельной
|
||||||
|
объединённой по вертикали ячейке: одна пара на весь шаг,
|
||||||
|
по центру его строк. */}
|
||||||
|
{editable && isBlockStart && (
|
||||||
|
<td className={styles.matrixMoveCol} rowSpan={block.size}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
disabled={block.index === 0}
|
||||||
|
onClick={() => editor.moveBlockUp(i)}
|
||||||
|
title="Шаг выше"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
disabled={block.index === blocks.length - 1}
|
||||||
|
onClick={() => editor.moveBlockDown(i)}
|
||||||
|
title="Шаг ниже"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-down" size="sm" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
{editable && (
|
{editable && (
|
||||||
<td className={styles.matrixActions}>
|
<td className={styles.matrixActions}>
|
||||||
<button
|
<button
|
||||||
@@ -111,30 +138,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{/* Перемещение — одно на весь шаг: параллельные строки
|
|
||||||
переезжают вместе, поэтому ячейка объединена по rowSpan. */}
|
|
||||||
{editable && isBlockStart && (
|
|
||||||
<td className={styles.matrixBlockMove} rowSpan={block.size}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.iconBtn}
|
|
||||||
disabled={block.index === 0}
|
|
||||||
onClick={() => editor.moveBlockUp(i)}
|
|
||||||
title="Шаг выше"
|
|
||||||
>
|
|
||||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.iconBtn}
|
|
||||||
disabled={block.index === blocks.length - 1}
|
|
||||||
onClick={() => editor.moveBlockDown(i)}
|
|
||||||
title="Шаг ниже"
|
|
||||||
>
|
|
||||||
<Icon name="chevron-down" size="sm" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -113,17 +113,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
|
||||||
|
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
|
||||||
|
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
|
||||||
.matrixActions {
|
.matrixActions {
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
align-items: center;
|
text-align: right;
|
||||||
gap: $sp-1;
|
vertical-align: middle;
|
||||||
justify-content: flex-end;
|
|
||||||
|
// Отступ между любыми соседними элементами: кроме кнопок тут бывают распорки
|
||||||
|
// (см. .iconBtnSpacer), на которые селектор .iconBtn + .iconBtn не сработал бы.
|
||||||
|
> * + * {
|
||||||
|
margin-left: $sp-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стрелки перемещения шага. Ячейка объединена по вертикали (rowSpan) на все
|
||||||
|
// строки шага, поэтому пара кнопок стоит по центру блока, а не у его первой
|
||||||
|
// строки. Как и .matrixActions — остаётся табличной ячейкой (см. комментарий
|
||||||
|
// там), кнопки держатся в строке через nowrap.
|
||||||
|
.matrixMoveCol {
|
||||||
|
width: 1%; // ужимает колонку по содержимому, ширину забирают текстовые колонки
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-left: $sp-1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconBtn {
|
.iconBtn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
// Инлайновые кнопки иначе выравниваются по baseline и «прыгают» относительно
|
||||||
|
// кнопок соседней ячейки.
|
||||||
|
vertical-align: middle;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -180,20 +206,6 @@
|
|||||||
color: $navy-700;
|
color: $navy-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перемещение шага целиком — тоже объединённая по вертикали ячейка, поэтому
|
|
||||||
// кнопки центрируем относительно всего блока.
|
|
||||||
// Та же flex-раскладка, что и у .matrixActions — иначе кнопки здесь (обычные
|
|
||||||
// инлайновые элементы) выравниваются по baseline и «прыгают» относительно
|
|
||||||
// кнопок соседней колонки, которая уже flex-контейнер.
|
|
||||||
.matrixBlockMove {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: $sp-1;
|
|
||||||
width: 64px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
|
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
|
||||||
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
|
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
|
||||||
// объединяют, но без фона строки визуально распадаются).
|
// объединяют, но без фона строки визуально распадаются).
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статус для ТОЛЬКО ЧТО ДОБАВЛЕННОЙ строки. Если у всех уже имеющихся строк
|
||||||
|
// статус одинаковый (маршрут целиком в одном состоянии — например, черновик),
|
||||||
|
// новая получает тот же: при сохранении writeRoute всё равно проставит всем
|
||||||
|
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
|
||||||
|
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
|
||||||
|
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
|
||||||
|
function statusForNewRow(rows: IMatrixRow[]): string {
|
||||||
|
if (rows.length === 0) return '—';
|
||||||
|
const first = rows[0].status || '—';
|
||||||
|
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
||||||
|
}
|
||||||
|
|
||||||
// Обратное преобразование для загруженных строк: одинаковый order у соседей
|
// Обратное преобразование для загруженных строк: одинаковый order у соседей
|
||||||
// означает параллельный шаг.
|
// означает параллельный шаг.
|
||||||
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
|
||||||
@@ -138,7 +150,7 @@ export function useMatrixEditor(
|
|||||||
who: user.name,
|
who: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
position,
|
position,
|
||||||
status: '—',
|
status: statusForNewRow(prev),
|
||||||
date: '',
|
date: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
allowReassign: false,
|
allowReassign: false,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Поверх всего, включая SPFx-хром вебпарта — иначе падение внутри вложенного
|
||||||
|
// блока схлопывает только его, а не видно, что вообще произошло.
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $sp-6;
|
||||||
|
background: rgba($navy-950, .78);
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 780px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: $sp-6;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
background: $white;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
border-top: 4px solid $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-form-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: $sp-4 0 0;
|
||||||
|
padding: $sp-3;
|
||||||
|
overflow: auto;
|
||||||
|
background: $gray-50;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-family: 'Consolas', 'Menlo', monospace;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-900;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-top: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyBtn {
|
||||||
|
@include btn-accent;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reloadBtn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
color: $navy-800;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $gold-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ErrorOverlay.module.scss';
|
||||||
|
|
||||||
|
interface ICaughtError {
|
||||||
|
message: string;
|
||||||
|
stack: string;
|
||||||
|
source: string; // откуда поймана: React-рендер, window.onerror, unhandledrejection
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IErrorOverlayState {
|
||||||
|
error: ICaughtError | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(err: unknown, source: string): ICaughtError {
|
||||||
|
const e = err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err));
|
||||||
|
return { message: e.message, stack: e.stack || '(нет стека)', source };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
|
||||||
|
// самого копирования не должна маскировать исходную — просто игнорируем.
|
||||||
|
function copyToClipboard(text: string): void {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench:
|
||||||
|
* 1) React-рендер (componentDidCatch);
|
||||||
|
* 2) синхронные ошибки вне React (window.onerror);
|
||||||
|
* 3) необработанные rejected promise (unhandledrejection).
|
||||||
|
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой
|
||||||
|
* «Скопировать», чтобы можно было переслать стек трейс.
|
||||||
|
*/
|
||||||
|
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
|
||||||
|
public state: IErrorOverlayState = { error: undefined };
|
||||||
|
|
||||||
|
private onWindowError = (event: ErrorEvent): void => {
|
||||||
|
this.setState({ error: formatError(event.error || event.message, 'window.onerror') });
|
||||||
|
};
|
||||||
|
|
||||||
|
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
|
||||||
|
this.setState({ error: formatError(event.reason, 'unhandledrejection') });
|
||||||
|
};
|
||||||
|
|
||||||
|
public componentDidMount(): void {
|
||||||
|
window.addEventListener('error', this.onWindowError);
|
||||||
|
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount(): void {
|
||||||
|
window.removeEventListener('error', this.onWindowError);
|
||||||
|
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
|
||||||
|
return { error: formatError(err, 'React render') };
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): React.ReactNode {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
const fullText = `[${error.source}] ${error.message}\n\n${error.stack}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.title}>Необработанное исключение</div>
|
||||||
|
<div className={styles.source}>{error.source}</div>
|
||||||
|
<pre className={styles.stack}>{fullText}</pre>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
|
||||||
|
Скопировать
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}>
|
||||||
|
Перезагрузить страницу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField';
|
|||||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||||
export { Icon } from './Icon/Icon';
|
export { Icon } from './Icon/Icon';
|
||||||
|
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
||||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||||
export { RequestNumber } from './RequestNumber/RequestNumber';
|
export { RequestNumber } from './RequestNumber/RequestNumber';
|
||||||
export { PrintButton } from './PrintButton/PrintButton';
|
export { PrintButton } from './PrintButton/PrintButton';
|
||||||
|
|||||||
Reference in New Issue
Block a user