Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
@@ -24,8 +24,25 @@ import * as React from 'react';
|
||||
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
|
||||
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||
|
||||
/** Раздел бокового меню. */
|
||||
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
||||
/**
|
||||
* Раздел бокового меню.
|
||||
*
|
||||
* Три раздела — очереди действий текущего пользователя, и они НЕ пересекаются
|
||||
* (см. ApprovalQueueKind в myRequests.service.ts):
|
||||
* 'approvals' — ждут его согласования (статус строки «На согласовании»),
|
||||
* 'execution' — ждут его исполнения (статус строки «На исполнении»),
|
||||
* 'rework' — ЕГО заявки, которые вернули ему на доработку (статус ЗАЯВКИ
|
||||
* «Возвращена на доработку»); в отличие от первых двух смотрит
|
||||
* на список данных по автору, а не на строки маршрута.
|
||||
*/
|
||||
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search';
|
||||
|
||||
/**
|
||||
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
|
||||
* (единственная, где есть фильтр по статусу); остальные — история его решений
|
||||
* как участника чужих маршрутов.
|
||||
*/
|
||||
export type OwnTab = 'created' | 'approved' | 'rejected' | 'executed';
|
||||
|
||||
export interface IRoute {
|
||||
mode: PortalMode;
|
||||
@@ -37,15 +54,207 @@ export interface IRoute {
|
||||
itemId?: number;
|
||||
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
||||
sourceId?: number;
|
||||
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
|
||||
ownTab?: OwnTab;
|
||||
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
|
||||
ownStatus?: string;
|
||||
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
|
||||
page?: number;
|
||||
/** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */
|
||||
query?: string;
|
||||
}
|
||||
|
||||
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
||||
|
||||
// Подвкладка и фильтр «Моих заявок» переживают перезагрузку страницы и новый
|
||||
// визит (localStorage), но НЕ более того — как и текст поиска в
|
||||
// usePersistedQuery.ts, это просто «продолжить с того же места», а не
|
||||
// синхронизация между вкладками/устройствами.
|
||||
//
|
||||
// Читаются здесь же, в parseRoute, а не отдельным хуком в компоненте: URL уже
|
||||
// является единственным источником правды для ownTab/ownStatus (см.
|
||||
// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут,
|
||||
// где URL молчит, естественно спросить «а что было в прошлый раз».
|
||||
// Пишутся ТОЛЬКО через persistOwnTab/persistOwnStatus — то есть только там,
|
||||
// где пользователь СОЗНАТЕЛЬНО меняет вкладку или фильтр (см.
|
||||
// gotoOwnTab/setOwnStatus в RequestPortal.tsx), а не при каждой навигации:
|
||||
// иначе, например, просто открыв и закрыв заявку, можно было бы затереть
|
||||
// запомненное значение значением по умолчанию.
|
||||
//
|
||||
// НАМЕРЕННО два отдельных сеттера, а не один persistOwnSelection(tab, status):
|
||||
// переключение НА подвкладку с решениями (approved/rejected/executed) должно
|
||||
// запомнить только её саму, не трогая фильтр статуса — тот относится
|
||||
// ИСКЛЮЧИТЕЛЬНО к «Инициированным» и при возврате туда должен найтись таким
|
||||
// же, каким был оставлен. Один сеттер этого не даёт: при переключении на
|
||||
// другую подвкладку он получал бы ownStatus=undefined (потому что на других
|
||||
// подвкладках фильтра просто нет) и стирал бы запомненное значение — что и
|
||||
// произошло на практике: уйдя с «Инициированных» на «Согласованные» и
|
||||
// вернувшись обратно, фильтр каждый раз сбрасывался на «Все статусы».
|
||||
const OWN_TAB_STORAGE_KEY = 'rp-own-tab';
|
||||
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
|
||||
// Страница запоминается ОТДЕЛЬНО ДЛЯ КАЖДОЙ таблицы (см. viewKeyOf): один общий
|
||||
// номер переносился бы между разделами, где у каждого свой объём данных, —
|
||||
// уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего
|
||||
// одна, пользователь попал бы в пустоту.
|
||||
const PAGE_STORAGE_PREFIX = 'rp-page:';
|
||||
// Текст поиска — тоже свой на каждую таблицу. Префикс НЕ меняем: под ним
|
||||
// поисковые запросы уже лежат (раньше их хранил хук usePersistedQuery), и
|
||||
// сохранённое у пользователей должно пережить переезд в маршрут.
|
||||
const QUERY_STORAGE_PREFIX = 'rp-search-query:';
|
||||
|
||||
/**
|
||||
* Ключ таблицы для запоминания страницы. Совпадает по смыслу с 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запомненная страница таблицы, на которую собираемся перейти. Нужна там, где
|
||||
* маршрут СТРОИТСЯ (переход по меню/подвкладке): parseRoute подставил бы её и
|
||||
* сам, но тогда номер остался бы только в состоянии и не попал бы в адрес —
|
||||
* ссылка на список показывала бы не ту страницу, что открыта на экране.
|
||||
*
|
||||
* ownTab не задан (переход в «Мои заявки» из другого раздела) — берём
|
||||
* запомненную подвкладку: именно она и откроется (см. parseOwnTab).
|
||||
*/
|
||||
export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number {
|
||||
const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined;
|
||||
return readStoredPage(viewKeyOf(nav, tab)) ?? 1;
|
||||
}
|
||||
|
||||
function readStoredQuery(viewKey: string): string {
|
||||
try {
|
||||
return window.localStorage.getItem(QUERY_STORAGE_PREFIX + viewKey) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Запомненный текст поиска таблицы — по тем же причинам, что и storedPageFor. */
|
||||
export function storedQueryFor(nav: NavSection, ownTab?: OwnTab): string {
|
||||
const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined;
|
||||
return readStoredQuery(viewKeyOf(nav, tab));
|
||||
}
|
||||
|
||||
/** Запомнить текст поиска текущей таблицы. */
|
||||
export function persistQuery(route: IRoute, query: string): void {
|
||||
const viewKey = viewKeyOf(route.nav, route.ownTab);
|
||||
try {
|
||||
if (query) {
|
||||
window.localStorage.setItem(QUERY_STORAGE_PREFIX + viewKey, query);
|
||||
} else {
|
||||
window.localStorage.removeItem(QUERY_STORAGE_PREFIX + viewKey);
|
||||
}
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута —
|
||||
* вызывающей стороне не нужно знать, как таблицы различаются между собой.
|
||||
*/
|
||||
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 {
|
||||
const raw = window.localStorage.getItem(OWN_TAB_STORAGE_KEY);
|
||||
return raw === 'approved' || raw === 'rejected' || raw === 'executed' ? raw : 'created';
|
||||
} catch {
|
||||
return 'created';
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredOwnStatus(): string | undefined {
|
||||
try {
|
||||
return window.localStorage.getItem(OWN_STATUS_STORAGE_KEY) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запомненные подвкладка и фильтр — как и storedPageFor, нужны там, где маршрут
|
||||
* СТРОИТСЯ: parseRoute подставил бы их и сам, но тогда они остались бы только в
|
||||
* состоянии и не попали бы в адрес (ссылка на раздел вела бы не туда, что
|
||||
* открыто на экране).
|
||||
*/
|
||||
export function storedOwnTab(): OwnTab {
|
||||
return readStoredOwnTab();
|
||||
}
|
||||
|
||||
/** Вне вкладки «Инициированные» фильтра по статусу нет по смыслу — там отбор задаёт сама вкладка. */
|
||||
export function storedOwnStatus(ownTab: OwnTab): string | undefined {
|
||||
return ownTab === 'created' ? readStoredOwnStatus() : undefined;
|
||||
}
|
||||
|
||||
/** Запомнить подвкладку «Моих заявок» — вызывать только при явном выборе пользователя. */
|
||||
export function persistOwnTab(ownTab: OwnTab): void {
|
||||
try {
|
||||
window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab);
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
/** Запомнить фильтр по статусу вкладки «Инициированные» — вызывать только при явном выборе пользователя. */
|
||||
export function persistOwnStatus(ownStatus: string | undefined): void {
|
||||
try {
|
||||
if (ownStatus) {
|
||||
window.localStorage.setItem(OWN_STATUS_STORAGE_KEY, ownStatus);
|
||||
} else {
|
||||
window.localStorage.removeItem(OWN_STATUS_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
function parseNav(raw: string | undefined): NavSection {
|
||||
if (raw === 'approvals' || raw === 'own' || raw === 'search') return raw;
|
||||
if (
|
||||
raw === 'approvals' ||
|
||||
raw === 'execution' ||
|
||||
raw === 'rework' ||
|
||||
raw === 'own' ||
|
||||
raw === 'search'
|
||||
) {
|
||||
return raw;
|
||||
}
|
||||
return 'new';
|
||||
}
|
||||
|
||||
function parseOwnTab(raw: string | undefined): OwnTab {
|
||||
if (raw === 'approved' || raw === 'rejected' || raw === 'executed' || raw === 'created') return raw;
|
||||
return readStoredOwnTab();
|
||||
}
|
||||
|
||||
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||||
export function parseRoute(search: string = window.location.search): IRoute {
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -57,19 +266,39 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
const sourceRaw = params.get('rpsource');
|
||||
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
|
||||
|
||||
// Подвкладка и фильтр — состояние раздела «Мои заявки», поэтому вне его не
|
||||
// читаются вовсе (иначе чужой rptab из ссылки «протёк» бы в другой раздел).
|
||||
// Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с
|
||||
// решениями отбор задаёт сама вкладка.
|
||||
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;
|
||||
|
||||
// Текст поиска — из адреса, иначе запомненный для этой же таблицы.
|
||||
const query = params.get('rpq') ?? readStoredQuery(viewKeyOf(nav, ownTab));
|
||||
|
||||
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
|
||||
// Раздел под панелью сохраняется целиком, вместе с подвкладкой, фильтром и
|
||||
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же
|
||||
// список и на то же место в нём.
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'approve', nav, formKey, itemId };
|
||||
return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query };
|
||||
}
|
||||
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
|
||||
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'edit', nav, formKey, itemId };
|
||||
return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||
}
|
||||
return { mode: 'submit', nav };
|
||||
return { mode: 'submit', nav, ownTab, ownStatus, page, query };
|
||||
}
|
||||
|
||||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
||||
@@ -80,10 +309,24 @@ function buildQuery(route: IRoute): string {
|
||||
params.delete('rptype');
|
||||
params.delete('rpid');
|
||||
params.delete('rpsource');
|
||||
params.delete('rptab');
|
||||
params.delete('rpstatus');
|
||||
params.delete('rppage');
|
||||
params.delete('rpq');
|
||||
|
||||
if (route.nav !== 'new') {
|
||||
params.set('rpnav', route.nav);
|
||||
}
|
||||
// Значения по умолчанию в адрес не пишем — чтобы обычный переход на «Мои
|
||||
// заявки» давал чистую ссылку, а не ?rpnav=own&rptab=created.
|
||||
if (route.nav === 'own') {
|
||||
const ownTab = route.ownTab ?? 'created';
|
||||
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.query) params.set('rpq', route.query);
|
||||
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
|
||||
params.set('rpmode', route.mode);
|
||||
params.set('rptype', route.formKey);
|
||||
@@ -101,9 +344,19 @@ function buildQuery(route: IRoute): string {
|
||||
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
|
||||
* query-параметры адресной строки через history.pushState и оповещает
|
||||
* useRoute() своим событием (popstate на pushState сам не срабатывает).
|
||||
*
|
||||
* replace=true заменяет текущую запись истории вместо добавления новой —
|
||||
* для набора текста в поиске: иначе каждая буква становилась бы отдельным
|
||||
* шагом «назад», и чтобы уйти со страницы, пришлось бы стереть весь запрос
|
||||
* по одному нажатию.
|
||||
*/
|
||||
export function navigate(route: IRoute): void {
|
||||
window.history.pushState({}, '', buildQuery(route));
|
||||
export function navigate(route: IRoute, options?: { replace?: boolean }): void {
|
||||
const url = buildQuery(route);
|
||||
if (options?.replace) {
|
||||
window.history.replaceState({}, '', url);
|
||||
} else {
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user