From b3dee2047cd68d19a2f6f5db037faf0893560de8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 13:44:10 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BF=D0=BE=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D0=BF=D0=B0=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=20=E2=80=94=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigateToRequestLink (shared/routing): если абсолютная ссылка ведёт на ЭТУ ЖЕ страницу портала и на просмотр заявки — открываем её через navigate(), то есть сменой rp-параметров, а не перезагрузкой SPFx-страницы (она грузится секунды, а показать нужно ту же страницу с другими параметрами). Раздел бокового меню берётся текущий: заявка показывается в панели поверх раздела, и переход по ссылке не должен переключать раздел под ней. LinkField перехватывает только «обычный» клик: с Ctrl/⌘/Shift/Alt и не левой кнопкой отдаёт браузеру, href настоящий — ссылку по-прежнему можно скопировать или открыть в отдельной вкладке. Чужой сайт, другая страница и ссылка не на заявку возвращают false и обрабатываются браузером как раньше. Тесты на requestLinkUrl/navigateToRequestLink в routing.test.ts; heft test — 179 успешных, 0 падений. В docs/routing.md добавлены hr-dismissal и clearance-form. Co-Authored-By: Claude Opus 5 --- docs/routing.md | 2 + .../requestPortal/forms/Dismissal/README.md | 8 +++- .../requestPortal/shared/routing.test.ts | 33 ++++++++++++++++ src/webparts/requestPortal/shared/routing.ts | 39 +++++++++++++++++++ .../shared/ui/LinkField/LinkField.tsx | 23 ++++++++--- 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/docs/routing.md b/docs/routing.md index 3e85312..9d1ea37 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -44,6 +44,8 @@ | `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — | | `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` | | `it-1c-ticket` | Заявка в отдел 1С | IT | да | да | — | +| `hr-dismissal` | Заявление на увольнение | Отдел кадров | да | да | — | +| `clearance-form` | Обходной лист | Отдел кадров | да | да | создаётся сам по согласованному `hr-dismissal` | | `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — | --- diff --git a/src/webparts/requestPortal/forms/Dismissal/README.md b/src/webparts/requestPortal/forms/Dismissal/README.md index 1c4273c..a0a5faf 100644 --- a/src/webparts/requestPortal/forms/Dismissal/README.md +++ b/src/webparts/requestPortal/forms/Dismissal/README.md @@ -63,8 +63,12 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и Ссылки **видны на обеих формах** и кликабельны: поле «Обходной лист» в заявлении и «Заявление на увольнение» в обходном листе (`LinkField`, `shared/ui`) — read-only, -открывают парную заявку в ТОЙ ЖЕ вкладке и появляются только когда связь есть. У согласующего, -только что одобрившего заявление, ссылка появляется сразу, без перезагрузки. +открывают парную заявку в ТОЙ ЖЕ вкладке и появляются только когда связь есть. +Переход идёт без перезагрузки страницы: ссылка ведёт на ту же страницу портала с +другими rp-параметрами, поэтому клик перехватывается и уходит в `navigate()` +(`navigateToRequestLink` в `shared/routing.ts`) — а Ctrl/⌘+клик по-прежнему открывает +заявку в отдельной вкладке, потому что href настоящий. У согласующего, только что +одобрившего заявление, ссылка появляется сразу, без перезагрузки. Что важно знать про это поведение: - **Идемпотентность — по непустой `ClearanceUrl`.** Заявление можно согласовать diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 9a94f74..ad5fef8 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -1,11 +1,13 @@ import { IRoute, navigate, + navigateToRequestLink, parseRoute, persistOwnStatus, persistOwnTab, persistPage, persistQuery, + requestLinkUrl, routeUrl, storedOwnStatus, storedOwnTab, @@ -527,3 +529,34 @@ describe('navigate', () => { expect(seen[0]).toContain('rpnav=approvals'); }); }); + +describe('requestLinkUrl / navigateToRequestLink', () => { + it('ссылка абсолютна и не тащит посторонние параметры текущей страницы', () => { + window.history.replaceState({}, '', '/?foo=bar&rpnav=own'); + expect(requestLinkUrl('clearance-form', 7, '/sites/requests/Lists/List10')).toBe( + 'http://localhost/?rpmode=approve&rptype=clearance-form&rpid=7&rplist=%2Fsites%2Frequests%2FLists%2FList10' + ); + }); + + it('ссылка на заявку этого же портала открывается навигацией, без перезагрузки', () => { + window.history.replaceState({}, '', '/?rpnav=own'); + expect(navigateToRequestLink(requestLinkUrl('clearance-form', 7))).toBe(true); + + const route = parseRoute(); + expect(route.mode).toBe('approve'); + expect(route.formKey).toBe('clearance-form'); + expect(route.itemId).toBe(7); + // Раздел под панелью заявки остаётся тем же, что был открыт. + expect(route.nav).toBe('own'); + }); + + it('чужой сайт, другая страница и ссылка не на заявку — клик отдаётся браузеру', () => { + expect(navigateToRequestLink('https://example.com/p?rpmode=approve&rptype=a&rpid=1')).toBe(false); + expect(navigateToRequestLink('/OtherPage.aspx?rpmode=approve&rptype=a&rpid=1')).toBe(false); + expect(navigateToRequestLink('http://localhost/?rpnav=own')).toBe(false); + }); + + it('мусор вместо адреса не роняет обработчик клика', () => { + expect(navigateToRequestLink('не ссылка')).toBe(false); + }); +}); diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 03d79fb..bba1ac1 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -436,6 +436,45 @@ export function requestLinkUrl(formKey: string, itemId: number, listId?: string) 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(() => parseRoute()); diff --git a/src/webparts/requestPortal/shared/ui/LinkField/LinkField.tsx b/src/webparts/requestPortal/shared/ui/LinkField/LinkField.tsx index 558e2ce..c85a44f 100644 --- a/src/webparts/requestPortal/shared/ui/LinkField/LinkField.tsx +++ b/src/webparts/requestPortal/shared/ui/LinkField/LinkField.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import styles from './LinkField.module.scss'; import { FormField, Icon } from '@tnsu/ui-kit'; +import { navigateToRequestLink } from '../../routing'; interface ILinkFieldProps { label: string; @@ -18,18 +19,30 @@ interface ILinkFieldProps { * заполняется автосозданием обходного листа), и до этого момента их нет смысла * показывать. * - * Открывается в ТОЙ ЖЕ вкладке — обычной навигацией. Ссылка ведёт на ту же страницу - * портала с другими rp-параметрами (см. requestLinkUrl), то есть страница - * перезагрузится и покажет парную заявку; для пользователя это переход «туда и - * обратно кнопкой назад», а не второе окно, которое потом закрывать. + * Открывается в ТОЙ ЖЕ вкладке, и если ссылка ведёт на заявку этого же портала — + * вообще без перезагрузки страницы (navigateToRequestLink: та же страница, другие + * rp-параметры). href при этом настоящий, а не «#»: ссылку можно скопировать, + * открыть в новой вкладке через Ctrl/⌘+клик или среднюю кнопку — такие клики + * перехват намеренно пропускает. */ export const LinkField: React.FC = ({ label, url, text }) => { if (!url) return null; + const handleClick = (e: React.MouseEvent): void => { + // Клик с модификатором или не левой кнопкой — осознанное «открыть отдельно», + // отдаём браузеру. Иначе пробуем перейти внутри портала. + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return; + } + if (navigateToRequestLink(url)) { + e.preventDefault(); + } + }; + return (