скелет готов

This commit is contained in:
gorbenko.pavel
2026-08-10 13:48:16 +05:00
parent f6f4872a7c
commit 2ed5d5a7d3
10 changed files with 620 additions and 134 deletions
@@ -13,6 +13,8 @@ describe('parseRoute', () => {
it('rpnav задаёт раздел бокового меню', () => {
expect(parseRoute('?rpnav=approvals').nav).toBe('approvals');
expect(parseRoute('?rpnav=execution').nav).toBe('execution');
expect(parseRoute('?rpnav=rework').nav).toBe('rework');
expect(parseRoute('?rpnav=own').nav).toBe('own');
expect(parseRoute('?rpnav=search').nav).toBe('search');
});
@@ -73,6 +75,64 @@ describe('parseRoute', () => {
});
});
describe('подвкладки и фильтр «Моих заявок»', () => {
it('без rptab — подвкладка по умолчанию', () => {
expect(parseRoute('?rpnav=own').ownTab).toBe('created');
});
it('rptab задаёт подвкладку', () => {
expect(parseRoute('?rpnav=own&rptab=approved').ownTab).toBe('approved');
expect(parseRoute('?rpnav=own&rptab=rejected').ownTab).toBe('rejected');
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
});
it('незнакомый rptab откатывается на подвкладку по умолчанию', () => {
expect(parseRoute('?rpnav=own&rptab=bogus').ownTab).toBe('created');
});
it('вне «Моих заявок» подвкладка не читается — чужой rptab туда не протекает', () => {
expect(parseRoute('?rpnav=approvals&rptab=approved').ownTab).toBeUndefined();
expect(parseRoute('?rpnav=search&rptab=approved').ownTab).toBeUndefined();
});
it('фильтр по статусу действует только на вкладке «Инициированные»', () => {
expect(parseRoute('?rpnav=own&rpstatus=Черновик').ownStatus).toBe('Черновик');
// У вкладок с решениями отбор задаёт сама вкладка — фильтр там бессмыслен.
expect(parseRoute('?rpnav=own&rptab=approved&rpstatus=Черновик').ownStatus).toBeUndefined();
expect(parseRoute('?rpnav=approvals&rpstatus=Черновик').ownStatus).toBeUndefined();
});
it('значения по умолчанию не засоряют адрес', () => {
expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'created' })).not.toContain('rptab');
expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rptab');
});
it('непустая подвкладка и фильтр попадают в адрес', () => {
expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'rejected' })).toContain('rptab=rejected');
expect(routeUrl({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: 'Черновик' })).toContain('rpstatus=');
});
it('фильтр не пишется в адрес вместе с подвкладкой решений', () => {
const url = routeUrl({ mode: 'submit', nav: 'own', ownTab: 'approved', ownStatus: 'Черновик' });
expect(url).toContain('rptab=approved');
expect(url).not.toContain('rpstatus');
});
it('уход из «Моих заявок» убирает подвкладку и фильтр из адреса', () => {
navigate({ mode: 'submit', nav: 'own', ownTab: 'rejected' });
navigate({ mode: 'submit', nav: 'approvals' });
expect(window.location.search).not.toContain('rptab');
expect(window.location.search).not.toContain('rpstatus');
});
it('round-trip для подвкладки решений', () => {
navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' });
const route = parseRoute(window.location.search);
expect(route.nav).toBe('own');
expect(route.ownTab).toBe('executed');
});
});
describe('routeUrl', () => {
it('раздел по умолчанию не засоряет адрес параметром', () => {
expect(routeUrl({ mode: 'submit', nav: 'new' })).toBe('/');
@@ -103,9 +163,24 @@ describe('navigate', () => {
nav: 'own',
formKey: 'hire',
itemId: 42,
// Подвкладка «Моих заявок» всегда определена внутри раздела — в адресе её
// нет только потому, что это значение по умолчанию.
ownTab: 'created',
ownStatus: undefined,
});
});
it('подвкладка и фильтр «Моих заявок» переживают открытие и закрытие заявки', () => {
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: 'Черновик' });
navigate({ mode: 'approve', nav: 'own', formKey: 'hire', itemId: 42, ownTab: 'created', ownStatus: 'Черновик' });
const opened = parseRoute(window.location.search);
expect(opened.ownStatus).toBe('Черновик');
navigate({ mode: 'submit', nav: 'own', ownTab: opened.ownTab, ownStatus: opened.ownStatus });
expect(parseRoute(window.location.search).ownStatus).toBe('Черновик');
});
it('round-trip для создания производной заявки', () => {
navigate({ mode: 'create', nav: 'new', formKey: 'workplace', sourceId: 15 });
expect(parseRoute(window.location.search)).toEqual({
+58 -6
View File
@@ -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,32 @@ export interface IRoute {
itemId?: number;
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
sourceId?: number;
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
ownTab?: OwnTab;
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
ownStatus?: string;
}
const ROUTE_CHANGE_EVENT = 'rp-route-change';
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') return raw;
return 'created';
}
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
export function parseRoute(search: string = window.location.search): IRoute {
const params = new URLSearchParams(search);
@@ -57,19 +91,28 @@ 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') ?? undefined : undefined;
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
// Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром:
// закрыв панель, пользователь должен вернуться ровно в тот же список.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', nav, formKey, itemId };
return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus };
}
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId };
return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus };
}
// Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', nav: 'new', formKey, sourceId };
}
return { mode: 'submit', nav };
return { mode: 'submit', nav, ownTab, ownStatus };
}
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
@@ -80,10 +123,19 @@ function buildQuery(route: IRoute): string {
params.delete('rptype');
params.delete('rpid');
params.delete('rpsource');
params.delete('rptab');
params.delete('rpstatus');
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.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
params.set('rpmode', route.mode);
params.set('rptype', route.formKey);