Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/routing.ts
T
gorbenko.pavel a8bfd9f456
Tests / frontend-test (push) Successful in 49s
Tests / backend-test (push) Successful in 1m38s
Feature/request type access (#23)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/23
2026-09-25 11:48:02 +00:00

249 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import { ScreenKey } from './components/app/types';
import { ResolveFieldValue, RouteStepStatus } from './models';
// Маршрутизация веб-части по параметрам URL: у каждой верхней вкладки свой
// адрес, а «Проверка маршрута» и «Редактирование заявок» — ещё и глубокие
// ссылки на конкретный подбор (маршрут+сотрудник / конкретная заявка), которые
// можно передать коллеге или обновить страницу без потери контекста.
//
// Префикс aa (approval admin) — тот же приём, что в request-portal
// (src/webparts/requestPortal/shared/routing.ts, префикс rp): SharePoint
// резервирует часть имён параметров (id/mode/type) под свою навигацию, поэтому
// используем собственные, заведомо не пересекающиеся имена.
//
// Навигация — history.pushState без перезагрузки страницы (SPA); useRoute()
// подписан и на popstate (кнопка «назад»), и на собственное событие (pushState
// само popstate не порождает).
export interface IResolveRouteParams {
requestTypeId: string;
userEmail: string;
projectId: string;
/** Название проекта — чтобы показать выбор до отдельного похода в справочник. */
projectLabel: string;
status: RouteStepStatus;
fieldValues: ResolveFieldValue[];
/**
* Для шагов с HeadType.DepartmentHead (см. ResolveTemplateRouteQuery.departmentId).
* Без отдельного departmentLabel — в отличие от projectLabel (AsyncSearchableSelect,
* пагинированный поиск), отделы выбираются из уже целиком загруженного справочника
* (SearchableSelect), так что подпись резолвится на месте по id, а не хранится отдельно.
*/
departmentId: string;
}
export interface IManageByUserRouteParams {
/** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */
userEmail: string;
/**
* На кого заменять. Часть ссылки, а не только состояние экрана: замена обычно
* идёт в несколько приёмов (сначала заявки, потом маршруты, потом что-то
* отдельной строкой), и выбранного «получателя» незачем терять между ними.
* Пусто — получатель ещё не выбран.
*/
targetEmail: string;
}
export interface IManageRequestRouteParams {
requestTypeId: string;
requestId: string;
/**
* Партиция (список-архив данных), где физически лежит заявка — у разных
* партиций независимая нумерация Id (см. ТЗ по архивации), поэтому без
* listId requestId неоднозначен. Пусто — старая ссылка (до архивации) или
* ссылка ещё не резолвнута; тогда экран считает, что заявка в текущей
* партиции (см. ManageRequestsScreen).
*/
listId: string;
}
export interface IAdminRoute {
tab: ScreenKey;
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
routeId?: number;
/**
* Фильтр списка маршрутов по типу заявки (id как строка, '' — все типы).
* Живёт и при открытых шагах (routeId): возврат «к маршрутам» должен вернуть
* к тому же отфильтрованному списку, а не сбросить фильтр.
*/
routesFilter?: string;
/**
* Фильтры таблицы «Доступ к заявкам» (id типа как строка, email) — только при
* tab='requestTypeAccess'. Та же логика, что и у routesFilter: живут в адресе,
* чтобы отфильтрованную ссылку можно было передать и чтобы фильтр не терялся
* при возврате на вкладку.
*/
accessFilterRequestTypeId?: string;
accessFilterEmail?: string;
/** Задан только при tab='resolve' и непустом requestTypeId. */
resolve?: IResolveRouteParams;
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
manage?: IManageRequestRouteParams;
/** Задан только при tab='manageByUser' и непустом userEmail. */
manageByUser?: IManageByUserRouteParams;
}
const ROUTE_CHANGE_EVENT = 'aa-route-change';
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
const PARAM_KEYS = [
'aatab', 'aaroute', 'aarfilter',
'aaacctype', 'aaaccemail',
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
'aamtype', 'aamreq', 'aamlist',
'aabuser', 'aabtarget'
];
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
// eslint-disable-next-line @rushstack/no-new-null
function parseTab(raw: string | null): ScreenKey {
return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard';
}
// eslint-disable-next-line @rushstack/no-new-null -- raw — результат URLSearchParams.get() (DOM API)
function parseFieldValues(raw: string | null): ResolveFieldValue[] {
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
.map(x => ({ fieldName: String(x.fieldName || ''), value: String(x.value || '') }));
} catch {
// Битый/устаревший JSON в ссылке — не роняем экран, просто без предзаполненных условий.
return [];
}
}
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
export function parseRoute(search: string = window.location.search): IAdminRoute {
const params = new URLSearchParams(search);
const tab = parseTab(params.get('aatab'));
const routeIdRaw = tab === 'routes' ? params.get('aaroute') : null;
const routeId = routeIdRaw ? Number(routeIdRaw) : undefined;
const resolveTypeId = params.get('aartype');
const resolve: IResolveRouteParams | undefined =
tab === 'resolve' && resolveTypeId
? {
requestTypeId: resolveTypeId,
userEmail: params.get('aauser') || '',
projectId: params.get('aaproject') || '',
projectLabel: params.get('aaprojectname') || '',
status: (params.get('aastatus') as RouteStepStatus) || RouteStepStatus.Approval,
fieldValues: parseFieldValues(params.get('aafields')),
departmentId: params.get('aadept') || ''
}
: undefined;
const manageTypeId = params.get('aamtype');
const manageReqId = params.get('aamreq');
const manage: IManageRequestRouteParams | undefined =
tab === 'manageRequests' && manageTypeId && manageReqId
? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
: undefined;
const byUserEmail = tab === 'manageByUser' ? params.get('aabuser') : null;
return {
tab,
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
accessFilterRequestTypeId: (tab === 'requestTypeAccess' ? params.get('aaacctype') : null) || undefined,
accessFilterEmail: (tab === 'requestTypeAccess' ? params.get('aaaccemail') : null) || undefined,
resolve,
manage,
manageByUser: byUserEmail
? { userEmail: byUserEmail, targetEmail: params.get('aabtarget') || '' }
: undefined
};
}
// Собрать query-строку маршрута, сохранив посторонние параметры страницы
// (SharePoint/Workbench могут держать в URL свои — их трогать нельзя).
function buildQuery(route: IAdminRoute): string {
const params = new URLSearchParams(window.location.search);
PARAM_KEYS.forEach(k => params.delete(k));
if (route.tab !== 'dashboard') params.set('aatab', route.tab);
if (route.tab === 'routes') {
if (route.routeId) params.set('aaroute', String(route.routeId));
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
}
if (route.tab === 'requestTypeAccess') {
if (route.accessFilterRequestTypeId) params.set('aaacctype', route.accessFilterRequestTypeId);
if (route.accessFilterEmail) params.set('aaaccemail', route.accessFilterEmail);
}
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
params.set('aartype', route.resolve.requestTypeId);
if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail);
if (route.resolve.projectId) params.set('aaproject', route.resolve.projectId);
if (route.resolve.projectLabel) params.set('aaprojectname', route.resolve.projectLabel);
if (route.resolve.status) params.set('aastatus', route.resolve.status);
if (route.resolve.fieldValues.length > 0) params.set('aafields', JSON.stringify(route.resolve.fieldValues));
if (route.resolve.departmentId) params.set('aadept', route.resolve.departmentId);
}
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
params.set('aamtype', route.manage.requestTypeId);
params.set('aamreq', route.manage.requestId);
if (route.manage.listId) params.set('aamlist', route.manage.listId);
}
if (route.tab === 'manageByUser' && route.manageByUser?.userEmail) {
params.set('aabuser', route.manageByUser.userEmail);
if (route.manageByUser.targetEmail) params.set('aabtarget', route.manageByUser.targetEmail);
}
const query = params.toString();
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
}
/** Перейти на другой маршрут без перезагрузки страницы (SPA) — новая запись в истории. */
export function navigate(route: IAdminRoute): void {
window.history.pushState({}, '', buildQuery(route));
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
}
/**
* Обновить параметры ТЕКУЩЕГО маршрута без новой записи в истории — для форм
* (ResolveScreen/ManageRequestsScreen), которые синхронизируют URL на каждое
* изменение поля: pushState на каждый символ ввода превратил бы кнопку «назад»
* в полистывание истории набора текста вместо переключения вкладок.
*/
export function replaceRoute(route: IAdminRoute): void {
window.history.replaceState({}, '', buildQuery(route));
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
}
/** Ссылка на маршрут — для href (открыть в новой вкладке, скопировать, поделиться). */
export function routeUrl(route: IAdminRoute): string {
return buildQuery(route);
}
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
export function useRoute(): IAdminRoute {
const [route, setRoute] = React.useState<IAdminRoute>(() => parseRoute());
React.useEffect(() => {
const onChange = (): void => setRoute(parseRoute());
window.addEventListener('popstate', onChange);
window.addEventListener(ROUTE_CHANGE_EVENT, onChange);
return () => {
window.removeEventListener('popstate', onChange);
window.removeEventListener(ROUTE_CHANGE_EVENT, onChange);
};
}, []);
return route;
}