пофикшен персист
This commit is contained in:
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import styles from './RequestPortal.module.scss';
|
import styles from './RequestPortal.module.scss';
|
||||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||||
import { navigate, persistOwnSelection, useRoute, NavSection, OwnTab } from '../shared/routing';
|
import { navigate, persistOwnTab, persistOwnStatus, useRoute, NavSection, OwnTab } from '../shared/routing';
|
||||||
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
|
||||||
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
|
||||||
import { SideNav } from './SideNav/SideNav';
|
import { SideNav } from './SideNav/SideNav';
|
||||||
@@ -114,21 +114,23 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
// ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет
|
// ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет
|
||||||
// его только когда nav==='own'), а при возврате на уже открытую «own» их
|
// его только когда nav==='own'), а при возврате на уже открытую «own» их
|
||||||
// подхватит storage-фолбэк в parseRoute — то же самое значение, которое
|
// подхватит storage-фолбэк в parseRoute — то же самое значение, которое
|
||||||
// пользователь выбирал последним (см. persistOwnSelection/gotoOwnTab).
|
// пользователь выбирал последним (см. persistOwnTab/gotoOwnTab).
|
||||||
navigate({ mode: 'submit', nav });
|
navigate({ mode: 'submit', nav });
|
||||||
};
|
};
|
||||||
|
|
||||||
const gotoOwnTab = (ownTab: OwnTab): void => {
|
const gotoOwnTab = (ownTab: OwnTab): void => {
|
||||||
// Фильтр по статусу относится только к «Инициированным» — на других
|
// ownStatus здесь НЕ передаём (даже для 'created'!) — по той же причине,
|
||||||
// подвкладках отбор задаёт сама вкладка, поэтому фильтр туда не тащим.
|
// что и в goto() выше: он подхватится storage-фолбэком в parseRoute. Если
|
||||||
const ownStatus = ownTab === 'created' ? route.ownStatus : undefined;
|
// передать route.ownStatus явно, при переходе с ДРУГОЙ подвкладки он всегда
|
||||||
persistOwnSelection(ownTab, ownStatus);
|
// undefined (сейчас мы не на 'created'), и фильтр на «Инициированных»
|
||||||
navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus });
|
// выглядел бы каждый раз сброшенным — ровно тот баг, который тут был.
|
||||||
|
persistOwnTab(ownTab);
|
||||||
|
navigate({ mode: 'submit', nav: 'own', ownTab });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setOwnStatus = (ownStatus: string): void => {
|
const setOwnStatus = (ownStatus: string): void => {
|
||||||
const normalized = ownStatus || undefined;
|
const normalized = ownStatus || undefined;
|
||||||
persistOwnSelection('created', normalized);
|
persistOwnStatus(normalized);
|
||||||
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized });
|
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized });
|
||||||
};
|
};
|
||||||
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
|
// Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик,
|
||||||
|
|||||||
@@ -66,6 +66,17 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Цвет пунктов списка — те же цвета, что у StatusBadge (см. statusOptionClassName
|
||||||
|
// в RequestsTableView.tsx). Только color: background-color нативный <option>
|
||||||
|
// показывает не во всех браузерах одинаково, поэтому не используем.
|
||||||
|
.optionDraft { color: $gray-700; }
|
||||||
|
.optionOnApproval { color: $blue-600; }
|
||||||
|
.optionApproved { color: $green-600; }
|
||||||
|
.optionRejected { color: $red-500; }
|
||||||
|
.optionReturned { color: $amber-700; }
|
||||||
|
.optionExecution { color: $navy-800; }
|
||||||
|
.optionClosed { color: $gray-500; }
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -77,6 +77,32 @@ const EMPTY_TEXTS: Record<ListView, string> = {
|
|||||||
'own:executed': 'Вы ещё ничего не выполнили.',
|
'own:executed': 'Вы ещё ничего не выполнили.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
|
||||||
|
// что в StatusBadge (VARIANT), только цвет текста, а не бейдж целиком: внутри
|
||||||
|
// нативного <option> нельзя отрендерить компонент, а background-color на нём
|
||||||
|
// браузеры показывают ненадёжно (в отличие от color). Если сопоставление там
|
||||||
|
// поменяется — поправить и здесь, второй раз найти дублирование негде.
|
||||||
|
function statusOptionClassName(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case REQUEST_STATUS.onApproval:
|
||||||
|
case REQUEST_STATUS.onExecution:
|
||||||
|
return styles.optionOnApproval;
|
||||||
|
case REQUEST_STATUS.approved:
|
||||||
|
case REQUEST_STATUS.executed:
|
||||||
|
return styles.optionApproved;
|
||||||
|
case REQUEST_STATUS.rejected:
|
||||||
|
return styles.optionRejected;
|
||||||
|
case REQUEST_STATUS.returned:
|
||||||
|
return styles.optionReturned;
|
||||||
|
case REQUEST_STATUS.execution:
|
||||||
|
return styles.optionExecution;
|
||||||
|
case REQUEST_STATUS.closed:
|
||||||
|
return styles.optionClosed;
|
||||||
|
default:
|
||||||
|
return styles.optionDraft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
||||||
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
||||||
// текущий статус мог уйти дальше вашего решения.
|
// текущий статус мог уйти дальше вашего решения.
|
||||||
@@ -331,14 +357,17 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
</h2>
|
</h2>
|
||||||
{view === 'own:created' && onStatusFilterChange && (
|
{view === 'own:created' && onStatusFilterChange && (
|
||||||
<select
|
<select
|
||||||
className={styles.statusFilter}
|
// Цвет самого <select> (закрытое состояние) — тем же классом, что и
|
||||||
|
// у выбранного <option>: statusFilter задаёт нейтральные border/фон,
|
||||||
|
// а конкретный statusOptionClassName(statusFilter) перекрывает color.
|
||||||
|
className={`${styles.statusFilter} ${statusFilter ? statusOptionClassName(statusFilter) : ''}`}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => onStatusFilterChange(e.target.value)}
|
onChange={(e) => onStatusFilterChange(e.target.value)}
|
||||||
aria-label="Фильтр по статусу"
|
aria-label="Фильтр по статусу"
|
||||||
>
|
>
|
||||||
<option value="">Все статусы</option>
|
<option value="">Все статусы</option>
|
||||||
{REQUEST_STATUS_CHOICES.map((s) => (
|
{REQUEST_STATUS_CHOICES.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s} className={statusOptionClassName(s)}>
|
||||||
{s}
|
{s}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { IRoute, navigate, parseRoute, persistOwnSelection, routeUrl } from './routing';
|
import { IRoute, navigate, parseRoute, persistOwnStatus, persistOwnTab, routeUrl } from './routing';
|
||||||
|
|
||||||
// buildQuery/navigate читают и пишут window.location через history API, а
|
// buildQuery/navigate читают и пишут window.location через history API, а
|
||||||
// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnSelection) —
|
// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnTab/
|
||||||
// сбрасываем оба перед каждым тестом, чтобы они не зависели от порядка запуска.
|
// persistOwnStatus) — сбрасываем оба перед каждым тестом, чтобы они не
|
||||||
|
// зависели от порядка запуска.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@@ -128,38 +129,51 @@ describe('подвкладки и фильтр «Моих заявок»', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('persistOwnSelection — запоминание подвкладки/фильтра между визитами', () => {
|
describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => {
|
||||||
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
||||||
persistOwnSelection('rejected', undefined);
|
persistOwnTab('rejected');
|
||||||
expect(parseRoute('?rpnav=own').ownTab).toBe('rejected');
|
expect(parseRoute('?rpnav=own').ownTab).toBe('rejected');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('без явного rpstatus подставляется запомненный фильтр (только на «Инициированных»)', () => {
|
it('без явного rpstatus подставляется запомненный фильтр (только на «Инициированных»)', () => {
|
||||||
persistOwnSelection('created', 'Черновик');
|
persistOwnStatus('Черновик');
|
||||||
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
||||||
// На вкладках с решениями запомненный фильтр ни при чём — там его нет по смыслу.
|
// На вкладках с решениями запомненный фильтр ни при чём — там его нет по смыслу.
|
||||||
persistOwnSelection('approved', 'Черновик');
|
persistOwnTab('approved');
|
||||||
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('явный параметр в адресе имеет приоритет над запомненным значением', () => {
|
it('явный параметр в адресе имеет приоритет над запомненным значением', () => {
|
||||||
persistOwnSelection('rejected', undefined);
|
persistOwnTab('rejected');
|
||||||
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
|
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('очистка фильтра persistOwnSelection убирает его и из последующих parseRoute', () => {
|
it('очистка фильтра persistOwnStatus убирает его и из последующих parseRoute', () => {
|
||||||
persistOwnSelection('created', 'Черновик');
|
persistOwnStatus('Черновик');
|
||||||
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
||||||
|
|
||||||
persistOwnSelection('created', undefined);
|
persistOwnStatus(undefined);
|
||||||
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('запомненное значение не протекает за пределы раздела «Мои заявки»', () => {
|
it('запомненное значение не протекает за пределы раздела «Мои заявки»', () => {
|
||||||
persistOwnSelection('rejected', undefined);
|
persistOwnTab('rejected');
|
||||||
expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined();
|
expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ровно тот баг, который это всё затевалось починить: подвкладка «Инициированные»
|
||||||
|
// с выбранным фильтром → уход на другую подвкладку → возврат — фильтр должен
|
||||||
|
// остаться прежним, а не сброситься на «Все статусы» (см. RequestPortal.tsx/
|
||||||
|
// gotoOwnTab — persistOwnTab НЕ трогает сохранённый статус).
|
||||||
|
it('переключение на другую подвкладку и обратно не стирает запомненный фильтр', () => {
|
||||||
|
persistOwnStatus('На согласовании');
|
||||||
|
expect(parseRoute('?rpnav=own').ownStatus).toBe('На согласовании');
|
||||||
|
|
||||||
|
persistOwnTab('approved');
|
||||||
|
persistOwnTab('created');
|
||||||
|
expect(parseRoute('?rpnav=own').ownStatus).toBe('На согласовании');
|
||||||
|
});
|
||||||
|
|
||||||
it('localStorage недоступен (например, приватный режим) — не роняет parseRoute', () => {
|
it('localStorage недоступен (например, приватный режим) — не роняет parseRoute', () => {
|
||||||
const original = window.localStorage.getItem;
|
const original = window.localStorage.getItem;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- имитация приватного режима, где чтение localStorage бросает
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- имитация приватного режима, где чтение localStorage бросает
|
||||||
|
|||||||
@@ -71,11 +71,21 @@ const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
|||||||
// является единственным источником правды для ownTab/ownStatus (см.
|
// является единственным источником правды для ownTab/ownStatus (см.
|
||||||
// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут,
|
// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут,
|
||||||
// где URL молчит, естественно спросить «а что было в прошлый раз».
|
// где URL молчит, естественно спросить «а что было в прошлый раз».
|
||||||
// Пишутся ТОЛЬКО через persistOwnSelection — то есть только там, где
|
// Пишутся ТОЛЬКО через persistOwnTab/persistOwnStatus — то есть только там,
|
||||||
// пользователь СОЗНАТЕЛЬНО меняет вкладку/фильтр (см. gotoOwnTab/setOwnStatus
|
// где пользователь СОЗНАТЕЛЬНО меняет вкладку или фильтр (см.
|
||||||
// в RequestPortal.tsx), а не при каждой навигации: иначе, например, просто
|
// gotoOwnTab/setOwnStatus в RequestPortal.tsx), а не при каждой навигации:
|
||||||
// открыв и закрыв заявку, можно было бы затереть запомненное значение
|
// иначе, например, просто открыв и закрыв заявку, можно было бы затереть
|
||||||
// значением по умолчанию.
|
// запомненное значение значением по умолчанию.
|
||||||
|
//
|
||||||
|
// НАМЕРЕННО два отдельных сеттера, а не один persistOwnSelection(tab, status):
|
||||||
|
// переключение НА подвкладку с решениями (approved/rejected/executed) должно
|
||||||
|
// запомнить только её саму, не трогая фильтр статуса — тот относится
|
||||||
|
// ИСКЛЮЧИТЕЛЬНО к «Инициированным» и при возврате туда должен найтись таким
|
||||||
|
// же, каким был оставлен. Один сеттер этого не даёт: при переключении на
|
||||||
|
// другую подвкладку он получал бы ownStatus=undefined (потому что на других
|
||||||
|
// подвкладках фильтра просто нет) и стирал бы запомненное значение — что и
|
||||||
|
// произошло на практике: уйдя с «Инициированных» на «Согласованные» и
|
||||||
|
// вернувшись обратно, фильтр каждый раз сбрасывался на «Все статусы».
|
||||||
const OWN_TAB_STORAGE_KEY = 'rp-own-tab';
|
const OWN_TAB_STORAGE_KEY = 'rp-own-tab';
|
||||||
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
|
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
|
||||||
|
|
||||||
@@ -96,10 +106,18 @@ function readStoredOwnStatus(): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Запомнить подвкладку/фильтр «Моих заявок» — вызывать только при явном выборе пользователя. */
|
/** Запомнить подвкладку «Моих заявок» — вызывать только при явном выборе пользователя. */
|
||||||
export function persistOwnSelection(ownTab: OwnTab, ownStatus: string | undefined): void {
|
export function persistOwnTab(ownTab: OwnTab): void {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab);
|
window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab);
|
||||||
|
} catch {
|
||||||
|
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запомнить фильтр по статусу вкладки «Инициированные» — вызывать только при явном выборе пользователя. */
|
||||||
|
export function persistOwnStatus(ownStatus: string | undefined): void {
|
||||||
|
try {
|
||||||
if (ownStatus) {
|
if (ownStatus) {
|
||||||
window.localStorage.setItem(OWN_STATUS_STORAGE_KEY, ownStatus);
|
window.localStorage.setItem(OWN_STATUS_STORAGE_KEY, ownStatus);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user