Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 1m8s
Tests / frontend-test (push) Successful in 1m8s
# Conflicts: # src/webparts/requestPortal/forms/Ticket1C/load.ts
This commit is contained in:
@@ -539,11 +539,20 @@ function freeTextClause(query: string): string | undefined {
|
||||
}
|
||||
|
||||
// Ответ Search → пары (Id элемента, его тип по префиксу Path).
|
||||
//
|
||||
// Дедуп по (тип, Id) — ЗДЕСЬ, а не у вызывающей стороны: во всех запросах
|
||||
// стоит TrimDuplicates: false (нужно, чтобы найти черновики и «свои» копии —
|
||||
// стандартная дедупликация Search иногда схлопывает и их), а без неё индекс
|
||||
// может вернуть один и тот же элемент несколькими хитами (переиндексация,
|
||||
// несколько «версий» в crawl). Раньше хиты превращались в строки один-в-один,
|
||||
// и дубликат из индекса становился дубликатом строки в таблице — с одинаковым
|
||||
// React-ключом, то есть видимым «залипанием» одной и той же заявки дважды.
|
||||
function hitsToEntries(
|
||||
results: { PrimarySearchResults: unknown[] },
|
||||
types: IRequestType[]
|
||||
): Array<{ id: number; type: IRequestType }> {
|
||||
const urls = types.map((t) => ({ type: t, url: listAbsUrl(t).toLowerCase() }));
|
||||
const seen = new Set<string>();
|
||||
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
|
||||
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '').toLowerCase() }))
|
||||
.filter((h) => !isNaN(h.id) && h.id > 0)
|
||||
@@ -551,7 +560,13 @@ function hitsToEntries(
|
||||
const matched = urls.filter((u) => h.path.indexOf(u.url) === 0)[0];
|
||||
return matched ? { id: h.id, type: matched.type } : undefined;
|
||||
})
|
||||
.filter((e): e is { id: number; type: IRequestType } => !!e);
|
||||
.filter((e): e is { id: number; type: IRequestType } => !!e)
|
||||
.filter((e) => {
|
||||
const key = `${e.type.id}-${e.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
|
||||
|
||||
@@ -31,5 +31,9 @@ export async function loadTicketRequest(itemId: number): Promise<ILoadedRequest>
|
||||
});
|
||||
data.TaskName = (item.Title as string) || '';
|
||||
|
||||
return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
||||
return {
|
||||
data,
|
||||
status: (item.RequestStatus as string) || '',
|
||||
code: (item.RequestUniqueCode as string) || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { installErrorLogging } from './diagnostics';
|
||||
import { describeReason, installErrorLogging } from './diagnostics';
|
||||
|
||||
// jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это
|
||||
// сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому
|
||||
@@ -62,3 +62,47 @@ describe('installErrorLogging', () => {
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// describeReason — то, что пользователь реально видит в отчёте об ошибке (и в
|
||||
// угловом уведомлении ErrorOverlay), поэтому пустых строк он давать не должен
|
||||
// ни на одном виде причины.
|
||||
describe('describeReason', () => {
|
||||
it('промис без причины описывается словами, а не пустой строкой', () => {
|
||||
// Здесь и ломалось: JSON.stringify(undefined) возвращает undefined, и
|
||||
// сообщение схлопывалось в пустоту — по отчёту нельзя было понять даже
|
||||
// того, что причины не было вовсе.
|
||||
expect(describeReason(undefined)).toContain('без причины');
|
||||
});
|
||||
|
||||
it('null тоже описывается', () => {
|
||||
expect(describeReason(null)).toContain('null');
|
||||
});
|
||||
|
||||
it('ошибка — имя и сообщение', () => {
|
||||
expect(describeReason(new TypeError('нет доступа'))).toBe('TypeError: нет доступа');
|
||||
});
|
||||
|
||||
it('ошибка с пустым сообщением не даёт пустую строку', () => {
|
||||
expect(describeReason(new Error(''))).toContain('пустое сообщение');
|
||||
});
|
||||
|
||||
it('строка возвращается как есть', () => {
|
||||
expect(describeReason('сервер недоступен')).toBe('сервер недоступен');
|
||||
});
|
||||
|
||||
it('обычный объект сериализуется', () => {
|
||||
expect(describeReason({ status: 404 })).toContain('404');
|
||||
});
|
||||
|
||||
it('циклическая ссылка не роняет разбор — остаются ключи', () => {
|
||||
// Внутри ошибок PnPjs лежит Response со ссылками на себя: JSON.stringify на
|
||||
// таком объекте бросает, и раньше это происходило прямо внутри обработчика
|
||||
// события.
|
||||
const circular: Record<string, unknown> = { status: 500 };
|
||||
circular.self = circular;
|
||||
|
||||
const text = describeReason(circular);
|
||||
expect(text).toContain('status');
|
||||
expect(text).toContain('self');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,14 +25,30 @@ let installed = false;
|
||||
|
||||
// Причиной отклонения может быть что угодно, не только Error — у PnPjs это
|
||||
// бывает объект с полями ответа. Разбираем по факту, а не по ожиданию.
|
||||
function describeReason(reason: unknown): string {
|
||||
if (reason instanceof Error) return `${reason.name}: ${reason.message}`;
|
||||
if (typeof reason === 'string') return reason;
|
||||
//
|
||||
// Отдельно разобраны случаи, из-за которых раньше в отчёт уходила пустая
|
||||
// строка. Промис можно отклонить вообще без причины (`Promise.reject()`), и
|
||||
// тогда reason === undefined: JSON.stringify возвращает на нём НЕ строку, а
|
||||
// undefined, из-за чего сообщение схлопывалось в пустоту и по отчёту нельзя
|
||||
// было понять даже того, что причины не было вовсе.
|
||||
export function describeReason(reason: unknown): string {
|
||||
if (reason === undefined) return 'промис отклонён без причины (reject без аргумента)';
|
||||
if (reason === null) return 'промис отклонён значением null';
|
||||
if (reason instanceof Error) {
|
||||
return `${reason.name}: ${reason.message || '(пустое сообщение)'}`;
|
||||
}
|
||||
if (typeof reason === 'string') return reason || '(пустая строка)';
|
||||
if (typeof reason !== 'object') return `${typeof reason}: ${String(reason)}`;
|
||||
|
||||
// Тип объекта указываем всегда: у PnPjs/SharePoint по одному имени класса
|
||||
// (HttpRequestError и т.п.) уже понятно, куда смотреть.
|
||||
const kind = (reason as { constructor?: { name?: string } }).constructor?.name || 'Object';
|
||||
try {
|
||||
return JSON.stringify(reason);
|
||||
return `${kind}: ${JSON.stringify(reason)}`;
|
||||
} catch {
|
||||
// Циклические ссылки — тогда хотя бы тип.
|
||||
return Object.prototype.toString.call(reason);
|
||||
// Циклические ссылки (внутри ошибок PnPjs лежит Response) — тогда хотя бы
|
||||
// перечислим ключи: по ним видно, что это за объект.
|
||||
return `${kind}: {${Object.keys(reason as object).join(', ')}}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,3 +83,73 @@
|
||||
border-color: $gold-400;
|
||||
}
|
||||
}
|
||||
|
||||
// Уведомление об АСИНХРОННОЙ ошибке: в углу, поверх содержимого, но ничего не
|
||||
// перекрывает и не блокирует — портал остаётся рабочим (см. ErrorOverlay.tsx о
|
||||
// том, почему такие ошибки больше не схлопывают вебпарт).
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: $sp-4;
|
||||
bottom: $sp-4;
|
||||
z-index: 999998;
|
||||
box-sizing: border-box;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - #{$sp-4} * 2);
|
||||
padding: $sp-3 $sp-4;
|
||||
border-radius: $r-md;
|
||||
border-left: 4px solid $red-500;
|
||||
background: $white;
|
||||
box-shadow: $sh-modal;
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.toastHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.toastTitle {
|
||||
font-size: $fs-sm;
|
||||
font-weight: 600;
|
||||
color: $navy-950;
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: $gray-500;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $navy-800;
|
||||
}
|
||||
}
|
||||
|
||||
.toastText {
|
||||
margin-top: $sp-1;
|
||||
max-height: 96px;
|
||||
overflow: auto;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-900;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toastCopy {
|
||||
margin-top: $sp-2;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $navy-800;
|
||||
font-size: $fs-caption;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: $gold-400;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { describeReason } from '../../diagnostics';
|
||||
import styles from './ErrorOverlay.module.scss';
|
||||
|
||||
interface ICaughtError {
|
||||
@@ -8,12 +9,30 @@ interface ICaughtError {
|
||||
}
|
||||
|
||||
interface IErrorOverlayState {
|
||||
error: ICaughtError | undefined;
|
||||
/** Падение самого рендера — дерево сломано, показывать нечего. */
|
||||
fatal: ICaughtError | undefined;
|
||||
/** Асинхронная ошибка со страницы — портал работает, это просто уведомление. */
|
||||
notice: 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 };
|
||||
// Стек берём ТОЛЬКО у настоящей ошибки. Раньше на любую причину строился
|
||||
// `new Error(...)` прямо здесь — и в отчёт уходил стек этого конструктора,
|
||||
// то есть самого ErrorOverlay:
|
||||
//
|
||||
// Error
|
||||
// at vp (request-portal-web-part_<hash>.js:9:397946)
|
||||
// at t.onUnhandledRejection (...)
|
||||
//
|
||||
// Место, где промис реально отклонился, при этом терялось полностью.
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
message: err.message || describeReason(err),
|
||||
stack: err.stack || '(нет стека)',
|
||||
source,
|
||||
};
|
||||
}
|
||||
return { message: describeReason(err), stack: '(стека нет — причина не Error)', source };
|
||||
}
|
||||
|
||||
// Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
|
||||
@@ -22,25 +41,43 @@ function copyToClipboard(text: string): void {
|
||||
navigator.clipboard.writeText(text).catch(() => undefined);
|
||||
}
|
||||
|
||||
function fullTextOf(error: ICaughtError): string {
|
||||
return `[${error.source}] ${error.message}\n\n${error.stack}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench:
|
||||
* 1) React-рендер (componentDidCatch);
|
||||
* 2) синхронные ошибки вне React (window.onerror);
|
||||
* 3) необработанные rejected promise (unhandledrejection).
|
||||
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой
|
||||
* «Скопировать», чтобы можно было переслать стек трейс.
|
||||
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench.
|
||||
*
|
||||
* Разделение на fatal и notice — не косметика. Слушатели `error` и
|
||||
* `unhandledrejection` вешаются на window, то есть срабатывают на ВСЮ страницу
|
||||
* SharePoint: на другие вебпарты, на скрипты самого SharePoint, на любые
|
||||
* фоновые промисы. Раньше любое такое отклонение — даже постороннее и
|
||||
* безобидное — заменяло собой весь портал: вместо открытой заявки пользователь
|
||||
* получал экран «Необработанное исключение». Именно так это и проявлялось при
|
||||
* переходе по ссылке на заявку.
|
||||
*
|
||||
* Теперь портал заменяется только при падении РЕНДЕРА (там показывать
|
||||
* действительно нечего), а асинхронные ошибки — угловое уведомление, которое
|
||||
* можно закрыть; работать оно не мешает. Полный разбор причины при этом всё
|
||||
* равно уходит в консоль — см. shared/diagnostics.ts.
|
||||
*/
|
||||
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
|
||||
public state: IErrorOverlayState = { error: undefined };
|
||||
public state: IErrorOverlayState = { fatal: undefined, notice: undefined };
|
||||
|
||||
private onWindowError = (event: ErrorEvent): void => {
|
||||
this.setState({ error: formatError(event.error || event.message, 'window.onerror') });
|
||||
this.showNotice(formatError(event.error || event.message, 'window.onerror'));
|
||||
};
|
||||
|
||||
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
|
||||
this.setState({ error: formatError(event.reason, 'unhandledrejection') });
|
||||
this.showNotice(formatError(event.reason, 'unhandledrejection'));
|
||||
};
|
||||
|
||||
private showNotice(notice: ICaughtError): void {
|
||||
// Первое уведомление не затираем: за одной ошибкой обычно летит несколько
|
||||
// производных, а разбираться нужно с исходной.
|
||||
this.setState((prev) => (prev.notice ? prev : { ...prev, notice }));
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
window.addEventListener('error', this.onWindowError);
|
||||
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||
@@ -51,32 +88,65 @@ export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknow
|
||||
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
|
||||
return { error: formatError(err, 'React render') };
|
||||
public static getDerivedStateFromError(err: unknown): Partial<IErrorOverlayState> {
|
||||
return { fatal: formatError(err, 'React render') };
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
const { fatal, notice } = this.state;
|
||||
|
||||
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>
|
||||
if (fatal) {
|
||||
const fullText = fullTextOf(fatal);
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.title}>Необработанное исключение</div>
|
||||
<div className={styles.source}>{fatal.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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.props.children}
|
||||
{notice && (
|
||||
<div className={styles.toast} role="alert">
|
||||
<div className={styles.toastHead}>
|
||||
<span className={styles.toastTitle}>Что-то пошло не так</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toastClose}
|
||||
aria-label="Закрыть уведомление"
|
||||
onClick={() => this.setState({ notice: undefined })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.toastText}>{notice.message}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toastCopy}
|
||||
onClick={() => copyToClipboard(fullTextOf(notice))}
|
||||
>
|
||||
Скопировать детали
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user