diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 4852ea9..e27dce2 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -2,7 +2,15 @@ import * as React from 'react'; import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; -import { navigate, persistOwnTab, persistOwnStatus, useRoute, NavSection, OwnTab } from '../shared/routing'; +import { + navigate, + persistOwnTab, + persistOwnStatus, + persistPage, + useRoute, + NavSection, + OwnTab, +} from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; @@ -131,7 +139,18 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context const setOwnStatus = (ownStatus: string): void => { const normalized = ownStatus || undefined; persistOwnStatus(normalized); - navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized }); + // Смена фильтра — другой набор заявок, поэтому явно с первой страницы + // (и запоминаем именно её, иначе вернувшись сюда попали бы на старую). + persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1); + navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized, page: 1 }); + }; + + // Смена страницы сохраняет ВЕСЬ остальной маршрут (в т.ч. открытую заявку), + // меняется только номер: так «назад» возвращает на прежнюю страницу, а + // ссылку со страницей можно передать. + const setPage = (page: number): void => { + persistPage(route, page); + navigate({ ...route, page }); }; // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // возвращённая, отклонённая) открывается на редактирование, всё остальное — @@ -221,6 +240,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.nav === 'search' && ( )} @@ -239,6 +260,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context } statusFilter={route.ownStatus ?? ''} onStatusFilterChange={setOwnStatus} + page={route.page ?? 1} + onPageChange={setPage} onOpen={openRequest} /> )} diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 9dbe83a..a5fa6e3 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -118,6 +118,14 @@ export interface IRequestsTableViewProps { /** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */ statusFilter?: string; onStatusFilterChange?: (status: string) => void; + /** + * Текущая страница (1-based) и её смена. Страница живёт в маршруте, а не + * внутри компонента: так она попадает в адрес (ссылку можно передать), + * переживает открытие/закрытие заявки и запоминается для этой таблицы + * (см. persistPage в shared/routing.ts). + */ + page: number; + onPageChange: (page: number) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; } @@ -230,12 +238,13 @@ export const RequestsTableView: React.FC = ({ view, statusFilter = '', onStatusFilterChange, + page, + onPageChange, onOpen, }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); - const [page, setPage] = React.useState(1); const [query, setQuery] = usePersistedQuery(view); const [error, setError] = React.useState(''); // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что @@ -243,7 +252,18 @@ export const RequestsTableView: React.FC = ({ // иначе поиск бил бы запросом по каждой набранной букве. const debouncedQuery = useDebouncedQuery(query); - React.useEffect(() => setPage(1), [debouncedQuery, view, statusFilter]); + // Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3» + // от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь + // НЕ обрабатываем — там страница приходит уже правильной из маршрута + // (см. RequestPortal: gotoOwnTab/setOwnStatus). Сравниваем с предыдущим + // значением, а не просто вешаемся на изменение: иначе эффект сбрасывал бы + // страницу и на первом рендере, затирая ту, что пришла из адреса. + const prevQuery = React.useRef(debouncedQuery); + React.useEffect(() => { + if (prevQuery.current === debouncedQuery) return; + prevQuery.current = debouncedQuery; + if (page !== 1) onPageChange(1); + }, [debouncedQuery, page, onPageChange]); React.useEffect(() => { let cancelled = false; @@ -348,6 +368,14 @@ export const RequestsTableView: React.FC = ({ const visible = rows || []; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + // Страница может оказаться за пределами набора: пришла из старой ссылки или + // из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они + // ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком — + // выглядит как поломка, поэтому возвращаемся на последнюю существующую. + React.useEffect(() => { + if (rows && page > totalPages) onPageChange(totalPages); + }, [rows, page, totalPages, onPageChange]); + return (
@@ -392,7 +420,7 @@ export const RequestsTableView: React.FC = ({ {!error && rows && total > 0 && ( <> - + )} diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index e6e3cbf..38ae872 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -20,6 +20,12 @@ const AUTO_REFRESH_MS = 10 * 60 * 1000; export interface ISearchViewProps { context: WebPartContext; + /** + * Текущая страница (1-based) и её смена — как и в RequestsTableView, страница + * живёт в маршруте, а не внутри компонента (см. shared/routing.ts). + */ + page: number; + onPageChange: (page: number) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; } @@ -94,11 +100,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi // после решения согласующего) — но т.к. это по большей части Search, свежая // правка может проявиться не мгновенно (частично компенсируется довеском // последних 10 минут через REST — см. myRequests.service.ts). -export const SearchView: React.FC = ({ context, onOpen }) => { +export const SearchView: React.FC = ({ context, page, onPageChange, onOpen }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); - const [page, setPage] = React.useState(1); const [query, setQuery] = usePersistedQuery('search'); // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что // уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск @@ -107,7 +112,15 @@ export const SearchView: React.FC = ({ context, onOpen }) => { const debouncedQuery = useDebouncedQuery(query); const [error, setError] = React.useState(''); - React.useEffect(() => setPage(1), [debouncedQuery]); + // Новый запрос — новый набор результатов, страница от прошлого к нему не + // относится. Сравниваем с предыдущим значением, чтобы не сбросить страницу, + // пришедшую из адреса, на первом же рендере (см. RequestsTableView). + const prevQuery = React.useRef(debouncedQuery); + React.useEffect(() => { + if (prevQuery.current === debouncedQuery) return; + prevQuery.current = debouncedQuery; + if (page !== 1) onPageChange(1); + }, [debouncedQuery, page, onPageChange]); React.useEffect(() => { let cancelled = false; @@ -198,7 +211,7 @@ export const SearchView: React.FC = ({ context, onOpen }) => { {!error && hasQuery && rows && totalRows > 0 && ( <>
- + )} diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 6f40557..70e1618 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -1,4 +1,12 @@ -import { IRoute, navigate, parseRoute, persistOwnStatus, persistOwnTab, routeUrl } from './routing'; +import { + IRoute, + navigate, + parseRoute, + persistOwnStatus, + persistOwnTab, + persistPage, + routeUrl, +} from './routing'; // buildQuery/navigate читают и пишут window.location через history API, а // подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnTab/ @@ -11,7 +19,8 @@ beforeEach(() => { describe('parseRoute', () => { it('пустой адрес — подача заявки в разделе «Новая заявка»', () => { - expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new' }); + // page всегда определён (1 — значение по умолчанию), в адрес не пишется. + expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1 }); }); it('rpnav задаёт раздел бокового меню', () => { @@ -32,6 +41,7 @@ describe('parseRoute', () => { nav: 'new', formKey: 'hire', itemId: 42, + page: 1, }); }); @@ -45,6 +55,7 @@ describe('parseRoute', () => { nav: 'new', formKey: 'hire', itemId: 7, + page: 1, }); }); @@ -129,6 +140,87 @@ describe('подвкладки и фильтр «Моих заявок»', () => }); }); +describe('страница таблицы (rppage + persistPage)', () => { + it('без rppage — первая страница', () => { + expect(parseRoute('?rpnav=own').page).toBe(1); + }); + + it('rppage задаёт страницу', () => { + expect(parseRoute('?rpnav=own&rppage=3').page).toBe(3); + }); + + it('мусор в rppage трактуется как первая страница, а не роняет разбор', () => { + expect(parseRoute('?rpnav=own&rppage=0').page).toBe(1); + expect(parseRoute('?rpnav=own&rppage=-2').page).toBe(1); + expect(parseRoute('?rpnav=own&rppage=abc').page).toBe(1); + expect(parseRoute('?rpnav=own&rppage=1.5').page).toBe(1); + }); + + it('первая страница не засоряет адрес, непервая — попадает', () => { + expect(routeUrl({ mode: 'submit', nav: 'own', page: 1 })).not.toContain('rppage'); + expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rppage'); + expect(routeUrl({ mode: 'submit', nav: 'own', page: 4 })).toContain('rppage=4'); + }); + + it('round-trip страницы через navigate', () => { + navigate({ mode: 'submit', nav: 'approvals', page: 2 }); + expect(parseRoute(window.location.search).page).toBe(2); + }); + + it('без rppage подставляется запомненная для ЭТОЙ таблицы страница', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + expect(parseRoute('?rpnav=approvals').page).toBe(5); + }); + + it('страница запоминается отдельно по таблицам и не переносится между разделами', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + // В другом разделе своя нумерация — чужая страница туда попасть не должна, + // иначе можно приземлиться на несуществующую (у него всего одна страница). + expect(parseRoute('?rpnav=execution').page).toBe(1); + }); + + it('подвкладки «Моих заявок» — тоже разные таблицы, у каждой своя страница', () => { + persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 3); + persistPage({ mode: 'submit', nav: 'own', ownTab: 'approved' }, 7); + + expect(parseRoute('?rpnav=own&rptab=created').page).toBe(3); + expect(parseRoute('?rpnav=own&rptab=approved').page).toBe(7); + }); + + it('явная страница в адресе имеет приоритет над запомненной', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + expect(parseRoute('?rpnav=approvals&rppage=2').page).toBe(2); + }); + + it('возврат на первую страницу стирает запомненную', () => { + persistPage({ mode: 'submit', nav: 'approvals' }, 5); + expect(parseRoute('?rpnav=approvals').page).toBe(5); + + persistPage({ mode: 'submit', nav: 'approvals' }, 1); + expect(parseRoute('?rpnav=approvals').page).toBe(1); + }); + + it('страница переживает открытие заявки поверх списка', () => { + navigate({ mode: 'submit', nav: 'own', ownTab: 'created', page: 3 }); + navigate({ mode: 'approve', nav: 'own', ownTab: 'created', formKey: 'hire', itemId: 42, page: 3 }); + expect(parseRoute(window.location.search).page).toBe(3); + }); + + it('localStorage недоступен — страница просто первая, без падения', () => { + const original = window.localStorage.getItem; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- имитация приватного режима, где чтение localStorage бросает + (window.localStorage as any).getItem = (): never => { + throw new Error('SecurityError'); + }; + try { + expect(() => parseRoute('?rpnav=own')).not.toThrow(); + expect(parseRoute('?rpnav=own').page).toBe(1); + } finally { + window.localStorage.getItem = original; + } + }); +}); + describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => { it('без явного rptab в адресе подставляется запомненная подвкладка', () => { persistOwnTab('rejected'); @@ -230,6 +322,7 @@ describe('navigate', () => { // нет только потому, что это значение по умолчанию. ownTab: 'created', ownStatus: undefined, + page: 1, }); }); diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 4055eb0..012b841 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -58,6 +58,8 @@ export interface IRoute { ownTab?: OwnTab; /** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */ ownStatus?: string; + /** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */ + page?: number; } const ROUTE_CHANGE_EVENT = 'rp-route-change'; @@ -88,6 +90,50 @@ const ROUTE_CHANGE_EVENT = 'rp-route-change'; // вернувшись обратно, фильтр каждый раз сбрасывался на «Все статусы». const OWN_TAB_STORAGE_KEY = 'rp-own-tab'; const OWN_STATUS_STORAGE_KEY = 'rp-own-status'; +// Страница запоминается ОТДЕЛЬНО ДЛЯ КАЖДОЙ таблицы (см. viewKeyOf): один общий +// номер переносился бы между разделами, где у каждого свой объём данных, — +// уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего +// одна, пользователь попал бы в пустоту. +const PAGE_STORAGE_PREFIX = 'rp-page:'; + +/** + * Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в + * RequestsTableView (плюс 'search'): у каждой подвкладки «Моих заявок» свой + * список и, значит, своя страница. + */ +function viewKeyOf(nav: NavSection, ownTab: OwnTab | undefined): string { + return nav === 'own' ? `own:${ownTab ?? 'created'}` : nav; +} + +function readStoredPage(viewKey: string): number | undefined { + try { + const raw = window.localStorage.getItem(PAGE_STORAGE_PREFIX + viewKey); + if (!raw) return undefined; + const page = Number(raw); + return Number.isInteger(page) && page > 1 ? page : undefined; + } catch { + return undefined; + } +} + +/** + * Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута — + * вызывающей стороне не нужно знать, как таблицы различаются между собой. + */ +export function persistPage(route: IRoute, page: number): void { + const viewKey = viewKeyOf(route.nav, route.ownTab); + try { + // Первая страница — значение по умолчанию: храним её ОТСУТСТВИЕМ ключа, + // иначе в localStorage копился бы мусор из единиц по каждой таблице. + if (page > 1) { + window.localStorage.setItem(PAGE_STORAGE_PREFIX + viewKey, String(page)); + } else { + window.localStorage.removeItem(PAGE_STORAGE_PREFIX + viewKey); + } + } catch { + // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. + } +} function readStoredOwnTab(): OwnTab { try { @@ -164,21 +210,29 @@ export function parseRoute(search: string = window.location.search): IRoute { const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined; const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined; + // Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в + // параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку: + // ссылка приходит извне, ронять из-за неё раздел незачем. + const pageRaw = Number(params.get('rppage')); + const page = + Number.isInteger(pageRaw) && pageRaw > 1 ? pageRaw : readStoredPage(viewKeyOf(nav, ownTab)) ?? 1; + // Открытая заявка (панель поверх раздела) — при полном наборе параметров. - // Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: - // закрыв панель, пользователь должен вернуться ровно в тот же список. + // Раздел под панелью сохраняется целиком, вместе с подвкладкой, фильтром и + // страницей: закрыв панель, пользователь должен вернуться ровно в тот же + // список и на то же место в нём. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus }; + return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page }; } // Редактирование собственной незавершённой заявки — та же панель, но с формой. if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus }; + return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page }; } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { return { mode: 'create', nav: 'new', formKey, sourceId }; } - return { mode: 'submit', nav, ownTab, ownStatus }; + return { mode: 'submit', nav, ownTab, ownStatus, page }; } // Собрать query-строку маршрута, сохранив посторонние параметры страницы. @@ -191,6 +245,7 @@ function buildQuery(route: IRoute): string { params.delete('rpsource'); params.delete('rptab'); params.delete('rpstatus'); + params.delete('rppage'); if (route.nav !== 'new') { params.set('rpnav', route.nav); @@ -202,6 +257,8 @@ function buildQuery(route: IRoute): string { if (ownTab !== 'created') params.set('rptab', ownTab); else if (route.ownStatus) params.set('rpstatus', route.ownStatus); } + // Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку). + if (route.page && route.page > 1) params.set('rppage', String(route.page)); if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) { params.set('rpmode', route.mode); params.set('rptype', route.formKey);