всякие статусы лишние убраны

This commit is contained in:
gorbenko.pavel
2026-08-10 17:36:55 +05:00
parent b4b40ce235
commit 3dd4195539
4 changed files with 167 additions and 21 deletions
@@ -7,6 +7,9 @@ import {
persistOwnTab, persistOwnTab,
persistOwnStatus, persistOwnStatus,
persistPage, persistPage,
storedOwnStatus,
storedOwnTab,
storedPageFor,
useRoute, useRoute,
NavSection, NavSection,
OwnTab, OwnTab,
@@ -118,22 +121,40 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// таблица на него подписана и перечитает данные фоном, не убирая с экрана // таблица на него подписана и перечитает данные фоном, не убирая с экрана
// текущие строки (см. RequestsTableView/SearchView). // текущие строки (см. RequestsTableView/SearchView).
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
// Подвкладку/фильтр «Моих заявок» здесь передавать не нужно: при переходе // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
// ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет // parseRoute подставил бы его и сам, но тогда оно осталось бы только в
// его только когда nav==='own'), а при возврате на уже открытую «own» их // состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
// подхватит storage-фолбэк в parseRoute — то же самое значение, которое // страницу без фильтра, а на экране была бы открыта другая.
// пользователь выбирал последним (см. persistOwnTab/gotoOwnTab). //
navigate({ mode: 'submit', nav }); // Брать route.ownTab/route.ownStatus здесь нельзя: при переходе ИЗ другого
// раздела они undefined (parseRoute заполняет их только при nav==='own'),
// и явная передача сбросила бы выбор пользователя.
if (nav === 'own') {
const ownTab = storedOwnTab();
navigate({
mode: 'submit',
nav,
ownTab,
ownStatus: storedOwnStatus(ownTab),
page: storedPageFor('own', ownTab),
});
return;
}
navigate({ mode: 'submit', nav, page: storedPageFor(nav) });
}; };
const gotoOwnTab = (ownTab: OwnTab): void => { const gotoOwnTab = (ownTab: OwnTab): void => {
// ownStatus здесь НЕ передаём (даже для 'created'!) — по той же причине, // Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о
// что и в goto() выше: он подхватится storage-фолбэком в parseRoute. Если // том, почему не из route). На вкладках с решениями фильтра нет по смыслу —
// передать route.ownStatus явно, при переходе с ДРУГОЙ подвкладки он всегда // storedOwnStatus сам вернёт undefined.
// undefined (сейчас мы не на 'created'), и фильтр на «Инициированных»
// выглядел бы каждый раз сброшенным — ровно тот баг, который тут был.
persistOwnTab(ownTab); persistOwnTab(ownTab);
navigate({ mode: 'submit', nav: 'own', ownTab }); navigate({
mode: 'submit',
nav: 'own',
ownTab,
ownStatus: storedOwnStatus(ownTab),
page: storedPageFor('own', ownTab),
});
}; };
const setOwnStatus = (ownStatus: string): void => { const setOwnStatus = (ownStatus: string): void => {
@@ -106,9 +106,19 @@ function statusOptionClassName(status: string): string {
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из // Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её // самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
// текущий статус мог уйти дальше вашего решения. // текущий статус мог уйти дальше вашего решения.
function columnsFor(view: ListView): { author: boolean; status: boolean } { //
// 'rework' — свои заявки (автор не нужен) И все с одним статусом «Возвращена на
// доработку», раз именно по нему раздел и отобран (см. getOwnRequestsByStatus):
// колонка из одинаковых плашек ничего не сообщает, только шумит. Та же логика,
// что у очередей согласования/исполнения.
//
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
// поэтому она зависит не от одного вида, а ещё и от фильтра.
function columnsFor(view: ListView, statusFilter: string): { author: boolean; status: boolean } {
if (view === 'approvals' || view === 'execution') return { author: true, status: false }; if (view === 'approvals' || view === 'execution') return { author: true, status: false };
if (view === 'rework' || view === 'own:created') return { author: false, status: true }; if (view === 'rework') return { author: false, status: false };
if (view === 'own:created') return { author: false, status: !statusFilter };
return { author: true, status: true }; return { author: true, status: true };
} }
@@ -142,15 +152,14 @@ function formatDate(iso: string): string {
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex // строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space). // (доступ с клавиатуры: Enter/Space).
// //
// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»; // Набор колонок зависит от раздела и от фильтра — см. columnsFor.
// статус не показываем — здесь все заявки по определению на согласовании.
// «own»: автор всегда текущий пользователь, зато важен статус.
const Table: React.FC<{ const Table: React.FC<{
rows: IMyRequest[]; rows: IMyRequest[];
view: ListView; view: ListView;
statusFilter: string;
onOpen: (request: IMyRequest) => void; onOpen: (request: IMyRequest) => void;
}> = ({ rows, view, onOpen }) => { }> = ({ rows, view, statusFilter, onOpen }) => {
const columns = columnsFor(view); const columns = columnsFor(view, statusFilter);
return ( return (
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<table className={styles.table}> <table className={styles.table}>
@@ -247,11 +256,22 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const [totalRows, setTotalRows] = React.useState<number>(0); const [totalRows, setTotalRows] = React.useState<number>(0);
const [query, setQuery] = usePersistedQuery(view); const [query, setQuery] = usePersistedQuery(view);
const [error, setError] = React.useState<string>(''); const [error, setError] = React.useState<string>('');
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
// иначе поиск бил бы запросом по каждой набранной букве. // иначе поиск бил бы запросом по каждой набранной букве.
const debouncedQuery = useDebouncedQuery(query); const debouncedQuery = useDebouncedQuery(query);
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
// отработает загрузка. Без этой отметки сброс страницы ниже принимал решение
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
// страница затиралась при каждом переходе по левому меню.
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
const [loadedKey, setLoadedKey] = React.useState<string>('');
// Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3» // Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3»
// от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь // от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь
// НЕ обрабатываем — там страница приходит уже правильной из маршрута // НЕ обрабатываем — там страница приходит уже правильной из маршрута
@@ -292,6 +312,9 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const cached = readPage(cacheKey); const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined); setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0); setTotalRows(cached ? cached.totalRows : 0);
// Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего
// на основании totalRows решать нельзя — см. loadedKey ниже.
setLoadedKey(cached ? dataKey : '');
const q = debouncedQuery.trim(); const q = debouncedQuery.trim();
@@ -340,6 +363,9 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
setRows(fresh.rows); setRows(fresh.rows);
setTotalRows(fresh.totalRows); setTotalRows(fresh.totalRows);
} }
// Данные точно от текущей таблицы — теперь по ним можно судить о
// количестве страниц (см. сброс страницы ниже).
setLoadedKey(dataKey);
setError(''); setError('');
} catch (e) { } catch (e) {
if (!cancelled) setError((e as Error).message); if (!cancelled) setError((e as Error).message);
@@ -372,9 +398,14 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они // из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком — // ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
// выглядит как поломка, поэтому возвращаемся на последнюю существующую. // выглядит как поломка, поэтому возвращаемся на последнюю существующую.
//
// Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от
// предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу
// на первую при обычном переходе по левому меню.
React.useEffect(() => { React.useEffect(() => {
if (loadedKey !== dataKey) return;
if (rows && page > totalPages) onPageChange(totalPages); if (rows && page > totalPages) onPageChange(totalPages);
}, [rows, page, totalPages, onPageChange]); }, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
return ( return (
<section className={styles.view}> <section className={styles.view}>
@@ -419,7 +450,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
)} )}
{!error && rows && total > 0 && ( {!error && rows && total > 0 && (
<> <>
<Table rows={visible} view={view} onOpen={onOpen} /> <Table rows={visible} view={view} statusFilter={statusFilter} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={onPageChange} /> <Pager page={page} totalPages={totalPages} onChange={onPageChange} />
</> </>
)} )}
@@ -6,6 +6,9 @@ import {
persistOwnTab, persistOwnTab,
persistPage, persistPage,
routeUrl, routeUrl,
storedOwnStatus,
storedOwnTab,
storedPageFor,
} from './routing'; } from './routing';
// buildQuery/navigate читают и пишут window.location через history API, а // buildQuery/navigate читают и пишут window.location через history API, а
@@ -206,6 +209,34 @@ describe('страница таблицы (rppage + persistPage)', () => {
expect(parseRoute(window.location.search).page).toBe(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 недоступен — страница просто первая, без падения', () => { it('localStorage недоступен — страница просто первая, без падения', () => {
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 бросает
@@ -253,6 +284,40 @@ describe('persistOwnTab/persistOwnStatus — запоминание подвкл
expect(parseRoute('?rpnav=approvals').ownTab).toBeUndefined(); 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/ // остаться прежним, а не сброситься на «Все статусы» (см. RequestPortal.tsx/
@@ -116,6 +116,20 @@ function readStoredPage(viewKey: string): number | 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;
}
/** /**
* Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута — * Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута —
* вызывающей стороне не нужно знать, как таблицы различаются между собой. * вызывающей стороне не нужно знать, как таблицы различаются между собой.
@@ -152,6 +166,21 @@ function readStoredOwnStatus(): string | 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 { export function persistOwnTab(ownTab: OwnTab): void {
try { try {