Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ApproveFeature.module.scss';
|
||||
import { getViewComponent } from '../../forms/viewRegistry';
|
||||
import { isConfirmedStaleListId } from '../../shared/approval';
|
||||
|
||||
export interface IApproveFeatureProps {
|
||||
context: WebPartContext;
|
||||
@@ -9,18 +10,29 @@ export interface IApproveFeatureProps {
|
||||
formKey: string;
|
||||
/** RowID заявки из URL. */
|
||||
itemId: number;
|
||||
/** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
// Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=).
|
||||
// Показывает заявку в режиме чтения; панель решения добавим следующим шагом.
|
||||
const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, itemId }) => {
|
||||
const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, itemId, listId }) => {
|
||||
const ViewComponent = getViewComponent(formKey);
|
||||
|
||||
// Партиция указана и реестр ТОЧНО знает, что её там нет — см. тот же
|
||||
// комментарий в EditFeature (isConfirmedStaleListId).
|
||||
const partitionError =
|
||||
listId && isConfirmedStaleListId(formKey, listId)
|
||||
? `Не удалось открыть заявку: список «${listId}» не найден в реестре партиций. Ссылка могла устареть — попробуйте открыть заявку заново из списка.`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{ViewComponent ? (
|
||||
{partitionError ? (
|
||||
<div className={styles.state}>{partitionError}</div>
|
||||
) : ViewComponent ? (
|
||||
<React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}>
|
||||
<ViewComponent context={context} itemId={itemId} formKey={formKey} />
|
||||
<ViewComponent context={context} itemId={itemId} formKey={formKey} listId={listId} />
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import styles from '../approve/ApproveFeature.module.scss';
|
||||
import { getFormComponent } from '../../forms/registry';
|
||||
import { REQUEST_TYPES } from '../../data/requests';
|
||||
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||
import { isConfirmedStaleListId } from '../../shared/approval';
|
||||
|
||||
export interface IEditFeatureProps {
|
||||
context: WebPartContext;
|
||||
@@ -11,6 +12,8 @@ export interface IEditFeatureProps {
|
||||
formKey: string;
|
||||
/** RowID редактируемой заявки из URL. */
|
||||
itemId: number;
|
||||
/** Партиция, где лежит заявка (см. IRoute.listId) — не задана у ссылок старого формата. */
|
||||
listId?: string;
|
||||
/** Закрыть панель (после сохранения). */
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -22,7 +25,7 @@ export interface IEditFeatureProps {
|
||||
//
|
||||
// Отдельно от features/submit намеренно: там форма живёт в потоке «плитки →
|
||||
// новая заявка», а тут — точечное открытие конкретной записи по ссылке.
|
||||
const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, onClose }) => {
|
||||
const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, listId, onClose }) => {
|
||||
const FormComponent = getFormComponent(formKey);
|
||||
const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0];
|
||||
|
||||
@@ -30,6 +33,24 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, on
|
||||
return <div className={styles.state}>Для формы «{formKey}» редактирование не настроено.</div>;
|
||||
}
|
||||
|
||||
// Партиция указана (заявки нумерованные, см. shared/approval/listPartitions.ts)
|
||||
// и реестр ТОЧНО знает, что такого listId в серии типа нет (см.
|
||||
// isConfirmedStaleListId) — устаревшая/битая ссылка. Если реестр про серию
|
||||
// просто ничего не знает (approval-route не ответил при старте) — это НЕ
|
||||
// повод отказывать: listId доверяем как есть, ровно как listTargetsForType в
|
||||
// myRequests.service.ts. Проверяем ЗДЕСЬ, ДО монтирования формы: внутри неё
|
||||
// имя списка резолвится синхронно (useMemo) и там уже негде красиво показать
|
||||
// ошибку без риска уронить рендер всей панели — см. vacationApprovalService и
|
||||
// аналоги в остальных формах.
|
||||
if (listId && isConfirmedStaleListId(formKey, listId)) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
Не удалось открыть заявку: список «{listId}» не найден в реестре партиций. Ссылка могла устареть —
|
||||
попробуйте открыть заявку заново из списка.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<React.Suspense fallback={<div className={styles.state}>Загрузка формы…</div>}>
|
||||
@@ -37,6 +58,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, on
|
||||
requestType={requestType}
|
||||
context={context}
|
||||
editId={itemId}
|
||||
editListId={listId}
|
||||
onCancel={onClose}
|
||||
onSaved={() => {
|
||||
emitRequestsChanged();
|
||||
|
||||
@@ -16,6 +16,7 @@ import { IMyRequest } from './models/IMyRequest';
|
||||
import { DataTable, IDataTableColumn } from '@tnsu/ui-kit';
|
||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { traced } from './perfTrace';
|
||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
@@ -208,9 +209,13 @@ const Table: React.FC<{
|
||||
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
|
||||
// (статус → StatusBadge, дата → формат) задаются через render.
|
||||
const columns: IDataTableColumn<IMyRequest>[] = [
|
||||
{ key: 'id', title: 'ID', width: '70px' },
|
||||
// Код заявки, а не Id элемента: Id уникален только внутри своей партиции
|
||||
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
|
||||
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
|
||||
// созданных до появления колонки — для них показываем Id, как раньше.
|
||||
{ key: 'code', title: 'Код', width: '130px', render: (r) => r.code || `#${r.id}` },
|
||||
{ key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName },
|
||||
{ key: 'title', title: 'Название', render: (r) => r.title || `#${r.id}` },
|
||||
{ key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
|
||||
{ key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) }
|
||||
];
|
||||
if (cols.author) {
|
||||
@@ -392,16 +397,20 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
return result;
|
||||
};
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
const load = async (forceRender = false): Promise<void> => {
|
||||
try {
|
||||
const fresh = await fetchPage();
|
||||
const fresh = await traced(`ИТОГО таблица:${view}#${page}`, fetchPage);
|
||||
if (cancelled) return;
|
||||
|
||||
// Перерисовываем, только если данные реально изменились — иначе при
|
||||
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
|
||||
// самым содержимым.
|
||||
// самым содержимым. forceRender — исключение: строки уже спрятаны со
|
||||
// экрана (см. force ниже), и их обязательно нужно вернуть, даже если
|
||||
// свежий ответ побайтово совпал с тем, что было в кеше, — иначе таблица
|
||||
// так и останется в состоянии «Загрузка…», хотя данные давно пришли
|
||||
// (баг: ушёл на этом самом совпадении сигнатур, найден 2026-08-18).
|
||||
const prev = readPage(cacheKey);
|
||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||
writePage(cacheKey, fresh);
|
||||
setRows(fresh.rows);
|
||||
setTotalRows(fresh.totalRows);
|
||||
@@ -419,12 +428,12 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
const unsubscribe = onRequestsChanged((force) => {
|
||||
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
|
||||
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
|
||||
// строки прячем сразу; свежие подставит load().
|
||||
// строки прячем сразу; свежие подставит load(force).
|
||||
if (force) {
|
||||
setRows(undefined);
|
||||
setLoadedKey('');
|
||||
}
|
||||
load().catch(() => undefined);
|
||||
load(force).catch(() => undefined);
|
||||
});
|
||||
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
|
||||
// сработает ближайший тик.
|
||||
|
||||
@@ -126,7 +126,9 @@
|
||||
}
|
||||
|
||||
.colId {
|
||||
width: 70px;
|
||||
// Под код заявки («13-2608-42»), а не под Id элемента: прежних 70px на него
|
||||
// не хватало (см. IMyRequest.code).
|
||||
width: 130px;
|
||||
color: $gray-400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myReq
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { traced } from './perfTrace';
|
||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
@@ -66,7 +67,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.colId}>ID</th>
|
||||
<th className={styles.colId}>Код</th>
|
||||
<th className={styles.colType}>Тип</th>
|
||||
<th className={styles.colTitle}>Название</th>
|
||||
<th className={styles.colDate}>Дата</th>
|
||||
@@ -91,9 +92,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className={styles.colId}>{r.id}</td>
|
||||
{/* Код заявки, а не Id элемента — см. IMyRequest.code и колонки в RequestsTableView. */}
|
||||
<td className={styles.colId}>{r.code || `#${r.id}`}</td>
|
||||
<td className={styles.colType}>{r.requestTypeName}</td>
|
||||
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
|
||||
<td className={styles.colTitle}>{r.title || r.code || `#${r.id}`}</td>
|
||||
<td className={styles.colDate}>{formatDate(r.created)}</td>
|
||||
<td className={styles.colAuthor}>
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
@@ -182,13 +184,16 @@ export const SearchView: React.FC<ISearchViewProps> = ({
|
||||
setRows(cached ? cached.rows : undefined);
|
||||
setTotalRows(cached ? cached.totalRows : 0);
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
const load = async (forceRender = false): Promise<void> => {
|
||||
try {
|
||||
const fresh = await searchMyRequests(context, types, debouncedQuery);
|
||||
const fresh = await traced('ИТОГО поиск', () => searchMyRequests(context, types, debouncedQuery));
|
||||
if (cancelled) return;
|
||||
applyKnownAuthors(fresh.rows, authors.current);
|
||||
const prev = readPage(cacheKey);
|
||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||
// forceRender — строки уже спрятаны (см. force ниже) и обязаны
|
||||
// вернуться, даже если свежий ответ совпал с прежним кешем: иначе
|
||||
// экран так и останется в «Ищем…» (см. тот же баг в RequestsTableView).
|
||||
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||
writePage(cacheKey, fresh);
|
||||
setRows(fresh.rows);
|
||||
setTotalRows(fresh.totalRows);
|
||||
@@ -202,7 +207,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({
|
||||
load().catch(() => undefined);
|
||||
const unsubscribe = onRequestsChanged((force) => {
|
||||
if (force) setRows(undefined);
|
||||
load().catch(() => undefined);
|
||||
load(force).catch(() => undefined);
|
||||
});
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) load().catch(() => undefined);
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
||||
export interface IMyRequest {
|
||||
id: number;
|
||||
/**
|
||||
* Уникальный код заявки (колонка RequestUniqueCode) — вида «13-2608-42», см.
|
||||
* shared/requestCode.ts. Именно он показывается пользователю вместо Id
|
||||
* элемента: Id уникален только ВНУТРИ своей партиции (в каждом списке
|
||||
* SharePoint нумерация с единицы), поэтому после архивации в одной таблице
|
||||
* оказывались разные заявки с одинаковым номером.
|
||||
*
|
||||
* Пустая строка — заявка создана до появления колонки либо код не успел
|
||||
* записаться (он проставляется вторым запросом, уже после создания элемента,
|
||||
* см. saveRequestItem): тогда в таблице показывается Id, как раньше.
|
||||
*/
|
||||
code: string;
|
||||
title: string;
|
||||
status: string;
|
||||
authorEmail: string;
|
||||
@@ -11,6 +23,20 @@ export interface IMyRequest {
|
||||
requestTypeId: number;
|
||||
requestTypeName: string;
|
||||
section: string;
|
||||
/**
|
||||
* Список (партиция), из которого прочитана ИМЕННО эта заявка — серверный
|
||||
* относительный url.
|
||||
*
|
||||
* Не выводится из requestTypeId: у типа заявки не один список, а нумерованная
|
||||
* серия, и RequestType.listId указывает только на текущую партицию. Архивную
|
||||
* заявку надо перечитывать (автор, комментарий решения, открытие формы) из её
|
||||
* собственного списка — иначе элемент с таким Id там не найдётся или, хуже,
|
||||
* найдётся ЧУЖАЯ заявка с тем же Id из другой партиции (нумерация элементов в
|
||||
* каждом списке SharePoint своя).
|
||||
*/
|
||||
listId: string;
|
||||
/** Сайт, где живёт listId этой заявки. */
|
||||
siteUrl: string;
|
||||
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
||||
formKey: string;
|
||||
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'
|
||||
function request(over: Partial<IMyRequest> = {}): IMyRequest {
|
||||
return {
|
||||
id: 1,
|
||||
code: '1-2608-1',
|
||||
title: 'Заявка №1',
|
||||
status: 'Черновик',
|
||||
authorEmail: 'ivanov@tnsu.kz',
|
||||
@@ -12,6 +13,8 @@ function request(over: Partial<IMyRequest> = {}): IMyRequest {
|
||||
requestTypeId: 1,
|
||||
requestTypeName: 'Приём на работу',
|
||||
section: 'Кадры',
|
||||
listId: '/sites/requests/Lists/List1',
|
||||
siteUrl: 'https://tnsukz.sharepoint.com/sites/requests',
|
||||
formKey: 'hire',
|
||||
kind: 'own',
|
||||
...over,
|
||||
@@ -111,6 +114,15 @@ describe('pageSignature', () => {
|
||||
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||
});
|
||||
|
||||
it('появление кода заявки меняет подпись — он показан колонкой «Код»', () => {
|
||||
// Код проставляется вторым запросом, уже после создания элемента (см.
|
||||
// saveRequestItem), поэтому первая выдача приходит без него. Не учитывать
|
||||
// его в подписи — значит оставить в таблице «#1» до перезагрузки страницы.
|
||||
const before = page({ rows: [request({ code: '' })] });
|
||||
const after = page({ rows: [request({ code: '1-2608-1' })] });
|
||||
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||
});
|
||||
|
||||
it('пустая страница имеет стабильную подпись', () => {
|
||||
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
||||
});
|
||||
|
||||
@@ -36,10 +36,15 @@ export function writePage(key: string, page: IPage): void {
|
||||
// которые видны в таблице: если они совпали, перерисовывать нечего — иначе при
|
||||
// каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы,
|
||||
// перерисовываясь тем же самым содержимым.
|
||||
// code — тоже видимое поле (колонка «Код»), и его обязательно учитывать: он
|
||||
// проставляется ВТОРЫМ запросом, уже после создания элемента (см.
|
||||
// saveRequestItem), поэтому первая выдача может прийти без него. Без code в
|
||||
// слепке подъехавший позже код совпал бы с прежней подписью, перерисовки не
|
||||
// случилось бы, и в таблице до перезагрузки страницы висело бы «#5».
|
||||
export function pageSignature(page: IPage): string {
|
||||
return `${page.totalRows}|${page.rows
|
||||
.map((r) =>
|
||||
[r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
|
||||
[r.requestTypeId, r.id, r.code, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
|
||||
)
|
||||
.join(';')}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Замер времени отдельных этапов загрузки «Моих заявок» — что именно тормозит
|
||||
* (индекс согласований, свои заявки по типу, конкретная партиция, Search),
|
||||
* а не просто «страница долго грузится».
|
||||
*
|
||||
* ВРЕМЕННО: пишет всегда, без флага-переключателя — этот модуль и все traced()
|
||||
* вокруг снимаются целиком, как только разбор текущей проблемы с
|
||||
* производительностью закончится (см. myRequests.service.ts, RequestsTableView.tsx,
|
||||
* SearchView.tsx).
|
||||
*/
|
||||
const PREFIX = '[Перф]';
|
||||
|
||||
/** Оборачивает async-шаг замером времени. */
|
||||
export async function traced<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log(`${PREFIX} ${label}: ${(performance.now() - start).toFixed(0)} мс`);
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,12 @@ import { IRequestType } from '../../shared/approval';
|
||||
* window.rpSearchTrace = true // следить за всеми типами
|
||||
* window.rpSearchTrace = false // выключить
|
||||
* Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен.
|
||||
*
|
||||
* По умолчанию выключена (false) — включённый DEFAULT_TRACE был точечной
|
||||
* настройкой на время разбора конкретного бага с партициями «Обходного листа»
|
||||
* и не должен постоянно писать в консоль всем остальным.
|
||||
*/
|
||||
const DEFAULT_TRACE: string | boolean = 'обходной';
|
||||
const DEFAULT_TRACE: string | boolean = false;
|
||||
|
||||
const PREFIX = '[Поиск]';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user