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): 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 }) => (
{rows.map((r) => ( onOpen(r)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(r); } }} > {/* Код заявки, а не Id элемента — см. IMyRequest.code и колонки в RequestsTableView. */} ))}
Код Тип Название Дата Автор Статус Роль
{r.code || `#${r.id}`} {r.requestTypeName} {r.title || r.code || `#${r.id}`} {formatDate(r.created)} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} {r.status ? : '—'} {r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}
); // Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где // пользователь участвует как согласующий (независимо от того, дошла ли уже // очередь) — единым списком, с колонками Автор и Статус одновременно (см. // searchMyRequests в myRequests.service.ts). Без введённого текста — пусто, // это не список «на экран по умолчанию», а именно поиск. // // Перечитывается по событию rp-requests-changed (после создания заявки и // после решения согласующего) — но т.к. это по большей части Search, свежая // правка может проявиться не мгновенно (частично компенсируется довеском // последних 10 минут через REST — см. myRequests.service.ts). export const SearchView: React.FC = ({ context, page, onPageChange, query, onQueryChange, onOpen, }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); const [error, setError] = React.useState(''); // Дочитанные авторы, ключ — requestTypeId-id. Живёт между запросами намеренно: // ключ глобально уникален, так что при возврате к прежнему поиску автор уже // известен и запрос не повторяется. const authors = React.useRef>(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 => { 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 (

Поиск {hasQuery && rows && {totalRows}}

setInput(e.target.value)} autoFocus />
{error &&
Не удалось выполнить поиск: {error}
} {!error && !hasQuery &&
Введите текст, чтобы начать поиск.
} {!error && hasQuery && rows === undefined &&
Ищем…
} {!error && hasQuery && rows && totalRows === 0 && (
По запросу «{debouncedQuery}» ничего не найдено.
)} {!error && hasQuery && rows && totalRows > 0 && ( <> )} ); };