Files
request-portal/src/webparts/requestPortal/features/myRequests/SearchView.tsx
T
2026-08-21 10:01:33 +00:00

288 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import { perfScope } from '../../shared/perfTrace';
import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager';
import styles from './SearchView.module.scss';
const PAGE_SIZE = 20;
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
// вкладка, оставленная открытой надолго, не показывала устаревший список.
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
// если данные реально изменились (см. pageSignature).
const AUTO_REFRESH_MS = 10 * 60 * 1000;
export interface ISearchViewProps {
context: WebPartContext;
/**
* Текущая страница (1-based) и её смена — как и в RequestsTableView, страница
* живёт в маршруте, а не внутри компонента (см. shared/routing.ts).
*/
page: number;
onPageChange: (page: number) => void;
/** Текст поиска и его смена — тоже из маршрута (см. page выше). */
query: string;
onQueryChange: (query: string) => void;
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
}
const rowKey = (r: IMyRequest): string => `${r.requestTypeId}-${r.id}`;
// Автор, уже дочитанный ранее (см. authors в SearchView), — проставляем его в
// свежий результат поиска до сравнения с кешем. Иначе фоновое обновление
// приносило бы строки без автора, слепок страницы отличался бы от кешированного
// на ровном месте, таблица моргала бы и автор загружался заново.
function applyKnownAuthors(rows: IMyRequest[], known: Map<string, { name: string; email: string }>): void {
rows.forEach((r) => {
const a = known.get(rowKey(r));
if (a) {
r.authorName = a.name;
r.authorEmail = a.email;
}
});
}
function formatDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
// В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор,
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
// чтобы было видно, откуда строка (kind), не только по автору.
const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => void }> = ({ rows, onOpen }) => (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.colId}>Код</th>
<th className={styles.colType}>Тип</th>
<th className={styles.colTitle}>Название</th>
<th className={styles.colDate}>Дата</th>
<th className={styles.colAuthor}>Автор</th>
<th className={styles.colStatus}>Статус</th>
<th className={styles.colKind}>Роль</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
className={styles.row}
key={`${r.requestTypeId}-${r.id}`}
tabIndex={0}
role="button"
aria-label={`Открыть заявку «${r.title || r.id}»`}
onClick={() => onOpen(r)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(r);
}
}}
>
{/* Код заявки, а не 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.code || `#${r.id}`}</td>
<td className={styles.colDate}>{formatDate(r.created)}</td>
<td className={styles.colAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td>
<td className={styles.colStatus}>
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
</td>
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
// Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где
// пользователь участвует как согласующий (независимо от того, дошла ли уже
// очередь) — единым списком, с колонками Автор и Статус одновременно (см.
// searchMyRequests в myRequests.service.ts). Без введённого текста — пусто,
// это не список «на экран по умолчанию», а именно поиск.
//
// Перечитывается по событию rp-requests-changed (после создания заявки и
// после решения согласующего) — но т.к. это по большей части Search, свежая
// правка может проявиться не мгновенно (частично компенсируется довеском
// последних 10 минут через REST — см. myRequests.service.ts).
export const SearchView: React.FC<ISearchViewProps> = ({
context,
page,
onPageChange,
query,
onQueryChange,
onOpen,
}) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0);
const [error, setError] = React.useState<string>('');
// Дочитанные авторы, ключ — requestTypeId-id. Живёт между запросами намеренно:
// ключ глобально уникален, так что при возврате к прежнему поиску автор уже
// известен и запрос не повторяется.
const authors = React.useRef<Map<string, { name: string; email: string }>>(new Map());
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
// после паузы в наборе. Здесь пауза особенно важна: поиск ходит сразу по двум
// источникам (свои заявки + где участвую), то есть каждая набранная буква
// стоила бы двух серий запросов.
const [input, setInput] = useRoutedQuery(query, onQueryChange);
const debouncedQuery = query;
// Страницу при смене запроса здесь НЕ сбрасываем — это делает setQuery в
// RequestPortal одним переходом вместе с самим запросом.
React.useEffect(() => {
let cancelled = false;
getLinkedTypes(context)
.then((t) => {
if (!cancelled) setTypes(t);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
// Поиск зависит ТОЛЬКО от запроса, не от страницы: результат приходит целиком
// и кешируется целиком, а страницы нарезаются ниже, в рендере. Раньше страница
// входила в ключ кеша — и каждое листание было промахом, который заново гонял
// обе серии запросов (свои + где участвую) и показывал «Ищем…», хотя все эти
// строки уже были получены при загрузке первой страницы.
React.useEffect(() => {
if (types === undefined) return;
if (debouncedQuery.trim().length === 0) {
setRows(undefined);
setTotalRows(0);
return;
}
let cancelled = false;
const cacheKey = pageKey('search', [debouncedQuery.trim()]);
// Уже искали это раньше — показываем прежний результат сразу, обновление
// идёт фоном (см. RequestsTableView, там та же схема и те же причины).
const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0);
const load = async (forceRender = false): Promise<void> => {
try {
const fresh = await perfScope('вкладка «Поиск»', () =>
searchMyRequests(context, types, debouncedQuery)
);
if (cancelled) return;
applyKnownAuthors(fresh.rows, authors.current);
const prev = readPage(cacheKey);
// forceRender — строки уже спрятаны (см. force ниже) и обязаны
// вернуться, даже если свежий ответ совпал с прежним кешем: иначе
// экран так и останется в «Ищем…» (см. тот же баг в RequestsTableView).
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
writePage(cacheKey, fresh);
setRows(fresh.rows);
setTotalRows(fresh.totalRows);
}
setError('');
} catch (e) {
if (!cancelled) setError((e as Error).message);
}
};
load().catch(() => undefined);
const unsubscribe = onRequestsChanged((force) => {
if (force) setRows(undefined);
load(force).catch(() => undefined);
});
const timer = window.setInterval(() => {
if (!document.hidden) load().catch(() => undefined);
}, AUTO_REFRESH_MS);
return () => {
cancelled = true;
unsubscribe();
window.clearInterval(timer);
};
}, [context, types, debouncedQuery]);
const hasQuery = debouncedQuery.trim().length > 0;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
const visible = React.useMemo(
() => (rows ? rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) : undefined),
[rows, page]
);
// Автора у строк 'approval' дочитываем отдельным запросом и только для видимой
// страницы: на весь набор (до 300 строк) это был бы $filter из сотен Id —
// такой длины URL SharePoint не переваривает. Строки мутируются на месте, то
// есть автор оседает и в кеше; карта authors не даёт дёргать один и тот же
// запрос повторно (в т.ч. когда автор оказался пустым).
React.useEffect(() => {
if (types === undefined || visible === undefined) return undefined;
const need = visible.filter((r) => r.kind === 'approval' && !authors.current.has(rowKey(r)));
if (need.length === 0) return undefined;
let cancelled = false;
hydratePendingAuthors(context, types, need)
.then(() => {
need.forEach((r) =>
authors.current.set(rowKey(r), { name: r.authorName ?? '', email: r.authorEmail ?? '' })
);
if (!cancelled) setRows((prev) => (prev ? [...prev] : prev));
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [context, types, visible]);
return (
<section className={styles.view}>
<div className={styles.head}>
<h2 className={styles.heading}>
Поиск
{hasQuery && rows && <span className={styles.count}>{totalRows}</span>}
</h2>
<input
type="search"
className={styles.search}
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
/>
</div>
{error && <div className={styles.error}>Не удалось выполнить поиск: {error}</div>}
{!error && !hasQuery && <div className={styles.empty}>Введите текст, чтобы начать поиск.</div>}
{!error && hasQuery && rows === undefined && <div className={styles.empty}>Ищем…</div>}
{!error && hasQuery && rows && totalRows === 0 && (
<div className={styles.empty}>По запросу «{debouncedQuery}» ничего не найдено.</div>
)}
{!error && hasQuery && rows && totalRows > 0 && (
<>
<Table rows={visible ?? []} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
</>
)}
</section>
);
};