Files
request-portal/src/webparts/requestPortal/shared/routing.ts
T
Тимур Городецкий 9400f8546e feat: параметр source во внешней ссылке ТМЦ сохраняется в заявке
Внешняя система (напр. КПС) передаёт source — пишем его в новую колонку
SourceSystem рядом с SourcePositionId, чтобы по заявке было видно, откуда
она пришла и куда отвечать. Запоминание источника вынесено в отдельный
эффект: positionId/source/склад сохраняются и по ссылке без nomenclatureId.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:39:48 +05:00

611 lines
35 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';
// Маршрутизация портала по параметрам URL.
//
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск
// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал).
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
// строке таблицы:
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в
// списке данных. Заявка показывается в выезжающей панели ПОВЕРХ текущего
// раздела, поэтому rpnav сохраняется вместе с rpmode.
//
// Ссылка на ПУСТУЮ новую форму конкретного типа (без предзаполнения, минуя
// плитки) — см. newRequestLinkUrl():
// …/СтраницаПортала.aspx?rpmode=create&rptype=<форма>
// Отличается от «создать на основе» (тот же rpmode=create, но с rpsource) —
// без rpsource форма просто открывается пустой.
//
// Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
// поэтому используем собственные, неконфликтные имена параметров.
//
// Навигация внутри портала идёт через navigate() — это history.pushState без
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
// 'edit' — открыть СВОЮ незавершённую заявку (черновик / возвращённую /
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
/**
* Раздел бокового меню.
*
* Три раздела — очереди действий текущего пользователя, и они НЕ пересекаются
* (см. ApprovalQueueKind в myRequests.service.ts):
* 'approvals' — ждут его согласования (статус строки «На согласовании»),
* 'execution' — ждут его исполнения (статус строки «На исполнении»),
* 'rework' — ЕГО заявки, которые вернули ему на доработку (статус ЗАЯВКИ
* «Возвращена на доработку»); в отличие от первых двух смотрит
* на список данных по автору, а не на строки маршрута.
*/
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports' | 'allRequests';
/**
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
* (единственная, где есть фильтр по статусу); остальные — история его решений
* как участника чужих маршрутов.
*/
export type OwnTab = 'created' | 'approved' | 'rejected' | 'executed';
export interface IRoute {
mode: PortalMode;
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
nav: NavSection;
/** Ключ формы (тип заявки) — для 'approve', 'edit' и 'create'. */
formKey?: string;
/** Id заявки в списке данных — для 'approve' и 'edit'. */
itemId?: number;
/**
* Серверный относительный url КОНКРЕТНОЙ партиции, где лежит заявка — для
* 'approve' и 'edit'. Списки заявок нумерованные (см. shared/approval/
* listPartitions.ts): без этого поля заявка всегда открывалась бы так, будто
* лежит в ТЕКУЩЕЙ партиции, и архивная заявка читалась/сохранялась бы не в
* тот список. Не задан — значит партиция неизвестна (старая ссылка без
* параметра), и форма/просмотр откатываются на текущую партицию.
*/
listId?: string;
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
sourceId?: number;
/**
* Id строки-источника во внешней системе (Dataverse «Строка заявки», GUID
* первичного ключа). Приходит по ссылке из внешней системы вместе с rptype —
* форма «СЗ на перемещение ТМЦ» по нему после отправки на согласование
* записывает обратно ссылку на заявку (см. shared/dataverse/demandLine.service).
*/
positionId?: string;
/**
* Id номенклатуры для GET /Dictionary/Nomenclature/{id} — форма предзаполняет
* табличную часть остатками по складам. Приходит по той же внешней ссылке.
*/
nomenclatureId?: string;
/**
* Склад-фильтр для GET /Dictionary/Nomenclature — вернуть остатки только по
* этому складу. Приходит по той же внешней ссылке; пусто — все склады.
*/
storeName?: string;
/**
* Система, из которой открыли форму по внешней ссылке (параметр `source`,
* напр. 'kps'). Сохраняется в заявке, чтобы знать, откуда она пришла и куда
* отвечать. Не путать с sourceId (rpsource) — тот про предзаполнение из
* другой заявки портала.
*/
sourceSystem?: string;
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
ownTab?: OwnTab;
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
ownStatus?: string;
/**
* Выбранный тип заявки в разделе «Все заявки» (nav='allRequests') — числовой
* RequestTypeId approval-route. Не задан — раздел сам выберет тип (первый
* доступный текущему пользователю, см. AllRequestsFeature).
*/
allTypeId?: number;
/** Номер страницы таблицы (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';
// Раздел бокового меню — тем же приёмом: запоминается только по явному клику
// (см. persistNav в goto() RequestPortal.tsx), чтобы заход на портал без
// rpnav в адресе открывал тот раздел, на котором остановились в прошлый раз,
// а не всегда «Новую заявку».
const NAV_STORAGE_KEY = 'rp-nav';
// Страница запоминается ОТДЕЛЬНО ДЛЯ КАЖДОЙ таблицы (см. 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 readStoredNav(): NavSection {
try {
const raw = window.localStorage.getItem(NAV_STORAGE_KEY);
return raw === 'approvals' ||
raw === 'execution' ||
raw === 'rework' ||
raw === 'own' ||
raw === 'search' ||
raw === 'reports' ||
raw === 'allRequests' ||
raw === 'new'
? raw
: 'new';
} catch {
return 'new';
}
}
// Выбранный тип заявки в разделе «Все заявки» — тем же приёмом, что и
// подвкладка/фильтр «Моих заявок» (OWN_TAB_STORAGE_KEY выше): переживает
// перезагрузку страницы и новый визит, но не более того. Без этого переход в
// другой раздел и обратно (клик по пункту меню, без rpalltype в адресе)
// каждый раз сбрасывал бы выбор на первый доступный тип.
const ALL_TYPE_STORAGE_KEY = 'rp-all-type';
function readStoredAllTypeId(): number | undefined {
try {
const raw = window.localStorage.getItem(ALL_TYPE_STORAGE_KEY);
const id = Number(raw);
return raw && Number.isInteger(id) && id > 0 ? id : undefined;
} catch {
return undefined;
}
}
/** Запомненный тип «Все заявки» — как и storedOwnTab, нужен там, где маршрут СТРОИТСЯ. */
export function storedAllTypeId(): number | undefined {
return readStoredAllTypeId();
}
/** Запомнить тип «Все заявки» — вызывать только при явном выборе пользователя. */
export function persistAllTypeId(typeId: number): void {
try {
window.localStorage.setItem(ALL_TYPE_STORAGE_KEY, String(typeId));
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
}
/** Запомнить раздел бокового меню — вызывать только при явном выборе пользователя. */
export function persistNav(nav: NavSection): void {
try {
window.localStorage.setItem(NAV_STORAGE_KEY, nav);
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
}
function parseNav(raw: string | undefined): NavSection {
if (
raw === 'approvals' ||
raw === 'execution' ||
raw === 'rework' ||
raw === 'own' ||
raw === 'search' ||
raw === 'reports' ||
raw === 'allRequests' ||
raw === 'new'
) {
return raw;
}
// В адресе раздела нет (rpnav отсутствует, в т.ч. при заходе на портал по
// голой ссылке) — открываем запомненный раздел, а не всегда «Новую заявку».
return readStoredNav();
}
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);
const mode = params.get('rpmode');
const nav = parseNav(params.get('rpnav') ?? undefined);
const formKey = params.get('rptype') ?? undefined;
const idRaw = params.get('rpid');
const itemId = idRaw ? Number(idRaw) : undefined;
const listId = params.get('rplist') ?? undefined;
const sourceRaw = params.get('rpsource');
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
// Параметры внешней ссылки (без rp-префикса — так их формирует внешняя
// система, менять её мы не можем). Актуальны только для сценария «создать» по
// rptype: заполнить форму ТМЦ из справочника и запомнить строку-источник.
const positionId = params.get('positionId') ?? undefined;
const nomenclatureId = params.get('nomenclatureId') ?? undefined;
const storeName = params.get('storeName') ?? undefined;
const sourceSystem = params.get('source') ?? undefined;
// Подвкладка и фильтр — состояние раздела «Мои заявки», поэтому вне его не
// читаются вовсе (иначе чужой rptab из ссылки «протёк» бы в другой раздел).
// Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с
// решениями отбор задаёт сама вкладка.
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
// Тип заявки в разделе «Все заявки» — из адреса, иначе запомненный (та же
// логика, что у ownTab/ownStatus выше). Если ни того, ни другого нет (первый
// визит) — undefined, и раздел сам выберет первый доступный тип (см.
// AllRequestsFeature), а как только он выберет — маршрут и localStorage
// подхватят выбор (см. gotoAllRequestsType в RequestPortal.tsx).
const allTypeIdParam = nav === 'allRequests' ? params.get('rpalltype') : null;
const allTypeIdFromUrl = allTypeIdParam ? Number(allTypeIdParam) : NaN;
const allTypeId =
nav === 'allRequests'
? (Number.isInteger(allTypeIdFromUrl) && allTypeIdFromUrl > 0 ? allTypeIdFromUrl : readStoredAllTypeId())
: 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, listId, ownTab, ownStatus, allTypeId, page, query };
}
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query };
}
// Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', nav: 'new', formKey, sourceId };
}
// Ссылка на пустую новую форму конкретного типа — без предзаполнения
// (клик по плитке внутри портала, либо явная ссылка ?rptype=<форма> без
// rpsource — см. newRequestLinkUrl). Как и ветка с rpsource выше, НЕ требует
// rpmode=create: buildQuery() его для этого случая не пишет (значения по
// умолчанию в адрес не пишем), поэтому единственный сигнал — сам rptype.
// Но approve/edit явно исключаем: битая/неполная ссылка на них (напр.
// rpmode=approve без валидного rpid) должна откатываться на обычную подачу,
// а не тихо превращаться в открытие пустой формы другого режима.
if (formKey && mode !== 'approve' && mode !== 'edit') {
return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName, sourceSystem };
}
return { mode: 'submit', nav, ownTab, ownStatus, allTypeId, page, query };
}
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
function buildQuery(route: IRoute): string {
const params = new URLSearchParams(window.location.search);
params.delete('rpmode');
params.delete('rpnav');
params.delete('rptype');
params.delete('rpid');
params.delete('rplist');
params.delete('rpsource');
params.delete('rptab');
params.delete('rpstatus');
params.delete('rppage');
params.delete('rpq');
params.delete('rpalltype');
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.nav === 'allRequests' && route.allTypeId) {
params.set('rpalltype', String(route.allTypeId));
}
// Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку).
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);
params.set('rpid', String(route.itemId));
// Партиция неизвестна (архивная ссылка старого формата, до этого поля) —
// ссылка всё равно валидна, форма/просмотр просто откатятся на текущую.
if (route.listId) params.set('rplist', route.listId);
} else if (route.mode === 'create' && route.formKey) {
params.set('rptype', route.formKey);
if (route.sourceId) params.set('rpsource', String(route.sourceId));
}
const query = params.toString();
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
}
/**
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
* query-параметры адресной строки через history.pushState и оповещает
* useRoute() своим событием (popstate на pushState сам не срабатывает).
*
* replace=true заменяет текущую запись истории вместо добавления новой —
* для набора текста в поиске: иначе каждая буква становилась бы отдельным
* шагом «назад», и чтобы уйти со страницы, пришлось бы стереть весь запрос
* по одному нажатию.
*/
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));
}
/** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */
export function routeUrl(route: IRoute): string {
return buildQuery(route);
}
/**
* АБСОЛЮТНАЯ ссылка на просмотр заявки — для того, что переживёт текущую сессию:
* колонка-гиперссылка в списке SharePoint, письмо, сообщение в чат.
*
* Отличается от routeUrl() двумя вещами, и обе важны именно для «хранимой» ссылки:
* она с origin (в списке относительный путь бесполезен — его открывают из
* представления SharePoint, а не со страницы портала) и без посторонних
* query-параметров текущей страницы (routeUrl их намеренно сохраняет, потому что
* это навигация ВНУТРИ открытой страницы).
*
* Страница портала берётся из адреса текущей — портал живёт на одной странице,
* другого способа узнать её путь у клиентского кода нет.
*/
export function requestLinkUrl(formKey: string, itemId: number, listId?: string): string {
const params = new URLSearchParams();
params.set('rpmode', 'approve');
params.set('rptype', formKey);
params.set('rpid', String(itemId));
if (listId) {
params.set('rplist', listId);
}
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
}
/**
* АБСОЛЮТНАЯ ссылка на ПУСТУЮ новую форму конкретного типа — без предзаполнения,
* в отличие от requestLinkUrl (просмотр существующей). Для той же цели: письмо,
* сообщение в чат, кнопка «поделиться ссылкой на форму».
*/
export function newRequestLinkUrl(formKey: string): string {
const params = new URLSearchParams();
params.set('rpmode', 'create');
params.set('rptype', formKey);
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
}
/**
* Открыть заявку по её АБСОЛЮТНОЙ ссылке (см. requestLinkUrl) БЕЗ перезагрузки
* страницы — если ссылка ведёт на эту же страницу портала.
*
* Зачем: страница SharePoint со SPFx грузится секунды, а ссылка между парными
* заявками (заявление на увольнение ↔ обходной лист) — это по сути та же страница
* с другими rp-параметрами, то есть ровно то, что умеет navigate(). Полная
* перезагрузка здесь ничего не даёт.
*
* Возвращает false, если ссылка ведёт куда-то ещё (другой сайт, другая страница,
* не на просмотр заявки) — тогда вызывающий НЕ должен подавлять клик и обязан
* дать браузеру перейти по ней обычным образом.
*/
export function navigateToRequestLink(url: string): boolean {
let target: URL;
try {
target = new URL(url, window.location.href);
} catch {
return false;
}
// Путь в SharePoint регистронезависим, а ссылка могла быть записана в список
// когда-то с другим регистром — сравниваем в нижнем.
if (
target.origin !== window.location.origin ||
target.pathname.toLowerCase() !== window.location.pathname.toLowerCase()
) {
return false;
}
const route = parseRoute(target.search);
if (route.mode !== 'approve' || !route.formKey || !route.itemId) {
return false;
}
// Раздел бокового меню берём ТЕКУЩИЙ, а не из ссылки: заявка показывается в
// панели поверх раздела, и уход по ссылке не должен переключать раздел под ней.
navigate({ ...route, nav: parseRoute().nav });
return true;
}
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
export function useRoute(): IRoute {
const [route, setRoute] = React.useState<IRoute>(() => 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;
}