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:
@@ -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