Files
request-portal/src/webparts/requestPortal/features/myRequests/SearchView.tsx
T

224 lines
9.8 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, searchMyRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import { usePersistedQuery, useDebouncedQuery } 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;
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
}
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()}`;
}
function statusClass(status: string): string {
switch (status) {
case REQUEST_STATUS.approved:
return styles.statusApproved;
case REQUEST_STATUS.rejected:
return styles.statusRejected;
case REQUEST_STATUS.returned:
return styles.statusReturned;
case REQUEST_STATUS.onApproval:
return styles.statusOnApproval;
case REQUEST_STATUS.draft:
return styles.statusDraft;
default:
return styles.statusDefault;
}
}
// В отличие от 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}>ID</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);
}
}}
>
<td className={styles.colId}>{r.id}</td>
<td className={styles.colType}>{r.requestTypeName}</td>
<td className={styles.colTitle}>{r.title || `#${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}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
</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, 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 [page, setPage] = React.useState<number>(1);
const [query, setQuery] = usePersistedQuery('search');
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
// ходит сразу по двум источникам (свои заявки + где участвую), то есть
// каждая набранная буква стоила бы двух серий запросов.
const debouncedQuery = useDebouncedQuery(query);
const [error, setError] = React.useState<string>('');
React.useEffect(() => setPage(1), [debouncedQuery]);
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(), page]);
// Уже искали это раньше — показываем прежний результат сразу, обновление
// идёт фоном (см. RequestsTableView, там та же схема и те же причины).
const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0);
const load = async (): Promise<void> => {
try {
const fresh = await searchMyRequests(context, types, debouncedQuery, page);
if (cancelled) return;
const prev = readPage(cacheKey);
if (!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(() => {
load().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, page]);
const hasQuery = debouncedQuery.trim().length > 0;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
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={query}
onChange={(e) => setQuery(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={rows} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
</section>
);
};