различные кэши
This commit is contained in:
@@ -3,7 +3,7 @@ import styles from './RequestPortal.module.scss';
|
|||||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||||
import { navigate, useRoute, NavSection } from '../shared/routing';
|
import { navigate, useRoute, NavSection } from '../shared/routing';
|
||||||
import { onRequestsChanged } from '../shared/requestEvents';
|
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||||
import { SideNav } from './SideNav/SideNav';
|
import { SideNav } from './SideNav/SideNav';
|
||||||
import { Drawer } from './Drawer/Drawer';
|
import { Drawer } from './Drawer/Drawer';
|
||||||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||||||
@@ -75,7 +75,14 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
}
|
}
|
||||||
}, [route.mode, route.formKey]);
|
}, [route.mode, route.formKey]);
|
||||||
|
|
||||||
const goto = (nav: NavSection): void => navigate({ mode: 'submit', nav });
|
const goto = (nav: NavSection): void => {
|
||||||
|
// Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект
|
||||||
|
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
|
||||||
|
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
|
||||||
|
// текущие строки (см. RequestsTableView/SearchView).
|
||||||
|
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
|
||||||
|
navigate({ mode: 'submit', nav });
|
||||||
|
};
|
||||||
const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav });
|
const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { IMyRequest } from './models/IMyRequest';
|
|||||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||||
|
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import styles from './RequestsTableView.module.scss';
|
import styles from './RequestsTableView.module.scss';
|
||||||
|
|
||||||
@@ -177,49 +178,58 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (types === undefined) return;
|
if (types === undefined) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]);
|
||||||
|
|
||||||
const load = async (): Promise<void> => {
|
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||||||
try {
|
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||||||
|
// без кеша при переключении на экране оставались бы строки предыдущей
|
||||||
|
// вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет —
|
||||||
|
// rows становится undefined, и показывается «Загрузка…».
|
||||||
|
const cached = readPage(cacheKey);
|
||||||
|
setRows(cached ? cached.rows : undefined);
|
||||||
|
setTotalRows(cached ? cached.totalRows : 0);
|
||||||
|
|
||||||
|
const fetchPage = async (): Promise<IPage> => {
|
||||||
if (kind === 'own') {
|
if (kind === 'own') {
|
||||||
const hasOwnQuery = debouncedQuery.trim().length > 0;
|
return debouncedQuery.trim().length > 0
|
||||||
const r = hasOwnQuery
|
? searchOwnRequests(context, types, debouncedQuery, page)
|
||||||
? await searchOwnRequests(context, types, debouncedQuery, page)
|
: getOwnRequests(context, types, page);
|
||||||
: await getOwnRequests(context, types, page);
|
|
||||||
if (cancelled) return;
|
|
||||||
setRows(r.rows);
|
|
||||||
setTotalRows(r.totalRows);
|
|
||||||
setError('');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasQuery = debouncedQuery.trim().length > 0;
|
if (debouncedQuery.trim().length === 0) {
|
||||||
|
|
||||||
if (!hasQuery) {
|
|
||||||
// Без поиска — страница уже нарезана и дочитана сервером.
|
// Без поиска — страница уже нарезана и дочитана сервером.
|
||||||
const r = await getPendingApproval(context, 'myTurn', page);
|
const r = await getPendingApproval(context, 'myTurn', page);
|
||||||
if (cancelled) return;
|
return { rows: r.rows, totalRows: r.totalRows };
|
||||||
console.table(r.debug);
|
|
||||||
setRows(r.rows);
|
|
||||||
setTotalRows(r.totalRows);
|
|
||||||
setError('');
|
|
||||||
if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// С поиском — сначала полный список «где я есть в маршруте» (без
|
// С поиском — сначала полный список «где я есть в маршруте» (без
|
||||||
// автора), фильтруем текстом, и только потом режем на страницы и
|
// автора), фильтруем текстом, и только потом режем на страницы и
|
||||||
// дочитываем автора — только для видимой страницы.
|
// дочитываем автора — только для видимой страницы.
|
||||||
const idx = await getPendingApprovalIndex(context, 'involved');
|
const idx = await getPendingApprovalIndex(context, 'involved');
|
||||||
if (cancelled) return;
|
|
||||||
console.table(idx.debug);
|
|
||||||
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
||||||
if (cancelled) return;
|
|
||||||
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
await hydratePendingAuthors(context, idx.types, pageRows);
|
await hydratePendingAuthors(context, idx.types, pageRows);
|
||||||
|
return { rows: pageRows, totalRows: filtered.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const fresh = await fetchPage();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRows(pageRows);
|
|
||||||
setTotalRows(filtered.length);
|
// Перерисовываем, только если данные реально изменились — иначе при
|
||||||
|
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
|
||||||
|
// самым содержимым.
|
||||||
|
const prev = readPage(cacheKey);
|
||||||
|
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||||
|
writePage(cacheKey, fresh);
|
||||||
|
setRows(fresh.rows);
|
||||||
|
setTotalRows(fresh.totalRows);
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
|
if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) {
|
||||||
|
onMyTurnCountChange(fresh.totalRows);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setError((e as Error).message);
|
if (!cancelled) setError((e as Error).message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IMyRequest } from './models/IMyRequest';
|
|||||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||||
|
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import styles from './SearchView.module.scss';
|
import styles from './SearchView.module.scss';
|
||||||
|
|
||||||
@@ -140,13 +141,24 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
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> => {
|
const load = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const r = await searchMyRequests(context, types, debouncedQuery, page);
|
const fresh = await searchMyRequests(context, types, debouncedQuery, page);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRows(r.rows);
|
const prev = readPage(cacheKey);
|
||||||
setTotalRows(r.totalRows);
|
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||||
|
writePage(cacheKey, fresh);
|
||||||
|
setRows(fresh.rows);
|
||||||
|
setTotalRows(fresh.totalRows);
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setError((e as Error).message);
|
if (!cancelled) setError((e as Error).message);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
|
|
||||||
|
// Загруженная страница таблицы — то, что кладётся в кеш вкладки
|
||||||
|
// (см. RequestsTableView/SearchView).
|
||||||
|
export interface IPage {
|
||||||
|
rows: IMyRequest[];
|
||||||
|
totalRows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хранилище на уровне МОДУЛЯ, а не внутри компонента (useRef), потому что
|
||||||
|
// компоненты таблиц размонтируются при переключении разделов: RequestPortal
|
||||||
|
// рендерит либо SubmitFeature, либо SearchView, либо RequestsTableView — уход
|
||||||
|
// на «Поиск» или «Новая заявка» уничтожает экземпляр таблицы вместе со всем его
|
||||||
|
// состоянием. С useRef кеш переживал только переключение «Мои заявки» ↔ «На
|
||||||
|
// согласовании» (там экземпляр один, меняется лишь проп kind), а после захода
|
||||||
|
// на «Поиск» терялся. Здесь он живёт до перезагрузки страницы.
|
||||||
|
//
|
||||||
|
// Ключ формирует вызывающая сторона и обязательно начинает с префикса раздела
|
||||||
|
// (см. pageKey), иначе ключи двух таблиц пересеклись бы.
|
||||||
|
const store = new Map<string, IPage>();
|
||||||
|
|
||||||
|
// Ключ кеша: раздел + всё, от чего зависит содержимое страницы.
|
||||||
|
export function pageKey(view: string, parts: Array<string | number>): string {
|
||||||
|
return [view, ...parts].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPage(key: string): IPage | undefined {
|
||||||
|
return store.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePage(key: string, page: IPage): void {
|
||||||
|
store.set(key, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Слепок страницы для сравнения «изменилось ли что-нибудь». Сравниваем поля,
|
||||||
|
// которые видны в таблице: если они совпали, перерисовывать нечего — иначе при
|
||||||
|
// каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы,
|
||||||
|
// перерисовываясь тем же самым содержимым.
|
||||||
|
export function pageSignature(page: IPage): string {
|
||||||
|
return `${page.totalRows}|${page.rows
|
||||||
|
.map((r) => [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName].join('~'))
|
||||||
|
.join(';')}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user