From 67c86576189a4df5c59e28026b524eb87a23c211 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 18:18:42 +0500 Subject: [PATCH] =?UTF-8?q?=D1=83=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=80=D0=BE=D1=83=D1=82=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 54 ++++++++- .../features/myRequests/RequestsTableView.tsx | 38 +++--- .../features/myRequests/SearchView.tsx | 108 ++++++++++++++---- .../features/myRequests/myRequests.service.ts | 30 ++--- .../requestPortal/shared/routing.test.ts | 91 ++++++++++++++- src/webparts/requestPortal/shared/routing.ts | 57 ++++++++- .../requestPortal/shared/usePersistedQuery.ts | 102 +++++++++-------- 7 files changed, 365 insertions(+), 115 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index a01f513..f5df610 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -7,9 +7,11 @@ import { persistOwnTab, persistOwnStatus, persistPage, + persistQuery, storedOwnStatus, storedOwnTab, storedPageFor, + storedQueryFor, useRoute, NavSection, OwnTab, @@ -137,10 +139,16 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context ownTab, ownStatus: storedOwnStatus(ownTab), page: storedPageFor('own', ownTab), + query: storedQueryFor('own', ownTab) || undefined, }); return; } - navigate({ mode: 'submit', nav, page: storedPageFor(nav) }); + navigate({ + mode: 'submit', + nav, + page: storedPageFor(nav), + query: storedQueryFor(nav) || undefined, + }); }; const gotoOwnTab = (ownTab: OwnTab): void => { @@ -154,6 +162,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context ownTab, ownStatus: storedOwnStatus(ownTab), page: storedPageFor('own', ownTab), + query: storedQueryFor('own', ownTab) || undefined, }); }; @@ -163,7 +172,19 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // Смена фильтра — другой набор заявок, поэтому явно с первой страницы // (и запоминаем именно её, иначе вернувшись сюда попали бы на старую). persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1); - navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized, page: 1 }); + // Текст поиска переносим ЯВНО: раздел тот же, поле на экране не очищается — + // значит и в адресе запрос должен остаться. Без этого rpq пропадал из + // ссылки при каждой смене фильтра (в самом состоянии он всё равно + // восстанавливался из запомненного, поэтому расхождение адреса с экраном + // было видно не сразу). + navigate({ + mode: 'submit', + nav: 'own', + ownTab: 'created', + ownStatus: normalized, + page: 1, + query: route.query || undefined, + }); }; // Смена страницы сохраняет ВЕСЬ остальной маршрут (в т.ч. открытую заявку), @@ -173,6 +194,18 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context persistPage(route, page); navigate({ ...route, page }); }; + + // Новый запрос — другой набор результатов, поэтому вместе с ним всегда первая + // страница (иначе можно приземлиться на несуществующую). + // + // replace, а не push: текст уходит сюда после КАЖДОЙ паузы в наборе, и с + // обычным переходом каждое слово становилось бы отдельным шагом «назад» — + // чтобы уйти со страницы, пришлось бы «отлистать» весь набранный запрос. + const setQuery = (query: string): void => { + persistQuery(route, query); + persistPage(route, 1); + navigate({ ...route, query: query || undefined, page: 1 }, { replace: true }); + }; // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // возвращённая, отклонённая) открывается на редактирование, всё остальное — // на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту — @@ -185,12 +218,23 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context itemId: request.id, ownTab: route.ownTab, ownStatus: route.ownStatus, + // Страница и запрос — часть того же списка под панелью: без них ссылка на + // открытую заявку вела бы на первую страницу без поиска. + page: route.page, + query: route.query, }); // Закрывая панель, возвращаемся ровно в тот список, из которого её открыли — // включая подвкладку и фильтр «Моих заявок». const closeDrawer = (): void => - navigate({ mode: 'submit', nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus }); + navigate({ + mode: 'submit', + nav: route.nav, + ownTab: route.ownTab, + ownStatus: route.ownStatus, + page: route.page, + query: route.query, + }); // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // только в памяти — уходя без сохранения, пользователь теряет правки. @@ -263,6 +307,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context context={context} page={route.page ?? 1} onPageChange={setPage} + query={route.query ?? ''} + onQueryChange={setQuery} onOpen={openRequest} /> )} @@ -283,6 +329,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context onStatusFilterChange={setOwnStatus} page={route.page ?? 1} onPageChange={setPage} + query={route.query ?? ''} + onQueryChange={setQuery} onOpen={openRequest} /> )} diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 1fdbe95..6b1f8a8 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -15,7 +15,7 @@ import { import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; -import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; +import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; import styles from './RequestsTableView.module.scss'; @@ -172,6 +172,12 @@ export interface IRequestsTableViewProps { */ page: number; onPageChange: (page: number) => void; + /** + * Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает + * в адрес, переживает открытие заявки и запоминается для этой таблицы. + */ + query: string; + onQueryChange: (query: string) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; } @@ -291,18 +297,20 @@ export const RequestsTableView: React.FC = ({ onStatusFilterChange, page, onPageChange, + query, + onQueryChange, onOpen, }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); - const [query, setQuery] = usePersistedQuery(view); const [error, setError] = React.useState(''); - // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что - // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, - // иначе поиск бил бы запросом по каждой набранной букве. - const debouncedQuery = useDebouncedQuery(query); + // input — то, что в поле ввода (меняется сразу); в маршрут значение уходит + // после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже + // «отложенный», отдельный debounce здесь был бы вторым по счёту. + const [input, setInput] = useRoutedQuery(query, onQueryChange); + const debouncedQuery = query; // Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows. // Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при @@ -314,18 +322,8 @@ export const RequestsTableView: React.FC = ({ const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`; const [loadedKey, setLoadedKey] = React.useState(''); - // Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3» - // от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь - // НЕ обрабатываем — там страница приходит уже правильной из маршрута - // (см. RequestPortal: gotoOwnTab/setOwnStatus). Сравниваем с предыдущим - // значением, а не просто вешаемся на изменение: иначе эффект сбрасывал бы - // страницу и на первом рендере, затирая ту, что пришла из адреса. - const prevQuery = React.useRef(debouncedQuery); - React.useEffect(() => { - if (prevQuery.current === debouncedQuery) return; - prevQuery.current = debouncedQuery; - if (page !== 1) onPageChange(1); - }, [debouncedQuery, page, onPageChange]); + // Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут + // в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal). React.useEffect(() => { let cancelled = false; @@ -491,8 +489,8 @@ export const RequestsTableView: React.FC = ({ type="search" className={styles.search} placeholder="Поиск по заявкам…" - value={query} - onChange={(e) => setQuery(e.target.value)} + value={input} + onChange={(e) => setInput(e.target.value)} /> diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 38ae872..7038d4a 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getLinkedTypes, searchMyRequests } from './myRequests.service'; +import { getLinkedTypes, hydratePendingAuthors, searchMyRequests } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { IRequestType, StatusBadge } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; -import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; +import { useRoutedQuery } from '../../shared/usePersistedQuery'; import { pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; import styles from './SearchView.module.scss'; @@ -26,10 +26,29 @@ export interface ISearchViewProps { */ page: number; onPageChange: (page: number) => void; + /** Текст поиска и его смена — тоже из маршрута (см. page выше). */ + query: string; + onQueryChange: (query: string) => void; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; } +const rowKey = (r: IMyRequest): string => `${r.requestTypeId}-${r.id}`; + +// Автор, уже дочитанный ранее (см. authors в SearchView), — проставляем его в +// свежий результат поиска до сравнения с кешем. Иначе фоновое обновление +// приносило бы строки без автора, слепок страницы отличался бы от кешированного +// на ровном месте, таблица моргала бы и автор загружался заново. +function applyKnownAuthors(rows: IMyRequest[], known: Map): void { + rows.forEach((r) => { + const a = known.get(rowKey(r)); + if (a) { + r.authorName = a.name; + r.authorEmail = a.email; + } + }); +} + function formatDate(iso: string): string { if (!iso) return ''; const d = new Date(iso); @@ -100,27 +119,33 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi // после решения согласующего) — но т.к. это по большей части Search, свежая // правка может проявиться не мгновенно (частично компенсируется довеском // последних 10 минут через REST — см. myRequests.service.ts). -export const SearchView: React.FC = ({ context, page, onPageChange, onOpen }) => { +export const SearchView: React.FC = ({ + context, + page, + onPageChange, + query, + onQueryChange, + onOpen, +}) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); - const [query, setQuery] = usePersistedQuery('search'); - // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что - // уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск - // ходит сразу по двум источникам (свои заявки + где участвую), то есть - // каждая набранная буква стоила бы двух серий запросов. - const debouncedQuery = useDebouncedQuery(query); const [error, setError] = React.useState(''); - // Новый запрос — новый набор результатов, страница от прошлого к нему не - // относится. Сравниваем с предыдущим значением, чтобы не сбросить страницу, - // пришедшую из адреса, на первом же рендере (см. RequestsTableView). - const prevQuery = React.useRef(debouncedQuery); - React.useEffect(() => { - if (prevQuery.current === debouncedQuery) return; - prevQuery.current = debouncedQuery; - if (page !== 1) onPageChange(1); - }, [debouncedQuery, page, onPageChange]); + // Дочитанные авторы, ключ — requestTypeId-id. Живёт между запросами намеренно: + // ключ глобально уникален, так что при возврате к прежнему поиску автор уже + // известен и запрос не повторяется. + const authors = React.useRef>(new Map()); + + // input — то, что в поле ввода (меняется сразу); в маршрут значение уходит + // после паузы в наборе. Здесь пауза особенно важна: поиск ходит сразу по двум + // источникам (свои заявки + где участвую), то есть каждая набранная буква + // стоила бы двух серий запросов. + const [input, setInput] = useRoutedQuery(query, onQueryChange); + const debouncedQuery = query; + + // Страницу при смене запроса здесь НЕ сбрасываем — это делает setQuery в + // RequestPortal одним переходом вместе с самим запросом. React.useEffect(() => { let cancelled = false; @@ -136,6 +161,11 @@ export const SearchView: React.FC = ({ context, page, onPageCh }; }, [context]); + // Поиск зависит ТОЛЬКО от запроса, не от страницы: результат приходит целиком + // и кешируется целиком, а страницы нарезаются ниже, в рендере. Раньше страница + // входила в ключ кеша — и каждое листание было промахом, который заново гонял + // обе серии запросов (свои + где участвую) и показывал «Ищем…», хотя все эти + // строки уже были получены при загрузке первой страницы. React.useEffect(() => { if (types === undefined) return; if (debouncedQuery.trim().length === 0) { @@ -144,7 +174,7 @@ export const SearchView: React.FC = ({ context, page, onPageCh return; } let cancelled = false; - const cacheKey = pageKey('search', [debouncedQuery.trim(), page]); + const cacheKey = pageKey('search', [debouncedQuery.trim()]); // Уже искали это раньше — показываем прежний результат сразу, обновление // идёт фоном (см. RequestsTableView, там та же схема и те же причины). @@ -154,8 +184,9 @@ export const SearchView: React.FC = ({ context, page, onPageCh const load = async (): Promise => { try { - const fresh = await searchMyRequests(context, types, debouncedQuery, page); + const fresh = await searchMyRequests(context, types, debouncedQuery); if (cancelled) return; + applyKnownAuthors(fresh.rows, authors.current); const prev = readPage(cacheKey); if (!prev || pageSignature(prev) !== pageSignature(fresh)) { writePage(cacheKey, fresh); @@ -180,10 +211,39 @@ export const SearchView: React.FC = ({ context, page, onPageCh unsubscribe(); window.clearInterval(timer); }; - }, [context, types, debouncedQuery, page]); + }, [context, types, debouncedQuery]); const hasQuery = debouncedQuery.trim().length > 0; const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + const visible = React.useMemo( + () => (rows ? rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) : undefined), + [rows, page] + ); + + // Автора у строк 'approval' дочитываем отдельным запросом и только для видимой + // страницы: на весь набор (до 300 строк) это был бы $filter из сотен Id — + // такой длины URL SharePoint не переваривает. Строки мутируются на месте, то + // есть автор оседает и в кеше; карта authors не даёт дёргать один и тот же + // запрос повторно (в т.ч. когда автор оказался пустым). + React.useEffect(() => { + if (types === undefined || visible === undefined) return undefined; + const need = visible.filter((r) => r.kind === 'approval' && !authors.current.has(rowKey(r))); + if (need.length === 0) return undefined; + + let cancelled = false; + hydratePendingAuthors(context, types, need) + .then(() => { + need.forEach((r) => + authors.current.set(rowKey(r), { name: r.authorName ?? '', email: r.authorEmail ?? '' }) + ); + if (!cancelled) setRows((prev) => (prev ? [...prev] : prev)); + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [context, types, visible]); return (
@@ -196,8 +256,8 @@ export const SearchView: React.FC = ({ context, page, onPageCh type="search" className={styles.search} placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…" - value={query} - onChange={(e) => setQuery(e.target.value)} + value={input} + onChange={(e) => setInput(e.target.value)} autoFocus /> @@ -210,7 +270,7 @@ export const SearchView: React.FC = ({ context, page, onPageCh )} {!error && hasQuery && rows && totalRows > 0 && ( <> - +
)} diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index fa24ad2..a284f12 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -973,11 +973,25 @@ export async function searchOwnAll( return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); } +/** + * ВЕСЬ результат поиска (без нарезки на страницы) — постраничность делает + * вызывающая сторона. + * + * Именно так, а не «страницу за вызов»: набор всё равно собирается целиком + * (двумя источниками + слияние + сортировка), и раньше 95% этой работы + * выбрасывалось ради 20 строк — а при переходе на следующую страницу весь поиск + * запускался заново. Теперь SearchView кеширует список по ЗАПРОСУ, и листание + * страниц не стоит ни одного запроса. + * + * Автор здесь НЕ дочитывается: у строк 'approval' он требует отдельного запроса, + * и делать его на весь набор (до KQL_ID_LIMIT) значило бы собирать $filter из + * сотен Id — SharePoint такой длины URL не переварит. Автора дочитывает + * вызывающая сторона для видимой страницы (hydratePendingAuthors). + */ export async function searchMyRequests( context: WebPartContext, types: IRequestType[], - query: string, - page: number + query: string ): Promise { const q = query.trim(); if (!q) return { rows: [], totalRows: 0 }; @@ -995,17 +1009,7 @@ export async function searchMyRequests( const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`)); const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created)); - const totalRows = merged.length; - const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - // Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с - // автором (loadByIds сам дочитывает Author). - const needAuthor = pageRows.filter((r) => r.kind === 'approval'); - if (needAuthor.length > 0) { - await hydratePendingAuthors(context, involvedIdx.types, needAuthor); - } - - return { rows: pageRows, totalRows }; + return { rows: merged, totalRows: merged.length }; } function toMyRequest( diff --git a/src/webparts/requestPortal/shared/routing.test.ts b/src/webparts/requestPortal/shared/routing.test.ts index 924c292..9a94f74 100644 --- a/src/webparts/requestPortal/shared/routing.test.ts +++ b/src/webparts/requestPortal/shared/routing.test.ts @@ -5,10 +5,12 @@ import { persistOwnStatus, persistOwnTab, persistPage, + persistQuery, routeUrl, storedOwnStatus, storedOwnTab, storedPageFor, + storedQueryFor, } from './routing'; // buildQuery/navigate читают и пишут window.location через history API, а @@ -23,7 +25,7 @@ beforeEach(() => { describe('parseRoute', () => { it('пустой адрес — подача заявки в разделе «Новая заявка»', () => { // page всегда определён (1 — значение по умолчанию), в адрес не пишется. - expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1 }); + expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1, query: '' }); }); it('rpnav задаёт раздел бокового меню', () => { @@ -45,6 +47,7 @@ describe('parseRoute', () => { formKey: 'hire', itemId: 42, page: 1, + query: '', }); }); @@ -59,6 +62,7 @@ describe('parseRoute', () => { formKey: 'hire', itemId: 7, page: 1, + query: '', }); }); @@ -252,6 +256,90 @@ describe('страница таблицы (rppage + persistPage)', () => { }); }); +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'); @@ -388,6 +476,7 @@ describe('navigate', () => { ownTab: 'created', ownStatus: undefined, page: 1, + query: '', }); }); diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 6793eac..d073317 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -97,6 +97,10 @@ const OWN_STATUS_STORAGE_KEY = 'rp-own-status'; // уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего // одна, пользователь попал бы в пустоту. const PAGE_STORAGE_PREFIX = 'rp-page:'; +// Текст поиска — тоже свой на каждую таблицу. Префикс НЕ меняем: под ним +// поисковые запросы уже лежат (раньше их хранил хук usePersistedQuery), и +// сохранённое у пользователей должно пережить переезд в маршрут. +const QUERY_STORAGE_PREFIX = 'rp-search-query:'; /** * Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в @@ -132,6 +136,34 @@ export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number { 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 недоступен (приватный режим и т.п.) — просто не сохраняем. + } +} + /** * Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута — * вызывающей стороне не нужно знать, как таблицы различаются между собой. @@ -248,22 +280,25 @@ export function parseRoute(search: string = window.location.search): IRoute { 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, ownTab, ownStatus, page }; + return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query }; } // Редактирование собственной незавершённой заявки — та же панель, но с формой. if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { - return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page }; + 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, ownTab, ownStatus, page }; + return { mode: 'submit', nav, ownTab, ownStatus, page, query }; } // Собрать query-строку маршрута, сохранив посторонние параметры страницы. @@ -277,6 +312,7 @@ function buildQuery(route: IRoute): string { params.delete('rptab'); params.delete('rpstatus'); params.delete('rppage'); + params.delete('rpq'); if (route.nav !== 'new') { params.set('rpnav', route.nav); @@ -290,6 +326,7 @@ function buildQuery(route: IRoute): string { } // Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку). 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); @@ -307,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)); } diff --git a/src/webparts/requestPortal/shared/usePersistedQuery.ts b/src/webparts/requestPortal/shared/usePersistedQuery.ts index 65dff92..9bdc937 100644 --- a/src/webparts/requestPortal/shared/usePersistedQuery.ts +++ b/src/webparts/requestPortal/shared/usePersistedQuery.ts @@ -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(() => readStored(storageKey)); - - // Компонент может продолжать жить, когда key поменялся: RequestsTableView — - // это один и тот же элемент для вкладок «На согласовании» и «Мои заявки», - // меняется только проп kind. Инициализатор useState при этом повторно НЕ - // срабатывает, поэтому без перечитывания текст из одной вкладки оставался бы - // на экране во второй и записывался уже под её ключ — выглядело бы так, будто - // ключ у них общий. - const prevKey = React.useRef(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(routeQuery); + const debounced = useDebouncedQuery(input); + const pushed = React.useRef(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]; +}