куча доработок

This commit is contained in:
gorbenko.pavel
2026-08-10 16:07:58 +05:00
parent 4cfb9a545d
commit 71335b325e
10 changed files with 277 additions and 190 deletions
@@ -2,7 +2,7 @@ import * as React from 'react';
import styles from './RequestPortal.module.scss';
import type { IRequestPortalProps } from './IRequestPortalProps';
import { IconSprite } from '../shared/ui/Icon/IconSprite';
import { navigate, useRoute, NavSection, OwnTab } from '../shared/routing';
import { navigate, persistOwnSelection, useRoute, NavSection, OwnTab } from '../shared/routing';
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
import { SideNav } from './SideNav/SideNav';
@@ -110,28 +110,27 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
// текущие строки (см. RequestsTableView/SearchView).
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
// Возврат в «Мои заявки» открывает ту же подвкладку и с тем же фильтром,
// что были в прошлый раз, — иначе уход в другой раздел и обратно сбрасывал
// бы выбор пользователя.
navigate(
nav === 'own'
? { mode: 'submit', nav, ownTab: route.ownTab, ownStatus: route.ownStatus }
: { mode: 'submit', nav }
);
// Подвкладку/фильтр «Моих заявок» здесь передавать не нужно: при переходе
// ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет
// его только когда nav==='own'), а при возврате на уже открытую «own» их
// подхватит storage-фолбэк в parseRoute — то же самое значение, которое
// пользователь выбирал последним (см. persistOwnSelection/gotoOwnTab).
navigate({ mode: 'submit', nav });
};
const gotoOwnTab = (ownTab: OwnTab): void =>
const gotoOwnTab = (ownTab: OwnTab): void => {
// Фильтр по статусу относится только к «Инициированным» — на других
// подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим.
navigate({
mode: 'submit',
nav: 'own',
ownTab,
ownStatus: ownTab === 'created' ? route.ownStatus : undefined,
});
const ownStatus = ownTab === 'created' ? route.ownStatus : undefined;
persistOwnSelection(ownTab, ownStatus);
navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus });
};
const setOwnStatus = (ownStatus: string): void =>
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: ownStatus || undefined });
const setOwnStatus = (ownStatus: string): void => {
const normalized = ownStatus || undefined;
persistOwnSelection('created', normalized);
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized });
};
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
// возвращённая, отклонённая) открывается на редактирование, всё остальное —
// на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту —
@@ -187,41 +187,7 @@
width: 190px;
}
.status {
display: inline-block;
padding: 3px 10px;
border-radius: $r-md;
font-size: $fs-caption;
font-weight: 500;
line-height: 1.3;
}
.statusDraft {
background: $gray-100;
color: $gray-700;
}
.statusOnApproval {
background: $gold-100;
color: $gold-600;
}
.statusApproved {
background: $green-50;
color: $green-600;
}
.statusRejected {
background: $red-50;
color: $red-500;
}
.statusReturned {
background: $blue-50;
color: $blue-600;
}
.statusDefault {
background: $gray-100;
color: $gray-500;
}
// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
// перепутаны), поэтому копию убрали, а не подправили.
@@ -7,11 +7,12 @@ import {
getApprovalQueueIndex,
getLinkedTypes,
getOwnRequests,
getOwnRequestsByStatus,
hydratePendingAuthors,
searchOwnRequests,
} from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType } from '../../shared/approval';
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
@@ -103,23 +104,6 @@ function formatDate(iso: string): string {
return `${dd}.${mm}.${d.getFullYear()}`;
}
function statusClass(status: string): string {
switch (status) {
case REQUEST_STATUS.approved:
return styles.statusApproved;
case REQUEST_STATUS.rejected:
return styles.statusRejected;
case REQUEST_STATUS.returned:
return styles.statusReturned;
case REQUEST_STATUS.onApproval:
return styles.statusOnApproval;
case REQUEST_STATUS.draft:
return styles.statusDraft;
default:
return styles.statusDefault;
}
}
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space).
@@ -173,7 +157,7 @@ const Table: React.FC<{
)}
{columns.status && (
<td className={styles.colStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
</td>
)}
</tr>
@@ -205,9 +189,12 @@ const Table: React.FC<{
// после фильтрации — нарезка на страницы и дочитывание автора
// (hydratePendingAuthors) — тоже только для видимой страницы.
//
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') уходит в тот же
// KQL отдельным условием (см. MP_TICKET_STATUS) — то есть работает вместе с
// текстовым поиском и с серверной постраничностью, без вычитывания архива.
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL:
// managed property для произвольной Choice-колонки ненадёжна (см.
// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
// REST; постраничность там же, в памяти.
//
// Все варианты перечитываются по событию rp-requests-changed
// (shared/requestEvents) — после создания заявки и после решения согласующего.
@@ -263,13 +250,17 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const q = debouncedQuery.trim();
const fetchPage = async (): Promise<IPage> => {
// Свои заявки: «Доработка» — тот же список, что «Инициированные», просто
// со статусом, зашитым в раздел, а не выбранным в фильтре.
if (view === 'rework' || view === 'own:created') {
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
// зашитым в раздел, а не выбранным в фильтре.
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
}
if (view === 'own:created') {
return q.length > 0
? searchOwnRequests(context, types, debouncedQuery, page, status)
: getOwnRequests(context, types, page, status);
? searchOwnRequests(context, types, debouncedQuery, page)
: getOwnRequests(context, types, page);
}
const queue = QUEUE_BY_VIEW[view];
@@ -163,41 +163,7 @@
white-space: nowrap;
}
.status {
display: inline-block;
padding: 3px 10px;
border-radius: $r-md;
font-size: $fs-caption;
font-weight: 500;
line-height: 1.3;
}
.statusDraft {
background: $gray-100;
color: $gray-700;
}
.statusOnApproval {
background: $gold-100;
color: $gold-600;
}
.statusApproved {
background: $green-50;
color: $green-600;
}
.statusRejected {
background: $red-50;
color: $red-500;
}
.statusReturned {
background: $blue-50;
color: $blue-600;
}
.statusDefault {
background: $gray-100;
color: $gray-500;
}
// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
// перепутаны), поэтому копию убрали, а не подправили.
@@ -2,7 +2,7 @@ import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getLinkedTypes, searchMyRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
@@ -32,23 +32,6 @@ function formatDate(iso: string): string {
return `${dd}.${mm}.${d.getFullYear()}`;
}
function statusClass(status: string): string {
switch (status) {
case REQUEST_STATUS.approved:
return styles.statusApproved;
case REQUEST_STATUS.rejected:
return styles.statusRejected;
case REQUEST_STATUS.returned:
return styles.statusReturned;
case REQUEST_STATUS.onApproval:
return styles.statusOnApproval;
case REQUEST_STATUS.draft:
return styles.statusDraft;
default:
return styles.statusDefault;
}
}
// В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор,
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
@@ -91,7 +74,7 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td>
<td className={styles.colStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
</td>
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
</tr>
@@ -41,20 +41,18 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER';
// Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») —
// Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором
// 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком, у него своя
// managed property (см. MP_TICKET_STATUS ниже).
// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком.
//
// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь
// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо
// знать точно, либо угадывать по конвенции — угадали неверно (или колонка
// после переименования Status → RequestStatus ещё не переиндексирована), и
// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки
// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST
// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному
// AuthorOWSUSER, статус ни при чём.
const MP_STATUS = 'ApprovalStatusOWSCHCS';
// То же самое для Choice-колонки «Статус» списка ДАННЫХ (внутреннее имя
// RequestStatus, см. forms/*/provisioning.json) — по ней отбираются
// «Доработка» и фильтр по статусу в «Моих заявках». Кириллица в значении
// проблемы не создаёт: KQL — не $filter (там она действительно подводит, см.
// indexApprovalsForType), и по MP_STATUS выше точно так же ищутся русские
// статусы строк маршрута.
// Если отбор по статусу вдруг начнёт стабильно возвращать 0 — проверять надо в
// первую очередь ЭТО имя в схеме поиска (см. предупреждение в searchOwnRequests).
const MP_TICKET_STATUS = 'RequestStatusOWSCHCS';
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
}
@@ -210,7 +208,7 @@ export async function getNavCounts(context: WebPartContext): Promise<INavCounts>
const [turn, rework] = await Promise.all([
buildPendingIndex(context, 'myTurn', MY_TURN_STATUSES),
getOwnRequests(context, types, 1, REQUEST_STATUS.returned),
getOwnRequestsByStatus(context, types, REQUEST_STATUS.returned, 1),
]);
return {
@@ -593,8 +591,7 @@ async function loadByIds(
// Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType).
// Фильтруем только по AuthorId: статус в $filter не используем, потому что
// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор
// по статусу делает вызывающая сторона. В самом Search кириллица проблемой не
// является, там статус уходит прямо в KQL (см. MP_TICKET_STATUS).
// по статусу делает вызывающая сторона.
async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
@@ -636,6 +633,39 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
);
}
// Заявки автора, изменённые за последние RECENT_MS, — довесок к Search для
// getOwnRequestsByStatus. В отличие от getFreshOwnRowsForType (которая ловит
// НОВЫЕ заявки по дате СОЗДАНИЯ), здесь важна дата ИЗМЕНЕНИЯ: заявку могли
// вернуть на доработку неделю спустя после создания — по Created такую не
// найти, а Modified у неё обновится ровно в момент решения согласующего
// (см. approverDecision.service.ts/decide). Filter по Modified — обычная дата,
// не Choice-колонка, кириллицы в значении нет, поэтому $filter здесь надёжен
// (в отличие от статуса, см. getOwnRequestsByStatus).
async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
try {
const sp = spForSite(type.siteUrl as string, context);
const me = await sp.web.currentUser.select('Id')();
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id} and Modified ge '${cutoffIso}'`)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
return rows.map((r) =>
toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')
);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать недавно изменённые заявки:`,
(e as Error).message
);
return [];
}
}
// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией
// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по
// «последним N минутам» добирает то, что Search мог не успеть проиндексировать
@@ -644,20 +674,18 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
export async function getOwnRequests(
context: WebPartContext,
types: IRequestType[],
page: number,
status: string = ''
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const searchPage = await searchOwnRequests(context, types, '', page, status);
const searchPage = await searchOwnRequests(context, types, '', page);
if (page !== 1) return searchPage;
// Довесок по свежести отбираем по статусу здесь: он приходит REST-ом, где
// сравнивать статус в $filter нельзя (кириллица), — зато набор маленький.
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))))
.reduce((all, r) => all.concat(r), [] as IMyRequest[])
.filter((r) => !status || r.status === status);
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce(
(all, r) => all.concat(r),
[] as IMyRequest[]
);
const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
@@ -677,8 +705,7 @@ export async function searchOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number,
status: string = ''
page: number
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
@@ -689,9 +716,6 @@ export async function searchOwnRequests(
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
];
// Отбор по статусу заявки — на СЕРВЕРЕ, вместе с постраничностью: иначе
// пришлось бы вычитывать весь архив автора и резать его в памяти.
if (status) clauses.push(`${MP_TICKET_STATUS}:"${status}"`);
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
@@ -708,18 +732,6 @@ export async function searchOwnRequests(
const totalRows = results.TotalRows || 0;
// Отбор по статусу опирается на managed property, имя которой мы выводим по
// конвенции, а не читаем из схемы (см. MP_TICKET_STATUS). Если она названа
// иначе или не заведена, запрос отработает без ошибки и просто вернёт 0 —
// такое молчание слишком дорого отлаживать вслепую, поэтому подсказываем.
if (status && totalRows === 0) {
console.warn(
`[Мои заявки] По статусу «${status}» ничего не найдено. Если так на ЛЮБОМ статусе — ` +
`проверьте managed property «${MP_TICKET_STATUS}» в схеме поиска SharePoint.`
);
}
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
@@ -728,6 +740,51 @@ export async function searchOwnRequests(
return { rows, totalRows };
}
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
// и фильтр по статусу на вкладке «Инициированные».
//
// Статус НЕ уходит в KQL: единственная надёжная managed property здесь —
// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки
// имя managed property надо либо знать из схемы поиска, либо угадывать по
// конвенции — угадали неверно (или она ещё не переиндексирована после
// переименования колонки Status → RequestStatus), из-за чего фильтр по
// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору
// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до
// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по
// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds).
// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по
// надёжному полю, остальное — на клиенте.
export async function getOwnRequestsByStatus(
context: WebPartContext,
types: IRequestType[],
status: string,
page: number,
query: string = ''
): Promise<IOwnRequestsPage> {
const all = await searchOwnAll(context, types, query);
const searchFiltered = all.filter((r) => r.status === status);
// Довесок по недавним изменениям — только на первой странице и только без
// текстового поиска (см. getOwnRequests: тот же принцип — довесок относится
// к «показать всё, свежее по дате», а не к результату полнотекстового поиска).
let merged = searchFiltered;
if (page === 1 && !query.trim()) {
const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))))
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
.filter((r) => r.status === status);
const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`));
merged = onlyFresh.concat(searchFiltered);
}
const sorted = merged.sort((a, b) => b.created.localeCompare(a.created));
return {
rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
totalRows: sorted.length,
};
}
// «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
@@ -791,7 +848,7 @@ export interface ISearchPage {
// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до
// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть.
async function searchOwnAll(
export async function searchOwnAll(
context: WebPartContext,
types: IRequestType[],
query: string
@@ -12,6 +12,16 @@
white-space: nowrap;
}
// Компактный вариант — для ячейки таблицы (RequestsTableView/SearchView),
// повторяет размеры, что были у их собственной .status до объединения.
.sm {
padding: 3px 10px;
border-radius: $r-md;
font-size: $fs-caption;
font-weight: 600;
letter-spacing: normal;
}
.draft {
background: $gray-100;
color: $gray-700;
@@ -16,7 +16,25 @@ const VARIANT: Record<string, string> = {
[REQUEST_STATUS.closed]: styles.closed,
};
/** Цветная плашка статуса заявки (шапка формы / просмотра). */
export const StatusBadge: React.FC<{ status: string }> = ({ status }) => (
<span className={`${styles.badge} ${VARIANT[status] || styles.draft}`}>{status}</span>
export interface IStatusBadgeProps {
status: string;
/**
* 'md' (по умолчанию) — шапка формы/просмотра заявки; 'sm' — компактный
* вариант для ячейки таблицы (RequestsTableView/SearchView). Цвет статуса
* не зависит от размера — единственный источник цвета: VARIANT выше.
*/
size?: 'sm' | 'md';
}
/**
* Цветная плашка статуса заявки — ЕДИНСТВЕННОЕ место, где статус превращается
* в цвет. У таблиц раньше была своя копия этой раскраски (statusClass в
* RequestsTableView.tsx/SearchView.tsx) — она разошлась с этой (на согласовании
* и возвращена на доработку оказались перепутаны местами), поэтому обе копии
* убраны в пользу общего компонента.
*/
export const StatusBadge: React.FC<IStatusBadgeProps> = ({ status, size = 'md' }) => (
<span className={`${styles.badge} ${size === 'sm' ? styles.sm : ''} ${VARIANT[status] || styles.draft}`}>
{status}
</span>
);
@@ -1,9 +1,11 @@
import { IRoute, navigate, parseRoute, routeUrl } from './routing';
import { IRoute, navigate, parseRoute, persistOwnSelection, routeUrl } from './routing';
// buildQuery/navigate читают и пишут window.location через history API —
// сбрасываем адрес перед каждым тестом, чтобы они не зависели от порядка запуска.
// buildQuery/navigate читают и пишут window.location через history API, а
// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnSelection) —
// сбрасываем оба перед каждым тестом, чтобы они не зависели от порядка запуска.
beforeEach(() => {
window.history.replaceState({}, '', '/');
window.localStorage.clear();
});
describe('parseRoute', () => {
@@ -124,6 +126,53 @@ describe('подвкладки и фильтр «Моих заявок»', () =>
expect(window.location.search).not.toContain('rptab');
expect(window.location.search).not.toContain('rpstatus');
});
});
describe('persistOwnSelection — запоминание подвкладки/фильтра между визитами', () => {
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
persistOwnSelection('rejected', undefined);
expect(parseRoute('?rpnav=own').ownTab).toBe('rejected');
});
it('без явного rpstatus подставляется запомненный фильтр (только на «Инициированных»)', () => {
persistOwnSelection('created', 'Черновик');
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
// На вкладках с решениями запомненный фильтр ни при чём — там его нет по смыслу.
persistOwnSelection('approved', 'Черновик');
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
});
it('явный параметр в адресе имеет приоритет над запомненным значением', () => {
persistOwnSelection('rejected', undefined);
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
});
it('очистка фильтра persistOwnSelection убирает его и из последующих parseRoute', () => {
persistOwnSelection('created', 'Черновик');
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
persistOwnSelection('created', undefined);
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
});
it('запомненное значение не протекает за пределы раздела «Мои заявки»', () => {
persistOwnSelection('rejected', undefined);
expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined();
});
it('localStorage недоступен (например, приватный режим) — не роняет parseRoute', () => {
const original = window.localStorage.getItem;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- имитация приватного режима, где чтение localStorage бросает
(window.localStorage as any).getItem = (): never => {
throw new Error('SecurityError');
};
try {
expect(() => parseRoute('?rpnav=own')).not.toThrow();
expect(parseRoute('?rpnav=own').ownTab).toBe('created');
} finally {
window.localStorage.getItem = original;
}
});
it('round-trip для подвкладки решений', () => {
navigate({ mode: 'submit', nav: 'own', ownTab: 'executed' });
+51 -3
View File
@@ -62,6 +62,54 @@ export interface IRoute {
const ROUTE_CHANGE_EVENT = 'rp-route-change';
// Подвкладка и фильтр «Моих заявок» переживают перезагрузку страницы и новый
// визит (localStorage), но НЕ более того — как и текст поиска в
// usePersistedQuery.ts, это просто «продолжить с того же места», а не
// синхронизация между вкладками/устройствами.
//
// Читаются здесь же, в parseRoute, а не отдельным хуком в компоненте: URL уже
// является единственным источником правды для ownTab/ownStatus (см.
// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут,
// где URL молчит, естественно спросить «а что было в прошлый раз».
// Пишутся ТОЛЬКО через persistOwnSelection — то есть только там, где
// пользователь СОЗНАТЕЛЬНО меняет вкладку/фильтр (см. gotoOwnTab/setOwnStatus
// в RequestPortal.tsx), а не при каждой навигации: иначе, например, просто
// открыв и закрыв заявку, можно было бы затереть запомненное значение
// значением по умолчанию.
const OWN_TAB_STORAGE_KEY = 'rp-own-tab';
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
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;
}
}
/** Запомнить подвкладку/фильтр «Моих заявок» — вызывать только при явном выборе пользователя. */
export function persistOwnSelection(ownTab: OwnTab, ownStatus: string | undefined): void {
try {
window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab);
if (ownStatus) {
window.localStorage.setItem(OWN_STATUS_STORAGE_KEY, ownStatus);
} else {
window.localStorage.removeItem(OWN_STATUS_STORAGE_KEY);
}
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
}
function parseNav(raw: string | undefined): NavSection {
if (
raw === 'approvals' ||
@@ -76,8 +124,8 @@ function parseNav(raw: string | undefined): NavSection {
}
function parseOwnTab(raw: string | undefined): OwnTab {
if (raw === 'approved' || raw === 'rejected' || raw === 'executed') return raw;
return 'created';
if (raw === 'approved' || raw === 'rejected' || raw === 'executed' || raw === 'created') return raw;
return readStoredOwnTab();
}
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
@@ -96,7 +144,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
// Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с
// решениями отбор задаёт сама вкладка.
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? undefined : undefined;
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
// Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: