у поиска нормальный роутинг
This commit is contained in:
@@ -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: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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