131e302f67
Tests / frontend-test (push) Successful in 1m18s
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/12
288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
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>
|
||
);
|
||
};
|