роутинг пагинации
This commit is contained in:
@@ -2,7 +2,15 @@ import * as React from 'react';
|
|||||||
import styles from './RequestPortal.module.scss';
|
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, 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 { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||||
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||||||
import { SideNav } from './SideNav/SideNav';
|
import { SideNav } from './SideNav/SideNav';
|
||||||
@@ -131,7 +139,18 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
const setOwnStatus = (ownStatus: string): void => {
|
const setOwnStatus = (ownStatus: string): void => {
|
||||||
const normalized = ownStatus || undefined;
|
const normalized = ownStatus || undefined;
|
||||||
persistOwnStatus(normalized);
|
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<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
{route.nav === 'search' && (
|
{route.nav === 'search' && (
|
||||||
<SearchView
|
<SearchView
|
||||||
context={context}
|
context={context}
|
||||||
|
page={route.page ?? 1}
|
||||||
|
onPageChange={setPage}
|
||||||
onOpen={openRequest}
|
onOpen={openRequest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -239,6 +260,8 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
}
|
}
|
||||||
statusFilter={route.ownStatus ?? ''}
|
statusFilter={route.ownStatus ?? ''}
|
||||||
onStatusFilterChange={setOwnStatus}
|
onStatusFilterChange={setOwnStatus}
|
||||||
|
page={route.page ?? 1}
|
||||||
|
onPageChange={setPage}
|
||||||
onOpen={openRequest}
|
onOpen={openRequest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ export interface IRequestsTableViewProps {
|
|||||||
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
|
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
|
||||||
statusFilter?: string;
|
statusFilter?: string;
|
||||||
onStatusFilterChange?: (status: string) => void;
|
onStatusFilterChange?: (status: string) => void;
|
||||||
|
/**
|
||||||
|
* Текущая страница (1-based) и её смена. Страница живёт в маршруте, а не
|
||||||
|
* внутри компонента: так она попадает в адрес (ссылку можно передать),
|
||||||
|
* переживает открытие/закрытие заявки и запоминается для этой таблицы
|
||||||
|
* (см. persistPage в shared/routing.ts).
|
||||||
|
*/
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
}
|
}
|
||||||
@@ -230,12 +238,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
view,
|
view,
|
||||||
statusFilter = '',
|
statusFilter = '',
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
onOpen,
|
onOpen,
|
||||||
}) => {
|
}) => {
|
||||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
const [page, setPage] = React.useState<number>(1);
|
|
||||||
const [query, setQuery] = usePersistedQuery(view);
|
const [query, setQuery] = usePersistedQuery(view);
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||||
@@ -243,7 +252,18 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
// иначе поиск бил бы запросом по каждой набранной букве.
|
// иначе поиск бил бы запросом по каждой набранной букве.
|
||||||
const debouncedQuery = useDebouncedQuery(query);
|
const debouncedQuery = useDebouncedQuery(query);
|
||||||
|
|
||||||
React.useEffect(() => setPage(1), [debouncedQuery, view, statusFilter]);
|
// Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3»
|
||||||
|
// от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь
|
||||||
|
// НЕ обрабатываем — там страница приходит уже правильной из маршрута
|
||||||
|
// (см. RequestPortal: gotoOwnTab/setOwnStatus). Сравниваем с предыдущим
|
||||||
|
// значением, а не просто вешаемся на изменение: иначе эффект сбрасывал бы
|
||||||
|
// страницу и на первом рендере, затирая ту, что пришла из адреса.
|
||||||
|
const prevQuery = React.useRef<string>(debouncedQuery);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (prevQuery.current === debouncedQuery) return;
|
||||||
|
prevQuery.current = debouncedQuery;
|
||||||
|
if (page !== 1) onPageChange(1);
|
||||||
|
}, [debouncedQuery, page, onPageChange]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -348,6 +368,14 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const visible = rows || [];
|
const visible = rows || [];
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
// Страница может оказаться за пределами набора: пришла из старой ссылки или
|
||||||
|
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
|
||||||
|
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
|
||||||
|
// выглядит как поломка, поэтому возвращаемся на последнюю существующую.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (rows && page > totalPages) onPageChange(totalPages);
|
||||||
|
}, [rows, page, totalPages, onPageChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.view}>
|
<section className={styles.view}>
|
||||||
<div className={styles.head}>
|
<div className={styles.head}>
|
||||||
@@ -392,7 +420,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
{!error && rows && total > 0 && (
|
{!error && rows && total > 0 && (
|
||||||
<>
|
<>
|
||||||
<Table rows={visible} view={view} onOpen={onOpen} />
|
<Table rows={visible} view={view} onOpen={onOpen} />
|
||||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
|||||||
|
|
||||||
export interface ISearchViewProps {
|
export interface ISearchViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
|
/**
|
||||||
|
* Текущая страница (1-based) и её смена — как и в RequestsTableView, страница
|
||||||
|
* живёт в маршруте, а не внутри компонента (см. shared/routing.ts).
|
||||||
|
*/
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
}
|
}
|
||||||
@@ -94,11 +100,10 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
|||||||
// после решения согласующего) — но т.к. это по большей части Search, свежая
|
// после решения согласующего) — но т.к. это по большей части Search, свежая
|
||||||
// правка может проявиться не мгновенно (частично компенсируется довеском
|
// правка может проявиться не мгновенно (частично компенсируется довеском
|
||||||
// последних 10 минут через REST — см. myRequests.service.ts).
|
// последних 10 минут через REST — см. myRequests.service.ts).
|
||||||
export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageChange, onOpen }) => {
|
||||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
const [page, setPage] = React.useState<number>(1);
|
|
||||||
const [query, setQuery] = usePersistedQuery('search');
|
const [query, setQuery] = usePersistedQuery('search');
|
||||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||||
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
|
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
|
||||||
@@ -107,7 +112,15 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
const debouncedQuery = useDebouncedQuery(query);
|
const debouncedQuery = useDebouncedQuery(query);
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
|
|
||||||
React.useEffect(() => setPage(1), [debouncedQuery]);
|
// Новый запрос — новый набор результатов, страница от прошлого к нему не
|
||||||
|
// относится. Сравниваем с предыдущим значением, чтобы не сбросить страницу,
|
||||||
|
// пришедшую из адреса, на первом же рендере (см. RequestsTableView).
|
||||||
|
const prevQuery = React.useRef<string>(debouncedQuery);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (prevQuery.current === debouncedQuery) return;
|
||||||
|
prevQuery.current = debouncedQuery;
|
||||||
|
if (page !== 1) onPageChange(1);
|
||||||
|
}, [debouncedQuery, page, onPageChange]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -198,7 +211,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
{!error && hasQuery && rows && totalRows > 0 && (
|
{!error && hasQuery && rows && totalRows > 0 && (
|
||||||
<>
|
<>
|
||||||
<Table rows={rows} onOpen={onOpen} />
|
<Table rows={rows} onOpen={onOpen} />
|
||||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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, а
|
// buildQuery/navigate читают и пишут window.location через history API, а
|
||||||
// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnTab/
|
// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnTab/
|
||||||
@@ -11,7 +19,8 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('parseRoute', () => {
|
describe('parseRoute', () => {
|
||||||
it('пустой адрес — подача заявки в разделе «Новая заявка»', () => {
|
it('пустой адрес — подача заявки в разделе «Новая заявка»', () => {
|
||||||
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new' });
|
// page всегда определён (1 — значение по умолчанию), в адрес не пишется.
|
||||||
|
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rpnav задаёт раздел бокового меню', () => {
|
it('rpnav задаёт раздел бокового меню', () => {
|
||||||
@@ -32,6 +41,7 @@ describe('parseRoute', () => {
|
|||||||
nav: 'new',
|
nav: 'new',
|
||||||
formKey: 'hire',
|
formKey: 'hire',
|
||||||
itemId: 42,
|
itemId: 42,
|
||||||
|
page: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +55,7 @@ describe('parseRoute', () => {
|
|||||||
nav: 'new',
|
nav: 'new',
|
||||||
formKey: 'hire',
|
formKey: 'hire',
|
||||||
itemId: 7,
|
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 — запоминание подвкладки/фильтра между визитами', () => {
|
describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => {
|
||||||
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
||||||
persistOwnTab('rejected');
|
persistOwnTab('rejected');
|
||||||
@@ -230,6 +322,7 @@ describe('navigate', () => {
|
|||||||
// нет только потому, что это значение по умолчанию.
|
// нет только потому, что это значение по умолчанию.
|
||||||
ownTab: 'created',
|
ownTab: 'created',
|
||||||
ownStatus: undefined,
|
ownStatus: undefined,
|
||||||
|
page: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface IRoute {
|
|||||||
ownTab?: OwnTab;
|
ownTab?: OwnTab;
|
||||||
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
|
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
|
||||||
ownStatus?: string;
|
ownStatus?: string;
|
||||||
|
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
|
||||||
|
page?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
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_TAB_STORAGE_KEY = 'rp-own-tab';
|
||||||
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
|
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 {
|
function readStoredOwnTab(): OwnTab {
|
||||||
try {
|
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 ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
|
||||||
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : 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)) {
|
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)) {
|
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)) {
|
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||||
}
|
}
|
||||||
return { mode: 'submit', nav, ownTab, ownStatus };
|
return { mode: 'submit', nav, ownTab, ownStatus, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
||||||
@@ -191,6 +245,7 @@ function buildQuery(route: IRoute): string {
|
|||||||
params.delete('rpsource');
|
params.delete('rpsource');
|
||||||
params.delete('rptab');
|
params.delete('rptab');
|
||||||
params.delete('rpstatus');
|
params.delete('rpstatus');
|
||||||
|
params.delete('rppage');
|
||||||
|
|
||||||
if (route.nav !== 'new') {
|
if (route.nav !== 'new') {
|
||||||
params.set('rpnav', route.nav);
|
params.set('rpnav', route.nav);
|
||||||
@@ -202,6 +257,8 @@ function buildQuery(route: IRoute): string {
|
|||||||
if (ownTab !== 'created') params.set('rptab', ownTab);
|
if (ownTab !== 'created') params.set('rptab', ownTab);
|
||||||
else if (route.ownStatus) params.set('rpstatus', route.ownStatus);
|
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) {
|
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
|
||||||
params.set('rpmode', route.mode);
|
params.set('rpmode', route.mode);
|
||||||
params.set('rptype', route.formKey);
|
params.set('rptype', route.formKey);
|
||||||
|
|||||||
Reference in New Issue
Block a user