Search requests (#2)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/2 Co-authored-by: timur.gorodetskiy <timur.gorodetskiy@tnsu.kz> Co-committed-by: timur.gorodetskiy <timur.gorodetskiy@tnsu.kz>
This commit was merged in pull request #2.
This commit is contained in:
@@ -1,9 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service';
|
||||
import {
|
||||
filterPendingByQuery,
|
||||
getLinkedTypes,
|
||||
getOwnRequests,
|
||||
getPendingApproval,
|
||||
getPendingApprovalIndex,
|
||||
hydratePendingAuthors,
|
||||
searchOwnRequests,
|
||||
} 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 { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
import styles from './RequestsTableView.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -14,6 +25,10 @@ export interface IRequestsTableViewProps {
|
||||
kind: 'approvals' | 'own';
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
|
||||
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
|
||||
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
|
||||
onMyTurnCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -41,70 +56,6 @@ function statusClass(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Постраничная навигация: номера страниц + вперёд/назад. При большом
|
||||
// количестве страниц показывает только соседние с текущей + первую/последнюю.
|
||||
const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({
|
||||
page,
|
||||
totalPages,
|
||||
onChange,
|
||||
}) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const numbers: number[] = [];
|
||||
const from = Math.max(1, page - 2);
|
||||
const to = Math.min(totalPages, page + 2);
|
||||
for (let i = from; i <= to; i++) numbers.push(i);
|
||||
|
||||
return (
|
||||
<div className={styles.pager}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pagerBtn}
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
aria-label="Предыдущая страница"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button type="button" className={styles.pagerBtn} onClick={() => onChange(1)}>
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className={styles.pagerEllipsis}>…</span>}
|
||||
</>
|
||||
)}
|
||||
{numbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pagerBtn} ${n === page ? styles.pagerBtnActive : ''}`}
|
||||
onClick={() => onChange(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{to < totalPages && (
|
||||
<>
|
||||
{to < totalPages - 1 && <span className={styles.pagerEllipsis}>…</span>}
|
||||
<button type="button" className={styles.pagerBtn} onClick={() => onChange(totalPages)}>
|
||||
{totalPages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pagerBtn}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
aria-label="Следующая страница"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
||||
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||||
// (доступ с клавиатуры: Enter/Space).
|
||||
@@ -166,24 +117,49 @@ const Table: React.FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
// Раздел с таблицей заявок: поиск по названию + постраничный вывод.
|
||||
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
|
||||
//
|
||||
// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть
|
||||
// весь маршрут заявки), поиск и пагинация — на клиенте.
|
||||
// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск —
|
||||
// серверным OData-фильтром по Title.
|
||||
// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса —
|
||||
// всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом —
|
||||
// всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше
|
||||
// поиск подмешивал REST для «свежих» строк поверх Search — из-за этого
|
||||
// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
|
||||
// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
|
||||
//
|
||||
// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в
|
||||
// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
||||
//
|
||||
// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status),
|
||||
// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает
|
||||
// автора только для него).
|
||||
// 'approvals' с поиском — область расширяется до «где пользователь вообще есть
|
||||
// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично
|
||||
// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый
|
||||
// фильтр через Search (filterPendingByQuery), и только после фильтрации —
|
||||
// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже
|
||||
// только для видимой страницы, а не для всего отфильтрованного набора.
|
||||
//
|
||||
// Оба варианта перечитываются по событию rp-requests-changed
|
||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context, kind, onOpen }) => {
|
||||
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
|
||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
context,
|
||||
kind,
|
||||
onOpen,
|
||||
onMyTurnCountChange,
|
||||
}) => {
|
||||
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] = React.useState<string>('');
|
||||
const [query, setQuery] = usePersistedQuery(kind);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
|
||||
// иначе поиск бил бы запросом по каждой набранной букве.
|
||||
const debouncedQuery = useDebouncedQuery(query);
|
||||
|
||||
React.useEffect(() => setPage(1), [query, kind]);
|
||||
React.useEffect(() => setPage(1), [debouncedQuery, kind]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -202,45 +178,77 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
|
||||
React.useEffect(() => {
|
||||
if (types === undefined) return;
|
||||
let cancelled = false;
|
||||
const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]);
|
||||
|
||||
const load = (): void => {
|
||||
const request =
|
||||
kind === 'own'
|
||||
? getOwnRequests(context, types, query, page).then((r) => ({ rows: r.rows, total: r.totalRows }))
|
||||
: getPendingApproval(context).then((r) => {
|
||||
console.table(r.debug);
|
||||
return { rows: r.rows, total: r.rows.length };
|
||||
});
|
||||
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||||
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||||
// без кеша при переключении на экране оставались бы строки предыдущей
|
||||
// вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет —
|
||||
// rows становится undefined, и показывается «Загрузка…».
|
||||
const cached = readPage(cacheKey);
|
||||
setRows(cached ? cached.rows : undefined);
|
||||
setTotalRows(cached ? cached.totalRows : 0);
|
||||
|
||||
request
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setRows(result.rows);
|
||||
setTotalRows(result.total);
|
||||
setError('');
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
const fetchPage = async (): Promise<IPage> => {
|
||||
if (kind === 'own') {
|
||||
return debouncedQuery.trim().length > 0
|
||||
? searchOwnRequests(context, types, debouncedQuery, page)
|
||||
: getOwnRequests(context, types, page);
|
||||
}
|
||||
|
||||
if (debouncedQuery.trim().length === 0) {
|
||||
// Без поиска — страница уже нарезана и дочитана сервером.
|
||||
const r = await getPendingApproval(context, 'myTurn', page);
|
||||
return { rows: r.rows, totalRows: r.totalRows };
|
||||
}
|
||||
|
||||
// С поиском — сначала полный список «где я есть в маршруте» (без
|
||||
// автора), фильтруем текстом, и только потом режем на страницы и
|
||||
// дочитываем автора — только для видимой страницы.
|
||||
const idx = await getPendingApprovalIndex(context, 'involved');
|
||||
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
||||
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
await hydratePendingAuthors(context, idx.types, pageRows);
|
||||
return { rows: pageRows, totalRows: filtered.length };
|
||||
};
|
||||
|
||||
load();
|
||||
const unsubscribe = onRequestsChanged(load);
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const fresh = await fetchPage();
|
||||
if (cancelled) return;
|
||||
|
||||
// Перерисовываем, только если данные реально изменились — иначе при
|
||||
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
|
||||
// самым содержимым.
|
||||
const prev = readPage(cacheKey);
|
||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||
writePage(cacheKey, fresh);
|
||||
setRows(fresh.rows);
|
||||
setTotalRows(fresh.totalRows);
|
||||
}
|
||||
setError('');
|
||||
if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) {
|
||||
onMyTurnCountChange(fresh.totalRows);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
|
||||
const unsubscribe = onRequestsChanged(() => {
|
||||
load().catch(() => undefined);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [context, types, kind, query, page]);
|
||||
}, [context, types, kind, debouncedQuery, page]);
|
||||
|
||||
// 'approvals' приходит целиком — фильтруем и нарезаем страницы на клиенте.
|
||||
const q = query.trim().toLowerCase();
|
||||
let visible = rows || [];
|
||||
let total = totalRows;
|
||||
if (kind === 'approvals' && rows) {
|
||||
const filtered = q ? rows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : rows;
|
||||
total = filtered.length;
|
||||
visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
}
|
||||
// Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
|
||||
const q = debouncedQuery.trim();
|
||||
const total = totalRows;
|
||||
const visible = rows || [];
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
|
||||
@@ -255,7 +263,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
|
||||
<input
|
||||
type="search"
|
||||
className={styles.search}
|
||||
placeholder="Поиск по названию…"
|
||||
placeholder="Поиск по заявкам…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
@@ -266,7 +274,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
|
||||
{!error && rows && total === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{q
|
||||
? `По запросу «${query}» ничего не найдено.`
|
||||
? `По запросу «${debouncedQuery}» ничего не найдено.`
|
||||
: kind === 'approvals'
|
||||
? 'Нет заявок, ждущих вашего решения.'
|
||||
: 'Вы ещё не создавали заявок.'}
|
||||
|
||||
Reference in New Issue
Block a user