роутинг пагинации

This commit is contained in:
gorbenko.pavel
2026-08-10 17:12:23 +05:00
parent 36ee7ec009
commit a80dc17c3e
5 changed files with 230 additions and 16 deletions
@@ -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,
});
});
+62 -5
View File
@@ -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);