diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index c68f148..3ee3ac1 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -12,6 +12,7 @@ import * as strings from 'RequestPortalWebPartStrings'; import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; +import { installErrorLogging } from './shared/diagnostics'; import { ErrorOverlay } from './shared/ui'; export interface IRequestPortalWebPartProps { @@ -41,6 +42,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { + installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения initSP(this.context); // единый клиент PnPjs для записи в списки return this._getEnvironmentMessage().then(message => { this._environmentMessage = message; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 4ea8fa0..2e564df 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -37,11 +37,20 @@ // Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска // и прижат к нему: вместе это один блок «чем сузить список». .statusFilter { + // appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная + // стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала + // выглядит чужеродно (и по-разному в разных браузерах). + appearance: none; margin-left: auto; - padding: 9px 13px; + // Справа место под галочку; по вертикали — как у .search, чтобы оба контрола + // в шапке были одной высоты. + padding: 9px 34px 9px 13px; border: 1px solid $gray-300; border-radius: $r-sm; - background: $white; + background-color: $white; + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; font-family: $font-sans; font-size: $fs-sm; color: $gray-900; diff --git a/src/webparts/requestPortal/shared/diagnostics.test.ts b/src/webparts/requestPortal/shared/diagnostics.test.ts new file mode 100644 index 0000000..fbae34f --- /dev/null +++ b/src/webparts/requestPortal/shared/diagnostics.test.ts @@ -0,0 +1,64 @@ +import { installErrorLogging } from './diagnostics'; + +// jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это +// сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому +// собираем обычное Event и дописываем reason (слушатель читает только его). +function emitRejection(reason: unknown): void { + const event = new Event('unhandledrejection') as Event & { reason?: unknown }; + event.reason = reason; + window.dispatchEvent(event); +} + +describe('installErrorLogging', () => { + let errors: unknown[][]; + let spy: jest.SpyInstance; + + beforeEach(() => { + errors = []; + spy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args); + }); + installErrorLogging(); + }); + + afterEach(() => spy.mockRestore()); + + it('печатает сообщение и стек обычной ошибки', () => { + emitRejection(new Error('Список недоступен')); + + const text = errors.map((a) => a.join(' ')).join('\n'); + expect(text).toContain('Список недоступен'); + expect(text).toContain('Стек'); + }); + + it('не теряет причину, если это не Error, а объект (типичный отказ PnPjs)', () => { + emitRejection({ status: 403, message: 'Access denied' }); + + const text = errors.map((a) => a.map((x) => JSON.stringify(x)).join(' ')).join('\n'); + expect(text).toContain('403'); + expect(text).toContain('Access denied'); + }); + + it('не падает на причине с циклической ссылкой', () => { + const cyclic: Record = { name: 'зацикленное' }; + cyclic.self = cyclic; + + expect(() => emitRejection(cyclic)).not.toThrow(); + expect(errors.length).toBeGreaterThan(0); + }); + + it('строка-причина попадает в лог как есть', () => { + emitRejection('просто текст'); + expect(errors.map((a) => a.join(' ')).join('\n')).toContain('просто текст'); + }); + + it('повторный вызов не вешает второй слушатель — ошибка не двоится', () => { + installErrorLogging(); + installErrorLogging(); + + emitRejection(new Error('однократно')); + + const count = errors.filter((a) => String(a[0]).indexOf('Необработанная ошибка') !== -1).length; + expect(count).toBe(1); + }); +}); diff --git a/src/webparts/requestPortal/shared/diagnostics.ts b/src/webparts/requestPortal/shared/diagnostics.ts new file mode 100644 index 0000000..6df3d74 --- /dev/null +++ b/src/webparts/requestPortal/shared/diagnostics.ts @@ -0,0 +1,51 @@ +// Читаемый лог необработанных отклонений промисов. +// +// Зачем: SPFx вешает на window свой обработчик unhandledrejection, и в консоль +// попадает только его собственный стек внутри минифицированного бандла — +// примерно такой: +// +// Error +// at vp (...request-portal-web-part_.js:9:397946) +// at t.onUnhandledRejection (...) +// +// Ни причины, ни места, где промис отклонился, там нет: SPFx логирует САМ факт, +// а не `reason`. Поэтому вешаем свой слушатель — он срабатывает наравне с +// SPFx-овским и печатает то, что действительно нужно: сообщение, стек и сам +// объект причины (у ошибок PnPjs/SharePoint внутри лежит ответ сервера). +// +// Ничего не подавляем (preventDefault не зовём) — поведение портала не +// меняется, добавляется только диагностика. + +const PREFIX = '[Портал заявок]'; + +// Слушатель вешается один раз на страницу: на странице может быть несколько +// экземпляров веб-части, и каждый вызвал бы install() в onInit — без флага +// одна и та же ошибка печаталась бы столько раз, сколько их размещено. +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; + try { + return JSON.stringify(reason); + } catch { + // Циклические ссылки — тогда хотя бы тип. + return Object.prototype.toString.call(reason); + } +} + +export function installErrorLogging(): void { + if (installed) return; + installed = true; + + window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + const reason: unknown = e.reason; + console.error(`${PREFIX} Необработанная ошибка: ${describeReason(reason)}`); + // Стек и объект — отдельными аргументами: так консоль браузера даёт + // раскрыть объект и перейти по стеку, а не показывает «[object Object]». + if (reason instanceof Error && reason.stack) console.error(`${PREFIX} Стек:`, reason.stack); + console.error(`${PREFIX} Причина целиком:`, reason); + }); +}