feat: переход по ссылке между парными заявками — без перезагрузки страницы
Tests / frontend-test (pull_request) Successful in 1m15s
Tests / frontend-test (pull_request) Successful in 1m15s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,8 @@
|
|||||||
| `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — |
|
| `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — |
|
||||||
| `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` |
|
| `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` |
|
||||||
| `it-1c-ticket` | Заявка в отдел 1С | IT | да | да | — |
|
| `it-1c-ticket` | Заявка в отдел 1С | IT | да | да | — |
|
||||||
|
| `hr-dismissal` | Заявление на увольнение | Отдел кадров | да | да | — |
|
||||||
|
| `clearance-form` | Обходной лист | Отдел кадров | да | да | создаётся сам по согласованному `hr-dismissal` |
|
||||||
| `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — |
|
| `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -63,8 +63,12 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и
|
|||||||
|
|
||||||
Ссылки **видны на обеих формах** и кликабельны: поле «Обходной лист» в заявлении и
|
Ссылки **видны на обеих формах** и кликабельны: поле «Обходной лист» в заявлении и
|
||||||
«Заявление на увольнение» в обходном листе (`LinkField`, `shared/ui`) — read-only,
|
«Заявление на увольнение» в обходном листе (`LinkField`, `shared/ui`) — read-only,
|
||||||
открывают парную заявку в ТОЙ ЖЕ вкладке и появляются только когда связь есть. У согласующего,
|
открывают парную заявку в ТОЙ ЖЕ вкладке и появляются только когда связь есть.
|
||||||
только что одобрившего заявление, ссылка появляется сразу, без перезагрузки.
|
Переход идёт без перезагрузки страницы: ссылка ведёт на ту же страницу портала с
|
||||||
|
другими rp-параметрами, поэтому клик перехватывается и уходит в `navigate()`
|
||||||
|
(`navigateToRequestLink` в `shared/routing.ts`) — а Ctrl/⌘+клик по-прежнему открывает
|
||||||
|
заявку в отдельной вкладке, потому что href настоящий. У согласующего, только что
|
||||||
|
одобрившего заявление, ссылка появляется сразу, без перезагрузки.
|
||||||
|
|
||||||
Что важно знать про это поведение:
|
Что важно знать про это поведение:
|
||||||
- **Идемпотентность — по непустой `ClearanceUrl`.** Заявление можно согласовать
|
- **Идемпотентность — по непустой `ClearanceUrl`.** Заявление можно согласовать
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
IRoute,
|
IRoute,
|
||||||
navigate,
|
navigate,
|
||||||
|
navigateToRequestLink,
|
||||||
parseRoute,
|
parseRoute,
|
||||||
persistOwnStatus,
|
persistOwnStatus,
|
||||||
persistOwnTab,
|
persistOwnTab,
|
||||||
persistPage,
|
persistPage,
|
||||||
persistQuery,
|
persistQuery,
|
||||||
|
requestLinkUrl,
|
||||||
routeUrl,
|
routeUrl,
|
||||||
storedOwnStatus,
|
storedOwnStatus,
|
||||||
storedOwnTab,
|
storedOwnTab,
|
||||||
@@ -527,3 +529,34 @@ describe('navigate', () => {
|
|||||||
expect(seen[0]).toContain('rpnav=approvals');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -436,6 +436,45 @@ export function requestLinkUrl(formKey: string, itemId: number, listId?: string)
|
|||||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
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() и кнопке «назад». */
|
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
|
||||||
export function useRoute(): IRoute {
|
export function useRoute(): IRoute {
|
||||||
const [route, setRoute] = React.useState<IRoute>(() => parseRoute());
|
const [route, setRoute] = React.useState<IRoute>(() => parseRoute());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './LinkField.module.scss';
|
import styles from './LinkField.module.scss';
|
||||||
import { FormField, Icon } from '@tnsu/ui-kit';
|
import { FormField, Icon } from '@tnsu/ui-kit';
|
||||||
|
import { navigateToRequestLink } from '../../routing';
|
||||||
|
|
||||||
interface ILinkFieldProps {
|
interface ILinkFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,18 +19,30 @@ interface ILinkFieldProps {
|
|||||||
* заполняется автосозданием обходного листа), и до этого момента их нет смысла
|
* заполняется автосозданием обходного листа), и до этого момента их нет смысла
|
||||||
* показывать.
|
* показывать.
|
||||||
*
|
*
|
||||||
* Открывается в ТОЙ ЖЕ вкладке — обычной навигацией. Ссылка ведёт на ту же страницу
|
* Открывается в ТОЙ ЖЕ вкладке, и если ссылка ведёт на заявку этого же портала —
|
||||||
* портала с другими rp-параметрами (см. requestLinkUrl), то есть страница
|
* вообще без перезагрузки страницы (navigateToRequestLink: та же страница, другие
|
||||||
* перезагрузится и покажет парную заявку; для пользователя это переход «туда и
|
* rp-параметры). href при этом настоящий, а не «#»: ссылку можно скопировать,
|
||||||
* обратно кнопкой назад», а не второе окно, которое потом закрывать.
|
* открыть в новой вкладке через Ctrl/⌘+клик или среднюю кнопку — такие клики
|
||||||
|
* перехват намеренно пропускает.
|
||||||
*/
|
*/
|
||||||
export const LinkField: React.FC<ILinkFieldProps> = ({ label, url, text }) => {
|
export const LinkField: React.FC<ILinkFieldProps> = ({ label, url, text }) => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||||
|
// Клик с модификатором или не левой кнопкой — осознанное «открыть отдельно»,
|
||||||
|
// отдаём браузеру. Иначе пробуем перейти внутри портала.
|
||||||
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigateToRequestLink(url)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField label={label} full>
|
<FormField label={label} full>
|
||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
<a className={styles.link} href={url}>
|
<a className={styles.link} href={url} onClick={handleClick}>
|
||||||
<span>{text || url}</span>
|
<span>{text || url}</span>
|
||||||
<Icon name="chevron-right" size="sm" />
|
<Icon name="chevron-right" size="sm" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user