Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getLinkedTypes, searchMyRequests } from './myRequests.service';
|
||||
import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { IRequestType, StatusBadge } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
import styles from './SearchView.module.scss';
|
||||
@@ -20,10 +20,35 @@ 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);
|
||||
@@ -32,23 +57,6 @@ function formatDate(iso: string): string {
|
||||
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 (где по смыслу раздела нужно либо Автор,
|
||||
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
|
||||
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
|
||||
@@ -91,7 +99,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
<td className={styles.colStatus}>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
|
||||
</td>
|
||||
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
|
||||
</tr>
|
||||
@@ -111,20 +119,33 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
||||
// после решения согласующего) — но т.к. это по большей части Search, свежая
|
||||
// правка может проявиться не мгновенно (частично компенсируется довеском
|
||||
// последних 10 минут через REST — см. myRequests.service.ts).
|
||||
export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
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 [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]);
|
||||
// Дочитанные авторы, ключ — 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;
|
||||
@@ -140,6 +161,11 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
// Поиск зависит ТОЛЬКО от запроса, не от страницы: результат приходит целиком
|
||||
// и кешируется целиком, а страницы нарезаются ниже, в рендере. Раньше страница
|
||||
// входила в ключ кеша — и каждое листание было промахом, который заново гонял
|
||||
// обе серии запросов (свои + где участвую) и показывал «Ищем…», хотя все эти
|
||||
// строки уже были получены при загрузке первой страницы.
|
||||
React.useEffect(() => {
|
||||
if (types === undefined) return;
|
||||
if (debouncedQuery.trim().length === 0) {
|
||||
@@ -148,7 +174,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const cacheKey = pageKey('search', [debouncedQuery.trim(), page]);
|
||||
const cacheKey = pageKey('search', [debouncedQuery.trim()]);
|
||||
|
||||
// Уже искали это раньше — показываем прежний результат сразу, обновление
|
||||
// идёт фоном (см. RequestsTableView, там та же схема и те же причины).
|
||||
@@ -158,8 +184,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const fresh = await searchMyRequests(context, types, debouncedQuery, page);
|
||||
const fresh = await searchMyRequests(context, types, debouncedQuery);
|
||||
if (cancelled) return;
|
||||
applyKnownAuthors(fresh.rows, authors.current);
|
||||
const prev = readPage(cacheKey);
|
||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||
writePage(cacheKey, fresh);
|
||||
@@ -184,10 +211,39 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
unsubscribe();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [context, types, debouncedQuery, page]);
|
||||
}, [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}>
|
||||
@@ -200,8 +256,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
type="search"
|
||||
className={styles.search}
|
||||
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -214,8 +270,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
||||
)}
|
||||
{!error && hasQuery && rows && totalRows > 0 && (
|
||||
<>
|
||||
<Table rows={rows} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
<Table rows={visible ?? []} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user