diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index e27dce2..a01f513 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -7,6 +7,9 @@ import { persistOwnTab, persistOwnStatus, persistPage, + storedOwnStatus, + storedOwnTab, + storedPageFor, useRoute, NavSection, OwnTab, @@ -118,22 +121,40 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); - // Подвкладку/фильтр «Моих заявок» здесь передавать не нужно: при переходе - // ИЗ другого раздела route.ownTab всё равно undefined (parseRoute заполняет - // его только когда nav==='own'), а при возврате на уже открытую «own» их - // подхватит storage-фолбэк в parseRoute — то же самое значение, которое - // пользователь выбирал последним (см. persistOwnTab/gotoOwnTab). - navigate({ mode: 'submit', nav }); + // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного. + // parseRoute подставил бы его и сам, но тогда оно осталось бы только в + // состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую + // страницу без фильтра, а на экране была бы открыта другая. + // + // Брать 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 => { - // ownStatus здесь НЕ передаём (даже для 'created'!) — по той же причине, - // что и в goto() выше: он подхватится storage-фолбэком в parseRoute. Если - // передать route.ownStatus явно, при переходе с ДРУГОЙ подвкладки он всегда - // undefined (сейчас мы не на 'created'), и фильтр на «Инициированных» - // выглядел бы каждый раз сброшенным — ровно тот баг, который тут был. + // Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о + // том, почему не из route). На вкладках с решениями фильтра нет по смыслу — + // storedOwnStatus сам вернёт undefined. 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 => { diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index a5fa6e3..37c67e5 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -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 === '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 }; } @@ -142,15 +152,14 @@ function formatDate(iso: string): string { // строка, а не только название), поэтому строка — с onClick + tabIndex // (доступ с клавиатуры: Enter/Space). // -// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»; -// статус не показываем — здесь все заявки по определению на согласовании. -// «own»: автор всегда текущий пользователь, зато важен статус. +// Набор колонок зависит от раздела и от фильтра — см. columnsFor. const Table: React.FC<{ rows: IMyRequest[]; view: ListView; + statusFilter: string; onOpen: (request: IMyRequest) => void; -}> = ({ rows, view, onOpen }) => { - const columns = columnsFor(view); +}> = ({ rows, view, statusFilter, onOpen }) => { + const columns = columnsFor(view, statusFilter); return (
@@ -247,11 +256,22 @@ export const RequestsTableView: React.FC = ({ const [totalRows, setTotalRows] = React.useState(0); const [query, setQuery] = usePersistedQuery(view); const [error, setError] = React.useState(''); + // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, // иначе поиск бил бы запросом по каждой набранной букве. const debouncedQuery = useDebouncedQuery(query); + // Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows. + // Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при + // переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не + // отработает загрузка. Без этой отметки сброс страницы ниже принимал решение + // по чужому totalRows и сбивал страницу на первую — из-за чего запомненная + // страница затиралась при каждом переходе по левому меню. + // Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу. + const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`; + const [loadedKey, setLoadedKey] = React.useState(''); + // Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3» // от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь // НЕ обрабатываем — там страница приходит уже правильной из маршрута @@ -292,6 +312,9 @@ export const RequestsTableView: React.FC = ({ const cached = readPage(cacheKey); setRows(cached ? cached.rows : undefined); setTotalRows(cached ? cached.totalRows : 0); + // Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего + // на основании totalRows решать нельзя — см. loadedKey ниже. + setLoadedKey(cached ? dataKey : ''); const q = debouncedQuery.trim(); @@ -340,6 +363,9 @@ export const RequestsTableView: React.FC = ({ setRows(fresh.rows); setTotalRows(fresh.totalRows); } + // Данные точно от текущей таблицы — теперь по ним можно судить о + // количестве страниц (см. сброс страницы ниже). + setLoadedKey(dataKey); setError(''); } catch (e) { if (!cancelled) setError((e as Error).message); @@ -372,9 +398,14 @@ export const RequestsTableView: React.FC = ({ // из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они // ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком — // выглядит как поломка, поэтому возвращаемся на последнюю существующую. + // + // Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от + // предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу + // на первую при обычном переходе по левому меню. React.useEffect(() => { + if (loadedKey !== dataKey) return; if (rows && page > totalPages) onPageChange(totalPages); - }, [rows, page, totalPages, onPageChange]); + }, [loadedKey, dataKey, rows, page, totalPages, onPageChange]); return (
@@ -419,7 +450,7 @@ export const RequestsTableView: React.FC = ({ )} {!error && rows && total > 0 && ( <> -
+
)} diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 70e1618..924c292 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -6,6 +6,9 @@ import { persistOwnTab, persistPage, routeUrl, + storedOwnStatus, + storedOwnTab, + storedPageFor, } from './routing'; // buildQuery/navigate читают и пишут window.location через history API, а @@ -206,6 +209,34 @@ describe('страница таблицы (rppage + persistPage)', () => { 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 бросает @@ -253,6 +284,40 @@ describe('persistOwnTab/persistOwnStatus — запоминание подвкл 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/ diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 012b841..6496c66 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -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 { try {