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

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
@@ -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;
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
// Раздел под панелью сохраняется целиком, вместе с подвкладкой и фильтром: