Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
@@ -141,6 +141,10 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
position: r.position,
|
position: r.position,
|
||||||
allowReassign: r.allowReassign,
|
allowReassign: r.allowReassign,
|
||||||
order: r.order,
|
order: r.order,
|
||||||
|
// Транзитом из маршрута в строку списка: наследование бэкенд применил
|
||||||
|
// при resolve, форма только переносит значения (см. IMatrixRow.slaHours).
|
||||||
|
slaHours: r.slaHours,
|
||||||
|
askOverdueReason: r.askOverdueReason,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, con
|
|||||||
email: r.email,
|
email: r.email,
|
||||||
position: r.position,
|
position: r.position,
|
||||||
allowReassign: r.allowReassign,
|
allowReassign: r.allowReassign,
|
||||||
order: r.order
|
order: r.order,
|
||||||
|
// Транзитом из маршрута в строку списка: наследование бэкенд применил
|
||||||
|
// при resolve, форма только переносит значения (см. IMatrixRow.slaHours).
|
||||||
|
slaHours: r.slaHours,
|
||||||
|
askOverdueReason: r.askOverdueReason
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleRemoveAttachment = (fileName: string): void => {
|
const handleRemoveAttachment = (fileName: string): void => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
|||||||
email: r.email,
|
email: r.email,
|
||||||
position: r.position,
|
position: r.position,
|
||||||
allowReassign: r.allowReassign,
|
allowReassign: r.allowReassign,
|
||||||
order: r.order
|
order: r.order,
|
||||||
|
// Транзитом из маршрута в строку списка: наследование бэкенд применил
|
||||||
|
// при resolve, форма только переносит значения (см. IMatrixRow.slaHours).
|
||||||
|
slaHours: r.slaHours,
|
||||||
|
askOverdueReason: r.askOverdueReason
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleRemoveAttachment = (fileName: string): void => {
|
const handleRemoveAttachment = (fileName: string): void => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,11 @@ const VacationForm: React.FC<IRequestFormProps> = ({ requestType, context, onCan
|
|||||||
email: r.email,
|
email: r.email,
|
||||||
position: r.position,
|
position: r.position,
|
||||||
allowReassign: r.allowReassign,
|
allowReassign: r.allowReassign,
|
||||||
order: r.order
|
order: r.order,
|
||||||
|
// Транзитом из маршрута в строку списка: наследование бэкенд применил
|
||||||
|
// при resolve, форма только переносит значения (см. IMatrixRow.slaHours).
|
||||||
|
slaHours: r.slaHours,
|
||||||
|
askOverdueReason: r.askOverdueReason
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
position: r.position,
|
position: r.position,
|
||||||
allowReassign: r.allowReassign,
|
allowReassign: r.allowReassign,
|
||||||
order: r.order,
|
order: r.order,
|
||||||
|
// Транзитом из маршрута в строку списка: наследование бэкенд применил
|
||||||
|
// при resolve, форма только переносит значения (см. IMatrixRow.slaHours).
|
||||||
|
slaHours: r.slaHours,
|
||||||
|
askOverdueReason: r.askOverdueReason,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const FIELDS = {
|
|||||||
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
||||||
phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение
|
phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение
|
||||||
delegatedBy: 'DelegatedBy', // «Кем переназначено» (User) → DelegatedById
|
delegatedBy: 'DelegatedBy', // «Кем переназначено» (User) → DelegatedById
|
||||||
|
slaHours: 'SLAhours', // «SLA, часов» (Number) — пусто значит «взять у типа заявки»
|
||||||
|
askOverdueReason: 'AskOverdueReason', // «Опрос причины просрочки» (Да/Нет)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Согласующий для записи в список.
|
// Согласующий для записи в список.
|
||||||
@@ -45,6 +47,18 @@ export interface IApproverToWrite {
|
|||||||
* (ApprovalMatrix/useMatrixEditor) всегда даёт уникальный order по позиции —
|
* (ApprovalMatrix/useMatrixEditor) всегда даёт уникальный order по позиции —
|
||||||
* это поле для параллельных шагов, приходящих из внешнего маршрута напрямую. */
|
* это поле для параллельных шагов, приходящих из внешнего маршрута напрямую. */
|
||||||
order?: number;
|
order?: number;
|
||||||
|
/**
|
||||||
|
* SLA шага в часах и признак опроса причины просрочки — приходят из resolve
|
||||||
|
* с уже применённым наследованием «шаг перекрывает тип заявки» (см. бэкенд
|
||||||
|
* ResolveStepAsync). Портал их не вычисляет, только переносит в строку.
|
||||||
|
*
|
||||||
|
* undefined/null — «в строке не задано»: тогда напоминания используют SLA
|
||||||
|
* типа заявки. Ноль — это НЕ пусто, а «напоминать сразу».
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null = «не задано» (в SharePoint пустая колонка)
|
||||||
|
slaHours?: number | null;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
|
||||||
|
askOverdueReason?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Все запросы к списку «<форма> — согласование». listTitle задаётся формой.
|
// Все запросы к списку «<форма> — согласование». listTitle задаётся формой.
|
||||||
@@ -101,6 +115,9 @@ export class ApprovalListService {
|
|||||||
[FIELDS.order]: order,
|
[FIELDS.order]: order,
|
||||||
[FIELDS.allowReassign]: a.allowReassign,
|
[FIELDS.allowReassign]: a.allowReassign,
|
||||||
[FIELDS.phase]: phase,
|
[FIELDS.phase]: phase,
|
||||||
|
// ?? null, а не || null: ноль часов — значимое значение.
|
||||||
|
[FIELDS.slaHours]: a.slaHours ?? null,
|
||||||
|
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +239,8 @@ export class ApprovalListService {
|
|||||||
FIELDS.order,
|
FIELDS.order,
|
||||||
FIELDS.allowReassign,
|
FIELDS.allowReassign,
|
||||||
FIELDS.phase,
|
FIELDS.phase,
|
||||||
|
FIELDS.slaHours,
|
||||||
|
FIELDS.askOverdueReason,
|
||||||
`${FIELDS.approver}/Title`,
|
`${FIELDS.approver}/Title`,
|
||||||
`${FIELDS.approver}/EMail`
|
`${FIELDS.approver}/EMail`
|
||||||
)
|
)
|
||||||
@@ -240,6 +259,11 @@ export class ApprovalListService {
|
|||||||
comment: (r[FIELDS.comment] as string) || '',
|
comment: (r[FIELDS.comment] as string) || '',
|
||||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||||
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
||||||
|
// Читаем, чтобы не потерять при пересохранении: writeRoute удаляет
|
||||||
|
// строки и создаёт их заново из матрицы, и без этих полей SLA,
|
||||||
|
// проставленный при первой отправке, обнулился бы у черновика.
|
||||||
|
slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
|
||||||
|
askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export async function getPlannedMatrix(
|
|||||||
comment: '',
|
comment: '',
|
||||||
allowReassign: s.allowReassignToOtherApprovers,
|
allowReassign: s.allowReassignToOtherApprovers,
|
||||||
phase,
|
phase,
|
||||||
|
slaHours: s.slaHours,
|
||||||
|
askOverdueReason: s.askOverdueReason,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,12 @@ export interface IResolvedRouteStep {
|
|||||||
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
||||||
// конкретного человека) — приходит от API, показываем автору при желании.
|
// конкретного человека) — приходит от API, показываем автору при желании.
|
||||||
warning: string | null;
|
warning: string | null;
|
||||||
|
// SLA шага в часах и признак опроса причины просрочки. Наследование
|
||||||
|
// «шаг перекрывает тип заявки» бэкенд уже применил — здесь итоговое значение,
|
||||||
|
// его достаточно записать в строку списка согласования.
|
||||||
|
// null — SLA не настроен, напоминаний по шагу не будет.
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||||
|
slaHours: number | null;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
|
||||||
|
askOverdueReason: boolean | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,16 @@ export interface IMatrixRow {
|
|||||||
// редактировании — переключается кнопкой и заново раздаёт order (см.
|
// редактировании — переключается кнопкой и заново раздаёт order (см.
|
||||||
// useMatrixEditor/withOrders).
|
// useMatrixEditor/withOrders).
|
||||||
parallelWithPrev?: boolean;
|
parallelWithPrev?: boolean;
|
||||||
|
/**
|
||||||
|
* SLA шага и признак опроса причины просрочки. Матрица здесь — транзитное
|
||||||
|
* звено: значения приходят из resolve (или читаются из уже записанных строк)
|
||||||
|
* и уходят обратно в список при сохранении. На форме они не показываются и
|
||||||
|
* не редактируются — правятся в админке.
|
||||||
|
*
|
||||||
|
* null/undefined — «не задано», работает SLA типа заявки. Ноль — «сразу».
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null приходит из SharePoint и значит «не задано»
|
||||||
|
slaHours?: number | null;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
|
||||||
|
askOverdueReason?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
|
||||||
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||||
const hasDates = rows.some((r) => !!r.date);
|
const hasDates = rows.some((r) => !!r.date);
|
||||||
|
// slaHours на строке — уже ИТОГОВОЕ значение с применённым наследованием
|
||||||
|
// (шаг перекрывает тип заявки): для черновика его так отдаёт resolve
|
||||||
|
// (getPlannedMatrix), для поданной заявки — то, что записали в строку при
|
||||||
|
// отправке (см. writeRoute). Здесь его достаточно просто показать, повторно
|
||||||
|
// резолвить нечего.
|
||||||
|
const hasSla = rows.some((r) => r.slaHours !== undefined && r.slaHours !== null);
|
||||||
|
|
||||||
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
||||||
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
||||||
@@ -101,6 +107,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<th>Должность</th>
|
<th>Должность</th>
|
||||||
{hasStatuses && <th>Статус</th>}
|
{hasStatuses && <th>Статус</th>}
|
||||||
{hasDates && <th>Дата</th>}
|
{hasDates && <th>Дата</th>}
|
||||||
|
{hasSla && <th>SLA</th>}
|
||||||
{editable && <th />}
|
{editable && <th />}
|
||||||
{editable && <th />}
|
{editable && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -137,6 +144,9 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{hasDates && <td data-label="Дата">{r.date}</td>}
|
{hasDates && <td data-label="Дата">{r.date}</td>}
|
||||||
|
{hasSla && (
|
||||||
|
<td data-label="SLA">{r.slaHours || r.slaHours === 0 ? `${r.slaHours} ч` : '—'}</td>
|
||||||
|
)}
|
||||||
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||||
переезжают вместе): одна пара стрелок на весь шаг. */}
|
переезжают вместе): одна пара стрелок на весь шаг. */}
|
||||||
{editable && isBlockStart && (
|
{editable && isBlockStart && (
|
||||||
|
|||||||
@@ -52,16 +52,26 @@ export function describeReason(reason: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logError(kind: string, reason: unknown): void {
|
||||||
|
console.error(`${PREFIX} ${kind}: ${describeReason(reason)}`);
|
||||||
|
// Стек и объект — отдельными аргументами: так консоль браузера даёт
|
||||||
|
// раскрыть объект и перейти по стеку, а не показывает «[object Object]».
|
||||||
|
if (reason instanceof Error && reason.stack) console.error(`${PREFIX} Стек:`, reason.stack);
|
||||||
|
console.error(`${PREFIX} Причина целиком:`, reason);
|
||||||
|
}
|
||||||
|
|
||||||
export function installErrorLogging(): void {
|
export function installErrorLogging(): void {
|
||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
const reason: unknown = e.reason;
|
logError('Необработанная ошибка', e.reason);
|
||||||
console.error(`${PREFIX} Необработанная ошибка: ${describeReason(reason)}`);
|
});
|
||||||
// Стек и объект — отдельными аргументами: так консоль браузера даёт
|
|
||||||
// раскрыть объект и перейти по стеку, а не показывает «[object Object]».
|
// Синхронные ошибки страницы. Раньше их ловил ErrorOverlay ради углового
|
||||||
if (reason instanceof Error && reason.stack) console.error(`${PREFIX} Стек:`, reason.stack);
|
// уведомления; уведомление убрано (чужая ошибка со страницы SharePoint
|
||||||
console.error(`${PREFIX} Причина целиком:`, reason);
|
// выглядела как поломка портала), а разбор нужен — поэтому он переехал сюда.
|
||||||
|
window.addEventListener('error', (e: ErrorEvent) => {
|
||||||
|
logError('Ошибка на странице', e.error || e.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,73 +83,3 @@
|
|||||||
border-color: $gold-400;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import styles from './ErrorOverlay.module.scss';
|
|||||||
interface ICaughtError {
|
interface ICaughtError {
|
||||||
message: string;
|
message: string;
|
||||||
stack: string;
|
stack: string;
|
||||||
source: string; // откуда поймана: React-рендер, window.onerror, unhandledrejection
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IErrorOverlayState {
|
interface IErrorOverlayState {
|
||||||
/** Падение самого рендера — дерево сломано, показывать нечего. */
|
/** Падение самого рендера — дерево сломано, показывать нечего. */
|
||||||
fatal: ICaughtError | undefined;
|
fatal: ICaughtError | undefined;
|
||||||
/** Асинхронная ошибка со страницы — портал работает, это просто уведомление. */
|
|
||||||
notice: ICaughtError | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(err: unknown, source: string): ICaughtError {
|
function formatError(err: unknown, source: string): ICaughtError {
|
||||||
@@ -24,7 +22,7 @@ function formatError(err: unknown, source: string): ICaughtError {
|
|||||||
// at vp (request-portal-web-part_<hash>.js:9:397946)
|
// at vp (request-portal-web-part_<hash>.js:9:397946)
|
||||||
// at t.onUnhandledRejection (...)
|
// at t.onUnhandledRejection (...)
|
||||||
//
|
//
|
||||||
// Место, где промис реально отклонился, при этом терялось полностью.
|
// Место, где ошибка реально возникла, при этом терялось полностью.
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return {
|
return {
|
||||||
message: err.message || describeReason(err),
|
message: err.message || describeReason(err),
|
||||||
@@ -41,62 +39,30 @@ function copyToClipboard(text: string): void {
|
|||||||
navigator.clipboard.writeText(text).catch(() => undefined);
|
navigator.clipboard.writeText(text).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fullTextOf(error: ICaughtError): string {
|
|
||||||
return `[${error.source}] ${error.message}\n\n${error.stack}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench.
|
* Граница ошибок React: ловит падение РЕНДЕРА, из-за которого вебпарт иначе
|
||||||
|
* схлопывается в пустое место, и показывает вместо него текст ошибки со стеком.
|
||||||
*
|
*
|
||||||
* Разделение на fatal и notice — не косметика. Слушатели `error` и
|
* Асинхронные ошибки страницы (`unhandledrejection`, `window.onerror`) здесь
|
||||||
* `unhandledrejection` вешаются на window, то есть срабатывают на ВСЮ страницу
|
* НЕ ловятся намеренно. Слушатели на window срабатывают на всю страницу
|
||||||
* SharePoint: на другие вебпарты, на скрипты самого SharePoint, на любые
|
* SharePoint — на другие вебпарты, на скрипты самого SharePoint, на любые
|
||||||
* фоновые промисы. Раньше любое такое отклонение — даже постороннее и
|
* фоновые промисы, — и показывать по ним что-то пользователю смысла нет: портал
|
||||||
* безобидное — заменяло собой весь портал: вместо открытой заявки пользователь
|
* при этом работает, а чужая ошибка выглядит как поломка портала. Разбор таких
|
||||||
* получал экран «Необработанное исключение». Именно так это и проявлялось при
|
* ошибок уходит в консоль, см. shared/diagnostics.ts.
|
||||||
* переходе по ссылке на заявку.
|
|
||||||
*
|
|
||||||
* Теперь портал заменяется только при падении РЕНДЕРА (там показывать
|
|
||||||
* действительно нечего), а асинхронные ошибки — угловое уведомление, которое
|
|
||||||
* можно закрыть; работать оно не мешает. Полный разбор причины при этом всё
|
|
||||||
* равно уходит в консоль — см. shared/diagnostics.ts.
|
|
||||||
*/
|
*/
|
||||||
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
|
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
|
||||||
public state: IErrorOverlayState = { fatal: undefined, notice: undefined };
|
public state: IErrorOverlayState = { fatal: undefined };
|
||||||
|
|
||||||
private onWindowError = (event: ErrorEvent): void => {
|
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
|
||||||
this.showNotice(formatError(event.error || event.message, 'window.onerror'));
|
|
||||||
};
|
|
||||||
|
|
||||||
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
|
||||||
window.removeEventListener('error', this.onWindowError);
|
|
||||||
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getDerivedStateFromError(err: unknown): Partial<IErrorOverlayState> {
|
|
||||||
return { fatal: formatError(err, 'React render') };
|
return { fatal: formatError(err, 'React render') };
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
const { fatal, notice } = this.state;
|
const { fatal } = this.state;
|
||||||
|
if (!fatal) return this.props.children;
|
||||||
|
|
||||||
|
const fullText = `[${fatal.source}] ${fatal.message}\n\n${fatal.stack}`;
|
||||||
|
|
||||||
if (fatal) {
|
|
||||||
const fullText = fullTextOf(fatal);
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay}>
|
<div className={styles.overlay}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
@@ -107,11 +73,7 @@ export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknow
|
|||||||
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
|
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
|
||||||
Скопировать
|
Скопировать
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}>
|
||||||
type="button"
|
|
||||||
className={styles.reloadBtn}
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
>
|
|
||||||
Перезагрузить страницу
|
Перезагрузить страницу
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,34 +81,4 @@ export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknow
|
|||||||
</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