Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
@@ -12,12 +12,18 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||
const FIELDS = {
|
||||
// Внутренние имена с префиксом группы колонок сайта (TansuSite) и «Request» —
|
||||
// так они заведены в SharePoint. Из них SharePoint выводит имена managed
|
||||
// properties для поиска: TansuSiteApproverOWSUSER и RequestStatusOWSCHCS
|
||||
// (см. MP_APPROVER/MP_STATUS в features/myRequests/myRequests.service.ts).
|
||||
// Внутренние имена с префиксом группы колонок сайта (TansuSite) там, где он
|
||||
// есть, — так они заведены в SharePoint. Из них SharePoint выводит имена
|
||||
// managed properties для поиска: TansuSiteApproverOWSUSER и
|
||||
// ApprovalStatusOWSCHCS (см. MP_APPROVER/MP_STATUS в
|
||||
// features/myRequests/myRequests.service.ts).
|
||||
//
|
||||
// ВАЖНО: колонка называется ApprovalStatus, а не Status и не RequestStatus —
|
||||
// это статус СТРОКИ маршрута (список «— согласование»); статус ЗАЯВКИ в
|
||||
// списке «— данных» лежит в отдельной колонке RequestStatus (см.
|
||||
// models/statuses.ts — там оба набора значений сведены в одну картину).
|
||||
approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId
|
||||
status: 'RequestStatus', // «Статус» (Choice)
|
||||
status: 'ApprovalStatus', // «Статус» (Choice) строки маршрута
|
||||
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
||||
date: 'Date', // «Дата» (DateTime)
|
||||
comment: 'Comment', // «Комментарий» (Text)
|
||||
|
||||
@@ -147,7 +147,7 @@ export class ApproverService {
|
||||
|
||||
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
||||
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
|
||||
Status: status,
|
||||
RequestStatus: status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,16 +45,21 @@ export function isEditableStatus(status: string | undefined): boolean {
|
||||
|
||||
// Наборов значений ДВА, и они не совпадают — это разные сущности:
|
||||
//
|
||||
// 1) статус ЗАЯВКИ — колонка Status в списке «… — данные». Пишется формой
|
||||
// (Черновик/На согласовании) и computeRequestStatus после каждого решения.
|
||||
// «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это состояния
|
||||
// отдельного шага маршрута, а не заявки целиком.
|
||||
// 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется
|
||||
// формой (Черновик/На согласовании) и computeRequestStatus после каждого
|
||||
// решения. «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это
|
||||
// состояния отдельного шага маршрута, а не заявки целиком.
|
||||
//
|
||||
// 2) статус СТРОКИ МАРШРУТА — колонка RequestStatus в списке «… — согласование».
|
||||
// 2) статус СТРОКИ МАРШРУТА — колонка ApprovalStatus в списке «… — согласование».
|
||||
// Пишется createSteps/activateNextQueued/setDecision. «Выполнение»/«Закрыта»
|
||||
// здесь не бывают: это агрегатные состояния заявки, у отдельного шага
|
||||
// смысла не имеют.
|
||||
//
|
||||
// Обе колонки переименованы (были Status в списке данных и RequestStatus в
|
||||
// списке согласования соответственно) — переименование внутреннего имени в
|
||||
// SharePoint не трогает уже сохранённые значения, отдельная миграция данных
|
||||
// не нужна.
|
||||
//
|
||||
// Держать их в синхроне с forms/*/provisioning.json и forms/*/approval/provisioning.json.
|
||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||
REQUEST_STATUS.draft,
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
|
||||
// ключи блока ДО перестановки: после неё индексы уже другие.
|
||||
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
|
||||
const registerRow = useFlipRows(activeKeys);
|
||||
const registerRow = useFlipRows(activeKeys, rowKeys);
|
||||
|
||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -67,19 +67,60 @@ function drop(row: HTMLTableRowElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Положение строк ОТНОСИТЕЛЬНО их <tbody>, а не окна.
|
||||
*
|
||||
* Это принципиально: getBoundingClientRect() отдаёт координату относительно
|
||||
* ОКНА, а прокрутка страницы не вызывает ре-рендер — значит, запомненные с
|
||||
* прошлого рендера позиции остаются от прежнего положения прокрутки. Стоит
|
||||
* пользователю прокрутить страницу к маршруту и нажать стрелку, как в сдвиг
|
||||
* попадает вся дельта прокрутки (сотни пикселей), и строки улетают за пределы
|
||||
* таблицы. Именно так выглядел баг «первое нажатие ломает, дальше нормально»:
|
||||
* повторные клики уже сравнивались с позициями, замеренными при текущей
|
||||
* прокрутке, поэтому шли правильно.
|
||||
*
|
||||
* Относительная база от этого избавляет: и строка, и <tbody> замеряются в
|
||||
* одном кадре, поэтому прокрутка сокращается. Заодно это чинит и случай, когда
|
||||
* блок маршрута целиком уезжает из-за изменений НАД ним (например, свернулся
|
||||
* блок вложений): tbody уезжает вместе со строками, и мнимого сдвига нет.
|
||||
*/
|
||||
function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, number> {
|
||||
const tops = new Map<string, number>();
|
||||
|
||||
let firstRow: HTMLTableRowElement | undefined;
|
||||
nodes.forEach((el) => {
|
||||
if (!firstRow) firstRow = el;
|
||||
});
|
||||
if (!firstRow) return tops;
|
||||
|
||||
const container = (firstRow as HTMLTableRowElement).parentElement;
|
||||
const base = container ? container.getBoundingClientRect().top : 0;
|
||||
|
||||
nodes.forEach((el, key) => tops.set(key, el.getBoundingClientRect().top - base));
|
||||
return tops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает функцию, которая по ключу строки отдаёт ref-колбэк для её <tr>.
|
||||
* @param activeKeys ключи строк того шага, который двигает пользователь —
|
||||
* только он подсвечивается и поднимается над остальными (см. ApprovalMatrix).
|
||||
* Для прочих изменений состава (добавление/удаление) — пустой массив.
|
||||
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
|
||||
* которому решаем, действительно ли что-то переставили (см. ниже, почему
|
||||
* геометрии для этого недостаточно).
|
||||
*/
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
||||
export function useFlipRows(activeKeys: string[]): (key: string) => (el: HTMLTableRowElement | null) => void {
|
||||
export function useFlipRows(
|
||||
activeKeys: string[],
|
||||
rowKeys: string[]
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
|
||||
): (key: string) => (el: HTMLTableRowElement | null) => void {
|
||||
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
|
||||
// Колбэк на один и тот же ключ должен быть стабильным, иначе React дёргал бы
|
||||
// ref(null)/ref(el) на каждый рендер и карта узлов пересобиралась бы впустую.
|
||||
const refCache = React.useRef(new Map<string, (el: HTMLTableRowElement | null) => void>());
|
||||
const prevTops = React.useRef(new Map<string, number>());
|
||||
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
|
||||
const prevRowKeys = React.useRef<string[]>([]);
|
||||
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
|
||||
// вернул бы заодно чужие (CSS-переходы и т.п.).
|
||||
const running = React.useRef<Animation[]>([]);
|
||||
@@ -101,25 +142,45 @@ export function useFlipRows(activeKeys: string[]): (key: string) => (el: HTMLTab
|
||||
// useEffect: инвертировать положение нужно до того, как браузер покажет кадр,
|
||||
// иначе будет заметен скачок.
|
||||
React.useLayoutEffect(() => {
|
||||
// ВАЖНО: «строка сдвинулась по пикселям» — недостаточный повод анимировать.
|
||||
// Пример живого бага: заявку отправили на согласование → таблица стала
|
||||
// read-only → у строк пропали колонки со стрелками/кнопками → ширина
|
||||
// текстовых колонок изменилась → где-то текст перенёсся по-другому →
|
||||
// строки СМЕСТИЛИСЬ ПО ВЫСОТЕ, хотя их состав и порядок не менялись вовсе.
|
||||
// Раньше это трактовалось как «переезд» и запускало translateY на
|
||||
// случайную величину — на кадре это выглядело как строки, которые на
|
||||
// мгновение пропадают (уезжают за пределы видимой области) и выскакивает
|
||||
// скролл. Настоящая перестановка меняет НАБОР/ПОРЯДОК ключей — вот это и
|
||||
// проверяем, а не сам факт смещения.
|
||||
const orderChanged =
|
||||
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
|
||||
prevRowKeys.current = rowKeys;
|
||||
|
||||
if (!orderChanged) {
|
||||
// Порядок тот же — просто обновляем базовую геометрию для следующего
|
||||
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
|
||||
prevTops.current = measureTops(nodes.current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Сначала запоминаем, где строка ВИДНА сейчас: при быстрых повторных кликах
|
||||
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
|
||||
// текущего положения на экране, иначе будет рывок.
|
||||
const visualTops = new Map<string, number>();
|
||||
nodes.current.forEach((el, key) => visualTops.set(key, el.getBoundingClientRect().top));
|
||||
const visualTops = measureTops(nodes.current);
|
||||
|
||||
running.current.forEach((a) => a.cancel());
|
||||
running.current = [];
|
||||
nodes.current.forEach(drop);
|
||||
|
||||
const previous = prevTops.current;
|
||||
const current = new Map<string, number>();
|
||||
// После отмены анимаций transform снят — это истинные новые положения.
|
||||
const current = measureTops(nodes.current);
|
||||
const moved: Array<{ el: HTMLTableRowElement; key: string; shift: number }> = [];
|
||||
const entered: HTMLTableRowElement[] = [];
|
||||
|
||||
nodes.current.forEach((el, key) => {
|
||||
// После отмены анимаций transform снят — это истинное новое положение.
|
||||
const layoutTop = el.getBoundingClientRect().top;
|
||||
current.set(key, layoutTop);
|
||||
const layoutTop = current.get(key);
|
||||
if (layoutTop === undefined) return;
|
||||
|
||||
const before = previous.get(key);
|
||||
if (before === undefined) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { installErrorLogging } from './diagnostics';
|
||||
|
||||
// jsdom не порождает unhandledrejection сам — событие диспатчим руками, как это
|
||||
// сделал бы браузер. Конструктора PromiseRejectionEvent в jsdom нет, поэтому
|
||||
// собираем обычное Event и дописываем reason (слушатель читает только его).
|
||||
function emitRejection(reason: unknown): void {
|
||||
const event = new Event('unhandledrejection') as Event & { reason?: unknown };
|
||||
event.reason = reason;
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe('installErrorLogging', () => {
|
||||
let errors: unknown[][];
|
||||
let spy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errors = [];
|
||||
spy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
});
|
||||
installErrorLogging();
|
||||
});
|
||||
|
||||
afterEach(() => spy.mockRestore());
|
||||
|
||||
it('печатает сообщение и стек обычной ошибки', () => {
|
||||
emitRejection(new Error('Список недоступен'));
|
||||
|
||||
const text = errors.map((a) => a.join(' ')).join('\n');
|
||||
expect(text).toContain('Список недоступен');
|
||||
expect(text).toContain('Стек');
|
||||
});
|
||||
|
||||
it('не теряет причину, если это не Error, а объект (типичный отказ PnPjs)', () => {
|
||||
emitRejection({ status: 403, message: 'Access denied' });
|
||||
|
||||
const text = errors.map((a) => a.map((x) => JSON.stringify(x)).join(' ')).join('\n');
|
||||
expect(text).toContain('403');
|
||||
expect(text).toContain('Access denied');
|
||||
});
|
||||
|
||||
it('не падает на причине с циклической ссылкой', () => {
|
||||
const cyclic: Record<string, unknown> = { name: 'зацикленное' };
|
||||
cyclic.self = cyclic;
|
||||
|
||||
expect(() => emitRejection(cyclic)).not.toThrow();
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('строка-причина попадает в лог как есть', () => {
|
||||
emitRejection('просто текст');
|
||||
expect(errors.map((a) => a.join(' ')).join('\n')).toContain('просто текст');
|
||||
});
|
||||
|
||||
it('повторный вызов не вешает второй слушатель — ошибка не двоится', () => {
|
||||
installErrorLogging();
|
||||
installErrorLogging();
|
||||
|
||||
emitRejection(new Error('однократно'));
|
||||
|
||||
const count = errors.filter((a) => String(a[0]).indexOf('Необработанная ошибка') !== -1).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Читаемый лог необработанных отклонений промисов.
|
||||
//
|
||||
// Зачем: SPFx вешает на window свой обработчик unhandledrejection, и в консоль
|
||||
// попадает только его собственный стек внутри минифицированного бандла —
|
||||
// примерно такой:
|
||||
//
|
||||
// Error
|
||||
// at vp (...request-portal-web-part_<hash>.js:9:397946)
|
||||
// at t.onUnhandledRejection (...)
|
||||
//
|
||||
// Ни причины, ни места, где промис отклонился, там нет: SPFx логирует САМ факт,
|
||||
// а не `reason`. Поэтому вешаем свой слушатель — он срабатывает наравне с
|
||||
// SPFx-овским и печатает то, что действительно нужно: сообщение, стек и сам
|
||||
// объект причины (у ошибок PnPjs/SharePoint внутри лежит ответ сервера).
|
||||
//
|
||||
// Ничего не подавляем (preventDefault не зовём) — поведение портала не
|
||||
// меняется, добавляется только диагностика.
|
||||
|
||||
const PREFIX = '[Портал заявок]';
|
||||
|
||||
// Слушатель вешается один раз на страницу: на странице может быть несколько
|
||||
// экземпляров веб-части, и каждый вызвал бы install() в onInit — без флага
|
||||
// одна и та же ошибка печаталась бы столько раз, сколько их размещено.
|
||||
let installed = false;
|
||||
|
||||
// Причиной отклонения может быть что угодно, не только Error — у PnPjs это
|
||||
// бывает объект с полями ответа. Разбираем по факту, а не по ожиданию.
|
||||
function describeReason(reason: unknown): string {
|
||||
if (reason instanceof Error) return `${reason.name}: ${reason.message}`;
|
||||
if (typeof reason === 'string') return reason;
|
||||
try {
|
||||
return JSON.stringify(reason);
|
||||
} catch {
|
||||
// Циклические ссылки — тогда хотя бы тип.
|
||||
return Object.prototype.toString.call(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export function installErrorLogging(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const reason: unknown = e.reason;
|
||||
console.error(`${PREFIX} Необработанная ошибка: ${describeReason(reason)}`);
|
||||
// Стек и объект — отдельными аргументами: так консоль браузера даёт
|
||||
// раскрыть объект и перейти по стеку, а не показывает «[object Object]».
|
||||
if (reason instanceof Error && reason.stack) console.error(`${PREFIX} Стек:`, reason.stack);
|
||||
console.error(`${PREFIX} Причина целиком:`, reason);
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,37 @@
|
||||
import { IRoute, navigate, parseRoute, routeUrl } from './routing';
|
||||
import {
|
||||
IRoute,
|
||||
navigate,
|
||||
parseRoute,
|
||||
persistOwnStatus,
|
||||
persistOwnTab,
|
||||
persistPage,
|
||||
persistQuery,
|
||||
routeUrl,
|
||||
storedOwnStatus,
|
||||
storedOwnTab,
|
||||
storedPageFor,
|
||||
storedQueryFor,
|
||||
} from './routing';
|
||||
|
||||
// buildQuery/navigate читают и пишут window.location через history API —
|
||||
// сбрасываем адрес перед каждым тестом, чтобы они не зависели от порядка запуска.
|
||||
// buildQuery/navigate читают и пишут window.location через history API, а
|
||||
// подвкладка/фильтр «Моих заявок» — ещё и localStorage (см. persistOwnTab/
|
||||
// persistOwnStatus) — сбрасываем оба перед каждым тестом, чтобы они не
|
||||
// зависели от порядка запуска.
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('parseRoute', () => {
|
||||
it('пустой адрес — подача заявки в разделе «Новая заявка»', () => {
|
||||
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new' });
|
||||
// page всегда определён (1 — значение по умолчанию), в адрес не пишется.
|
||||
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1, query: '' });
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -27,6 +46,8 @@ describe('parseRoute', () => {
|
||||
nav: 'new',
|
||||
formKey: 'hire',
|
||||
itemId: 42,
|
||||
page: 1,
|
||||
query: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +61,8 @@ describe('parseRoute', () => {
|
||||
nav: 'new',
|
||||
formKey: 'hire',
|
||||
itemId: 7,
|
||||
page: 1,
|
||||
query: '',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +96,351 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('страница таблицы (rppage + persistPage)', () => {
|
||||
it('без rppage — первая страница', () => {
|
||||
expect(parseRoute('?rpnav=own').page).toBe(1);
|
||||
});
|
||||
|
||||
it('rppage задаёт страницу', () => {
|
||||
expect(parseRoute('?rpnav=own&rppage=3').page).toBe(3);
|
||||
});
|
||||
|
||||
it('мусор в rppage трактуется как первая страница, а не роняет разбор', () => {
|
||||
expect(parseRoute('?rpnav=own&rppage=0').page).toBe(1);
|
||||
expect(parseRoute('?rpnav=own&rppage=-2').page).toBe(1);
|
||||
expect(parseRoute('?rpnav=own&rppage=abc').page).toBe(1);
|
||||
expect(parseRoute('?rpnav=own&rppage=1.5').page).toBe(1);
|
||||
});
|
||||
|
||||
it('первая страница не засоряет адрес, непервая — попадает', () => {
|
||||
expect(routeUrl({ mode: 'submit', nav: 'own', page: 1 })).not.toContain('rppage');
|
||||
expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rppage');
|
||||
expect(routeUrl({ mode: 'submit', nav: 'own', page: 4 })).toContain('rppage=4');
|
||||
});
|
||||
|
||||
it('round-trip страницы через navigate', () => {
|
||||
navigate({ mode: 'submit', nav: 'approvals', page: 2 });
|
||||
expect(parseRoute(window.location.search).page).toBe(2);
|
||||
});
|
||||
|
||||
it('без rppage подставляется запомненная для ЭТОЙ таблицы страница', () => {
|
||||
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
|
||||
expect(parseRoute('?rpnav=approvals').page).toBe(5);
|
||||
});
|
||||
|
||||
it('страница запоминается отдельно по таблицам и не переносится между разделами', () => {
|
||||
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
|
||||
// В другом разделе своя нумерация — чужая страница туда попасть не должна,
|
||||
// иначе можно приземлиться на несуществующую (у него всего одна страница).
|
||||
expect(parseRoute('?rpnav=execution').page).toBe(1);
|
||||
});
|
||||
|
||||
it('подвкладки «Моих заявок» — тоже разные таблицы, у каждой своя страница', () => {
|
||||
persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 3);
|
||||
persistPage({ mode: 'submit', nav: 'own', ownTab: 'approved' }, 7);
|
||||
|
||||
expect(parseRoute('?rpnav=own&rptab=created').page).toBe(3);
|
||||
expect(parseRoute('?rpnav=own&rptab=approved').page).toBe(7);
|
||||
});
|
||||
|
||||
it('явная страница в адресе имеет приоритет над запомненной', () => {
|
||||
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
|
||||
expect(parseRoute('?rpnav=approvals&rppage=2').page).toBe(2);
|
||||
});
|
||||
|
||||
it('возврат на первую страницу стирает запомненную', () => {
|
||||
persistPage({ mode: 'submit', nav: 'approvals' }, 5);
|
||||
expect(parseRoute('?rpnav=approvals').page).toBe(5);
|
||||
|
||||
persistPage({ mode: 'submit', nav: 'approvals' }, 1);
|
||||
expect(parseRoute('?rpnav=approvals').page).toBe(1);
|
||||
});
|
||||
|
||||
it('страница переживает открытие заявки поверх списка', () => {
|
||||
navigate({ mode: 'submit', nav: 'own', ownTab: 'created', page: 3 });
|
||||
navigate({ mode: 'approve', nav: 'own', ownTab: 'created', formKey: 'hire', itemId: 42, page: 3 });
|
||||
expect(parseRoute(window.location.search).page).toBe(3);
|
||||
});
|
||||
|
||||
// Ради этого storedPageFor и заведён: при переходе по меню маршрут СТРОИТСЯ,
|
||||
// и если не подставить запомненную страницу явно, она останется только в
|
||||
// состоянии — в адресе будет первая, а на экране открытая (см. goto в
|
||||
// RequestPortal.tsx).
|
||||
it('storedPageFor отдаёт запомненную страницу нужной таблицы', () => {
|
||||
persistPage({ mode: 'submit', nav: 'approvals' }, 4);
|
||||
expect(storedPageFor('approvals')).toBe(4);
|
||||
expect(storedPageFor('execution')).toBe(1);
|
||||
});
|
||||
|
||||
it('storedPageFor различает подвкладки «Моих заявок»', () => {
|
||||
persistPage({ mode: 'submit', nav: 'own', ownTab: 'rejected' }, 6);
|
||||
expect(storedPageFor('own', 'rejected')).toBe(6);
|
||||
expect(storedPageFor('own', 'created')).toBe(1);
|
||||
});
|
||||
|
||||
it('storedPageFor без подвкладки берёт запомненную — ту, что и откроется', () => {
|
||||
persistOwnTab('executed');
|
||||
persistPage({ mode: 'submit', nav: 'own', ownTab: 'executed' }, 2);
|
||||
expect(storedPageFor('own')).toBe(2);
|
||||
});
|
||||
|
||||
it('переход по меню кладёт запомненную страницу в адрес', () => {
|
||||
persistPage({ mode: 'submit', nav: 'approvals' }, 3);
|
||||
navigate({ mode: 'submit', nav: 'approvals', page: storedPageFor('approvals') });
|
||||
expect(window.location.search).toContain('rppage=3');
|
||||
});
|
||||
|
||||
it('localStorage недоступен — страница просто первая, без падения', () => {
|
||||
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').page).toBe(1);
|
||||
} finally {
|
||||
window.localStorage.getItem = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('поиск (rpq + persistQuery)', () => {
|
||||
it('без rpq — пустой запрос', () => {
|
||||
expect(parseRoute('?rpnav=own').query).toBe('');
|
||||
});
|
||||
|
||||
it('rpq задаёт запрос, в т.ч. с пробелами и кириллицей', () => {
|
||||
expect(parseRoute('?rpnav=own&rpq=%D0%B0%D0%B1%D0%B2%20%D0%B3').query).toBe('абв г');
|
||||
});
|
||||
|
||||
it('пустой запрос не засоряет адрес, непустой — попадает', () => {
|
||||
expect(routeUrl({ mode: 'submit', nav: 'own', query: '' })).not.toContain('rpq');
|
||||
expect(routeUrl({ mode: 'submit', nav: 'own' })).not.toContain('rpq');
|
||||
expect(routeUrl({ mode: 'submit', nav: 'own', query: 'бухгалтер' })).toContain('rpq=');
|
||||
});
|
||||
|
||||
it('round-trip запроса через navigate', () => {
|
||||
navigate({ mode: 'submit', nav: 'search', query: 'Горбенко' });
|
||||
expect(parseRoute(window.location.search).query).toBe('Горбенко');
|
||||
});
|
||||
|
||||
it('без rpq подставляется запомненный для ЭТОЙ таблицы запрос', () => {
|
||||
persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер');
|
||||
expect(parseRoute('?rpnav=search').query).toBe('бухгалтер');
|
||||
});
|
||||
|
||||
it('запрос запоминается отдельно по таблицам и не переносится между разделами', () => {
|
||||
persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер');
|
||||
expect(parseRoute('?rpnav=approvals').query).toBe('');
|
||||
});
|
||||
|
||||
it('подвкладки «Моих заявок» — тоже разные таблицы, у каждой свой запрос', () => {
|
||||
persistQuery({ mode: 'submit', nav: 'own', ownTab: 'created' }, 'приём');
|
||||
persistQuery({ mode: 'submit', nav: 'own', ownTab: 'approved' }, 'место');
|
||||
|
||||
expect(parseRoute('?rpnav=own&rptab=created').query).toBe('приём');
|
||||
expect(parseRoute('?rpnav=own&rptab=approved').query).toBe('место');
|
||||
});
|
||||
|
||||
it('очистка запроса стирает запомненный', () => {
|
||||
persistQuery({ mode: 'submit', nav: 'search' }, 'бухгалтер');
|
||||
expect(parseRoute('?rpnav=search').query).toBe('бухгалтер');
|
||||
|
||||
persistQuery({ mode: 'submit', nav: 'search' }, '');
|
||||
expect(parseRoute('?rpnav=search').query).toBe('');
|
||||
});
|
||||
|
||||
it('storedQueryFor отдаёт запомненный запрос нужной таблицы', () => {
|
||||
persistQuery({ mode: 'submit', nav: 'approvals' }, 'заявка');
|
||||
expect(storedQueryFor('approvals')).toBe('заявка');
|
||||
expect(storedQueryFor('execution')).toBe('');
|
||||
});
|
||||
|
||||
it('переход по меню кладёт восстановленный запрос в адрес', () => {
|
||||
persistQuery({ mode: 'submit', nav: 'approvals' }, 'заявка');
|
||||
navigate({ mode: 'submit', nav: 'approvals', query: storedQueryFor('approvals') || undefined });
|
||||
expect(parseRoute(window.location.search).query).toBe('заявка');
|
||||
});
|
||||
|
||||
it('replace не добавляет запись в историю — набор текста не засоряет «назад»', () => {
|
||||
navigate({ mode: 'submit', nav: 'search' });
|
||||
const before = window.history.length;
|
||||
|
||||
navigate({ mode: 'submit', nav: 'search', query: 'бух' }, { replace: true });
|
||||
navigate({ mode: 'submit', nav: 'search', query: 'бухг' }, { replace: true });
|
||||
|
||||
expect(window.history.length).toBe(before);
|
||||
expect(parseRoute(window.location.search).query).toBe('бухг');
|
||||
});
|
||||
|
||||
it('localStorage недоступен — запрос просто пустой, без падения', () => {
|
||||
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').query).toBe('');
|
||||
} finally {
|
||||
window.localStorage.getItem = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => {
|
||||
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
||||
persistOwnTab('rejected');
|
||||
expect(parseRoute('?rpnav=own').ownTab).toBe('rejected');
|
||||
});
|
||||
|
||||
it('без явного rpstatus подставляется запомненный фильтр (только на «Инициированных»)', () => {
|
||||
persistOwnStatus('Черновик');
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
||||
// На вкладках с решениями запомненный фильтр ни при чём — там его нет по смыслу.
|
||||
persistOwnTab('approved');
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('явный параметр в адресе имеет приоритет над запомненным значением', () => {
|
||||
persistOwnTab('rejected');
|
||||
expect(parseRoute('?rpnav=own&rptab=executed').ownTab).toBe('executed');
|
||||
});
|
||||
|
||||
it('очистка фильтра persistOwnStatus убирает его и из последующих parseRoute', () => {
|
||||
persistOwnStatus('Черновик');
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBe('Черновик');
|
||||
|
||||
persistOwnStatus(undefined);
|
||||
expect(parseRoute('?rpnav=own').ownStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('запомненное значение не протекает за пределы раздела «Мои заявки»', () => {
|
||||
persistOwnTab('rejected');
|
||||
expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined();
|
||||
});
|
||||
|
||||
// Как и со страницей: при переходе маршрут СТРОИТСЯ, и без явной подстановки
|
||||
// восстановленный фильтр остался бы только в состоянии — в адресе его бы не
|
||||
// было (см. goto/gotoOwnTab в RequestPortal.tsx).
|
||||
it('storedOwnStatus отдаёт фильтр только для вкладки «Инициированные»', () => {
|
||||
persistOwnStatus('Отклонено');
|
||||
expect(storedOwnStatus('created')).toBe('Отклонено');
|
||||
expect(storedOwnStatus('approved')).toBeUndefined();
|
||||
expect(storedOwnStatus('rejected')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('storedOwnTab отдаёт запомненную подвкладку', () => {
|
||||
persistOwnTab('executed');
|
||||
expect(storedOwnTab()).toBe('executed');
|
||||
});
|
||||
|
||||
it('переход в «Мои заявки» кладёт восстановленный фильтр в адрес', () => {
|
||||
persistOwnStatus('Согласовано');
|
||||
const ownTab = storedOwnTab();
|
||||
|
||||
navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus: storedOwnStatus(ownTab) });
|
||||
|
||||
expect(window.location.search).toContain('rpstatus=');
|
||||
expect(parseRoute(window.location.search).ownStatus).toBe('Согласовано');
|
||||
});
|
||||
|
||||
it('переход на подвкладку решений фильтр в адрес не тащит', () => {
|
||||
persistOwnStatus('Согласовано');
|
||||
|
||||
navigate({ mode: 'submit', nav: 'own', ownTab: 'approved', ownStatus: storedOwnStatus('approved') });
|
||||
|
||||
expect(window.location.search).toContain('rptab=approved');
|
||||
expect(window.location.search).not.toContain('rpstatus');
|
||||
});
|
||||
|
||||
// Ровно тот баг, который это всё затевалось починить: подвкладка «Инициированные»
|
||||
// с выбранным фильтром → уход на другую подвкладку → возврат — фильтр должен
|
||||
// остаться прежним, а не сброситься на «Все статусы» (см. 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', () => {
|
||||
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' });
|
||||
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 +471,26 @@ describe('navigate', () => {
|
||||
nav: 'own',
|
||||
formKey: 'hire',
|
||||
itemId: 42,
|
||||
// Подвкладка «Моих заявок» всегда определена внутри раздела — в адресе её
|
||||
// нет только потому, что это значение по умолчанию.
|
||||
ownTab: 'created',
|
||||
ownStatus: undefined,
|
||||
page: 1,
|
||||
query: '',
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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,207 @@ export interface IRoute {
|
||||
itemId?: number;
|
||||
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
||||
sourceId?: number;
|
||||
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
|
||||
ownTab?: OwnTab;
|
||||
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
|
||||
ownStatus?: string;
|
||||
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
|
||||
page?: number;
|
||||
/** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */
|
||||
query?: string;
|
||||
}
|
||||
|
||||
const ROUTE_CHANGE_EVENT = 'rp-route-change';
|
||||
|
||||
// Подвкладка и фильтр «Моих заявок» переживают перезагрузку страницы и новый
|
||||
// визит (localStorage), но НЕ более того — как и текст поиска в
|
||||
// usePersistedQuery.ts, это просто «продолжить с того же места», а не
|
||||
// синхронизация между вкладками/устройствами.
|
||||
//
|
||||
// Читаются здесь же, в parseRoute, а не отдельным хуком в компоненте: URL уже
|
||||
// является единственным источником правды для ownTab/ownStatus (см.
|
||||
// buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут,
|
||||
// где URL молчит, естественно спросить «а что было в прошлый раз».
|
||||
// Пишутся ТОЛЬКО через persistOwnTab/persistOwnStatus — то есть только там,
|
||||
// где пользователь СОЗНАТЕЛЬНО меняет вкладку или фильтр (см.
|
||||
// gotoOwnTab/setOwnStatus в RequestPortal.tsx), а не при каждой навигации:
|
||||
// иначе, например, просто открыв и закрыв заявку, можно было бы затереть
|
||||
// запомненное значение значением по умолчанию.
|
||||
//
|
||||
// НАМЕРЕННО два отдельных сеттера, а не один persistOwnSelection(tab, status):
|
||||
// переключение НА подвкладку с решениями (approved/rejected/executed) должно
|
||||
// запомнить только её саму, не трогая фильтр статуса — тот относится
|
||||
// ИСКЛЮЧИТЕЛЬНО к «Инициированным» и при возврате туда должен найтись таким
|
||||
// же, каким был оставлен. Один сеттер этого не даёт: при переключении на
|
||||
// другую подвкладку он получал бы ownStatus=undefined (потому что на других
|
||||
// подвкладках фильтра просто нет) и стирал бы запомненное значение — что и
|
||||
// произошло на практике: уйдя с «Инициированных» на «Согласованные» и
|
||||
// вернувшись обратно, фильтр каждый раз сбрасывался на «Все статусы».
|
||||
const OWN_TAB_STORAGE_KEY = 'rp-own-tab';
|
||||
const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
|
||||
// Страница запоминается ОТДЕЛЬНО ДЛЯ КАЖДОЙ таблицы (см. viewKeyOf): один общий
|
||||
// номер переносился бы между разделами, где у каждого свой объём данных, —
|
||||
// уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего
|
||||
// одна, пользователь попал бы в пустоту.
|
||||
const PAGE_STORAGE_PREFIX = 'rp-page:';
|
||||
// Текст поиска — тоже свой на каждую таблицу. Префикс НЕ меняем: под ним
|
||||
// поисковые запросы уже лежат (раньше их хранил хук usePersistedQuery), и
|
||||
// сохранённое у пользователей должно пережить переезд в маршрут.
|
||||
const QUERY_STORAGE_PREFIX = 'rp-search-query:';
|
||||
|
||||
/**
|
||||
* Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в
|
||||
* RequestsTableView (плюс 'search'): у каждой подвкладки «Моих заявок» свой
|
||||
* список и, значит, своя страница.
|
||||
*/
|
||||
function viewKeyOf(nav: NavSection, ownTab: OwnTab | undefined): string {
|
||||
return nav === 'own' ? `own:${ownTab ?? 'created'}` : nav;
|
||||
}
|
||||
|
||||
function readStoredPage(viewKey: string): number | undefined {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PAGE_STORAGE_PREFIX + viewKey);
|
||||
if (!raw) return undefined;
|
||||
const page = Number(raw);
|
||||
return Number.isInteger(page) && page > 1 ? page : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запомненная страница таблицы, на которую собираемся перейти. Нужна там, где
|
||||
* маршрут СТРОИТСЯ (переход по меню/подвкладке): parseRoute подставил бы её и
|
||||
* сам, но тогда номер остался бы только в состоянии и не попал бы в адрес —
|
||||
* ссылка на список показывала бы не ту страницу, что открыта на экране.
|
||||
*
|
||||
* ownTab не задан (переход в «Мои заявки» из другого раздела) — берём
|
||||
* запомненную подвкладку: именно она и откроется (см. parseOwnTab).
|
||||
*/
|
||||
export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number {
|
||||
const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined;
|
||||
return readStoredPage(viewKeyOf(nav, tab)) ?? 1;
|
||||
}
|
||||
|
||||
function readStoredQuery(viewKey: string): string {
|
||||
try {
|
||||
return window.localStorage.getItem(QUERY_STORAGE_PREFIX + viewKey) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Запомненный текст поиска таблицы — по тем же причинам, что и storedPageFor. */
|
||||
export function storedQueryFor(nav: NavSection, ownTab?: OwnTab): string {
|
||||
const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined;
|
||||
return readStoredQuery(viewKeyOf(nav, tab));
|
||||
}
|
||||
|
||||
/** Запомнить текст поиска текущей таблицы. */
|
||||
export function persistQuery(route: IRoute, query: string): void {
|
||||
const viewKey = viewKeyOf(route.nav, route.ownTab);
|
||||
try {
|
||||
if (query) {
|
||||
window.localStorage.setItem(QUERY_STORAGE_PREFIX + viewKey, query);
|
||||
} else {
|
||||
window.localStorage.removeItem(QUERY_STORAGE_PREFIX + viewKey);
|
||||
}
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута —
|
||||
* вызывающей стороне не нужно знать, как таблицы различаются между собой.
|
||||
*/
|
||||
export function persistPage(route: IRoute, page: number): void {
|
||||
const viewKey = viewKeyOf(route.nav, route.ownTab);
|
||||
try {
|
||||
// Первая страница — значение по умолчанию: храним её ОТСУТСТВИЕМ ключа,
|
||||
// иначе в localStorage копился бы мусор из единиц по каждой таблице.
|
||||
if (page > 1) {
|
||||
window.localStorage.setItem(PAGE_STORAGE_PREFIX + viewKey, String(page));
|
||||
} else {
|
||||
window.localStorage.removeItem(PAGE_STORAGE_PREFIX + viewKey);
|
||||
}
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запомненные подвкладка и фильтр — как и storedPageFor, нужны там, где маршрут
|
||||
* СТРОИТСЯ: parseRoute подставил бы их и сам, но тогда они остались бы только в
|
||||
* состоянии и не попали бы в адрес (ссылка на раздел вела бы не туда, что
|
||||
* открыто на экране).
|
||||
*/
|
||||
export function storedOwnTab(): OwnTab {
|
||||
return readStoredOwnTab();
|
||||
}
|
||||
|
||||
/** Вне вкладки «Инициированные» фильтра по статусу нет по смыслу — там отбор задаёт сама вкладка. */
|
||||
export function storedOwnStatus(ownTab: OwnTab): string | undefined {
|
||||
return ownTab === 'created' ? readStoredOwnStatus() : undefined;
|
||||
}
|
||||
|
||||
/** Запомнить подвкладку «Моих заявок» — вызывать только при явном выборе пользователя. */
|
||||
export function persistOwnTab(ownTab: OwnTab): void {
|
||||
try {
|
||||
window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab);
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
}
|
||||
|
||||
/** Запомнить фильтр по статусу вкладки «Инициированные» — вызывать только при явном выборе пользователя. */
|
||||
export function persistOwnStatus(ownStatus: string | undefined): void {
|
||||
try {
|
||||
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' || 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' || raw === 'created') return raw;
|
||||
return readStoredOwnTab();
|
||||
}
|
||||
|
||||
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||||
export function parseRoute(search: string = window.location.search): IRoute {
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -57,19 +266,39 @@ 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') ?? readStoredOwnStatus() : undefined;
|
||||
|
||||
// Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в
|
||||
// параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку:
|
||||
// ссылка приходит извне, ронять из-за неё раздел незачем.
|
||||
const pageRaw = Number(params.get('rppage'));
|
||||
const page =
|
||||
Number.isInteger(pageRaw) && pageRaw > 1 ? pageRaw : readStoredPage(viewKeyOf(nav, ownTab)) ?? 1;
|
||||
|
||||
// Текст поиска — из адреса, иначе запомненный для этой же таблицы.
|
||||
const query = params.get('rpq') ?? readStoredQuery(viewKeyOf(nav, ownTab));
|
||||
|
||||
// Открытая заявка (панель поверх раздела) — при полном наборе параметров.
|
||||
// Раздел под панелью сохраняется целиком, вместе с подвкладкой, фильтром и
|
||||
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же
|
||||
// список и на то же место в нём.
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'approve', nav, formKey, itemId };
|
||||
return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query };
|
||||
}
|
||||
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
|
||||
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'edit', nav, formKey, itemId };
|
||||
return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||
}
|
||||
return { mode: 'submit', nav };
|
||||
return { mode: 'submit', nav, ownTab, ownStatus, page, query };
|
||||
}
|
||||
|
||||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
||||
@@ -80,10 +309,24 @@ function buildQuery(route: IRoute): string {
|
||||
params.delete('rptype');
|
||||
params.delete('rpid');
|
||||
params.delete('rpsource');
|
||||
params.delete('rptab');
|
||||
params.delete('rpstatus');
|
||||
params.delete('rppage');
|
||||
params.delete('rpq');
|
||||
|
||||
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.page && route.page > 1) params.set('rppage', String(route.page));
|
||||
if (route.query) params.set('rpq', route.query);
|
||||
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
|
||||
params.set('rpmode', route.mode);
|
||||
params.set('rptype', route.formKey);
|
||||
@@ -101,9 +344,19 @@ function buildQuery(route: IRoute): string {
|
||||
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
|
||||
* query-параметры адресной строки через history.pushState и оповещает
|
||||
* useRoute() своим событием (popstate на pushState сам не срабатывает).
|
||||
*
|
||||
* replace=true заменяет текущую запись истории вместо добавления новой —
|
||||
* для набора текста в поиске: иначе каждая буква становилась бы отдельным
|
||||
* шагом «назад», и чтобы уйти со страницы, пришлось бы стереть весь запрос
|
||||
* по одному нажатию.
|
||||
*/
|
||||
export function navigate(route: IRoute): void {
|
||||
window.history.pushState({}, '', buildQuery(route));
|
||||
export function navigate(route: IRoute, options?: { replace?: boolean }): void {
|
||||
const url = buildQuery(route);
|
||||
if (options?.replace) {
|
||||
window.history.replaceState({}, '', url);
|
||||
} else {
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,5 @@
|
||||
import * as React from 'react';
|
||||
|
||||
// Текст поиска переживает перезагрузку страницы (localStorage), но не более
|
||||
// того — это просто «продолжить с того же места», а не синхронизация между
|
||||
// вкладками/устройствами. key должен быть уникальным на раздел (см. вызовы в
|
||||
// RequestsTableView/SearchView), иначе значения разных таблиц перепишут друг друга.
|
||||
function readStored(storageKey: string): string {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function usePersistedQuery(key: string): [string, (value: string) => void] {
|
||||
const storageKey = `rp-search-query:${key}`;
|
||||
|
||||
const [value, setValue] = React.useState<string>(() => readStored(storageKey));
|
||||
|
||||
// Компонент может продолжать жить, когда key поменялся: RequestsTableView —
|
||||
// это один и тот же элемент для вкладок «На согласовании» и «Мои заявки»,
|
||||
// меняется только проп kind. Инициализатор useState при этом повторно НЕ
|
||||
// срабатывает, поэтому без перечитывания текст из одной вкладки оставался бы
|
||||
// на экране во второй и записывался уже под её ключ — выглядело бы так, будто
|
||||
// ключ у них общий.
|
||||
const prevKey = React.useRef<string>(storageKey);
|
||||
React.useEffect(() => {
|
||||
if (prevKey.current === storageKey) return;
|
||||
prevKey.current = storageKey;
|
||||
setValue(readStored(storageKey));
|
||||
}, [storageKey]);
|
||||
|
||||
const setPersisted = React.useCallback(
|
||||
(next: string): void => {
|
||||
setValue(next);
|
||||
try {
|
||||
if (next) {
|
||||
window.localStorage.setItem(storageKey, next);
|
||||
} else {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {
|
||||
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
|
||||
}
|
||||
},
|
||||
[storageKey]
|
||||
);
|
||||
|
||||
return [value, setPersisted];
|
||||
}
|
||||
|
||||
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
|
||||
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
|
||||
// (Search + дочитывание через REST по каждому типу заявок), из которых нужен
|
||||
@@ -74,3 +25,56 @@ export function useDebouncedQuery(value: string, delayMs: number = SEARCH_DEBOUN
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Связывает поле поиска с маршрутом: в поле текст меняется мгновенно, а в
|
||||
* маршрут (и, значит, в адрес) уходит только после паузы в наборе.
|
||||
*
|
||||
* Раньше текст хранился прямо здесь, в localStorage (usePersistedQuery). Теперь
|
||||
* источник правды — маршрут: так поиск попадает в ссылку и работает кнопка
|
||||
* «назад», а запоминание между визитами делает persistQuery в shared/routing.ts.
|
||||
*
|
||||
* Неочевидное место — ref `pushed`: в нём лежит текст, на котором поле и
|
||||
* маршрут в последний раз сошлись. Он нужен в ОБЕ стороны.
|
||||
*
|
||||
* Вниз (маршрут → поле): без него собственное же изменение маршрута
|
||||
* возвращалось бы обратно в поле и затирало текст, набранный за время паузы:
|
||||
* пользователь печатает «абвг», через 450 мс в маршрут уходит «абв», маршрут
|
||||
* меняется, и обратная синхронизация вернула бы в поле «абв», съев «г».
|
||||
* Поэтому поле перечитывается только от ВНЕШНИХ смен (другой раздел, «назад»).
|
||||
*
|
||||
* Вверх (поле → маршрут): сравнивать `debounced` нужно именно с `pushed`, а НЕ
|
||||
* с текущим routeQuery. RequestsTableView — один экземпляр на все таблицы
|
||||
* («На согласовании», «Мои заявки» и т.д.), и при переключении раздела меняется
|
||||
* не поле, а routeQuery. Со сравнением по routeQuery это выглядело как «текст
|
||||
* изменился» — и запрос ПРЕДЫДУЩЕЙ таблицы уезжал в новую (и там же
|
||||
* сохранялся), из-за чего один и тот же поиск оказывался во всех разделах.
|
||||
*/
|
||||
export function useRoutedQuery(
|
||||
routeQuery: string,
|
||||
onQueryChange: (query: string) => void
|
||||
): [string, (value: string) => void] {
|
||||
const [input, setInput] = React.useState<string>(routeQuery);
|
||||
const debounced = useDebouncedQuery(input);
|
||||
const pushed = React.useRef<string>(routeQuery);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (debounced === pushed.current) return;
|
||||
pushed.current = debounced;
|
||||
onQueryChange(debounced);
|
||||
// onQueryChange пересоздаётся на каждый рендер родителя — в зависимостях
|
||||
// он гонял бы эффект вхолостую; важен только сам факт смены debounced.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debounced]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (routeQuery === pushed.current) return;
|
||||
// Пришло извне — принимаем как новую точку схождения. Иначе догоняющий
|
||||
// debounced (в поле уже новый текст) тут же счёл бы себя правкой
|
||||
// пользователя и отправил бы этот же текст обратно в маршрут.
|
||||
pushed.current = routeQuery;
|
||||
setInput(routeQuery);
|
||||
}, [routeQuery]);
|
||||
|
||||
return [input, setInput];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user