у поиска нормальный роутинг
This commit is contained in:
@@ -7,9 +7,11 @@ import {
|
|||||||
persistOwnTab,
|
persistOwnTab,
|
||||||
persistOwnStatus,
|
persistOwnStatus,
|
||||||
persistPage,
|
persistPage,
|
||||||
|
persistQuery,
|
||||||
storedOwnStatus,
|
storedOwnStatus,
|
||||||
storedOwnTab,
|
storedOwnTab,
|
||||||
storedPageFor,
|
storedPageFor,
|
||||||
|
storedQueryFor,
|
||||||
useRoute,
|
useRoute,
|
||||||
NavSection,
|
NavSection,
|
||||||
OwnTab,
|
OwnTab,
|
||||||
@@ -137,10 +139,16 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
ownTab,
|
ownTab,
|
||||||
ownStatus: storedOwnStatus(ownTab),
|
ownStatus: storedOwnStatus(ownTab),
|
||||||
page: storedPageFor('own', ownTab),
|
page: storedPageFor('own', ownTab),
|
||||||
|
query: storedQueryFor('own', ownTab) || undefined,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate({ mode: 'submit', nav, page: storedPageFor(nav) });
|
navigate({
|
||||||
|
mode: 'submit',
|
||||||
|
nav,
|
||||||
|
page: storedPageFor(nav),
|
||||||
|
query: storedQueryFor(nav) || undefined,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const gotoOwnTab = (ownTab: OwnTab): void => {
|
const gotoOwnTab = (ownTab: OwnTab): void => {
|
||||||
@@ -154,6 +162,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
ownTab,
|
ownTab,
|
||||||
ownStatus: storedOwnStatus(ownTab),
|
ownStatus: storedOwnStatus(ownTab),
|
||||||
page: storedPageFor('own', ownTab),
|
page: storedPageFor('own', ownTab),
|
||||||
|
query: storedQueryFor('own', ownTab) || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,7 +172,19 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
// Смена фильтра — другой набор заявок, поэтому явно с первой страницы
|
// Смена фильтра — другой набор заявок, поэтому явно с первой страницы
|
||||||
// (и запоминаем именно её, иначе вернувшись сюда попали бы на старую).
|
// (и запоминаем именно её, иначе вернувшись сюда попали бы на старую).
|
||||||
persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1);
|
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<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
persistPage(route, page);
|
persistPage(route, page);
|
||||||
navigate({ ...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<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
itemId: request.id,
|
itemId: request.id,
|
||||||
ownTab: route.ownTab,
|
ownTab: route.ownTab,
|
||||||
ownStatus: route.ownStatus,
|
ownStatus: route.ownStatus,
|
||||||
|
// Страница и запрос — часть того же списка под панелью: без них ссылка на
|
||||||
|
// открытую заявку вела бы на первую страницу без поиска.
|
||||||
|
page: route.page,
|
||||||
|
query: route.query,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Закрывая панель, возвращаемся ровно в тот список, из которого её открыли —
|
// Закрывая панель, возвращаемся ровно в тот список, из которого её открыли —
|
||||||
// включая подвкладку и фильтр «Моих заявок».
|
// включая подвкладку и фильтр «Моих заявок».
|
||||||
const closeDrawer = (): void =>
|
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<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
context={context}
|
context={context}
|
||||||
page={route.page ?? 1}
|
page={route.page ?? 1}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
query={route.query ?? ''}
|
||||||
|
onQueryChange={setQuery}
|
||||||
onOpen={openRequest}
|
onOpen={openRequest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -283,6 +329,8 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
onStatusFilterChange={setOwnStatus}
|
onStatusFilterChange={setOwnStatus}
|
||||||
page={route.page ?? 1}
|
page={route.page ?? 1}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
query={route.query ?? ''}
|
||||||
|
onQueryChange={setQuery}
|
||||||
onOpen={openRequest}
|
onOpen={openRequest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
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 { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import styles from './RequestsTableView.module.scss';
|
import styles from './RequestsTableView.module.scss';
|
||||||
@@ -172,6 +172,12 @@ export interface IRequestsTableViewProps {
|
|||||||
*/
|
*/
|
||||||
page: number;
|
page: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
/**
|
||||||
|
* Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает
|
||||||
|
* в адрес, переживает открытие заявки и запоминается для этой таблицы.
|
||||||
|
*/
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
}
|
}
|
||||||
@@ -291,18 +297,20 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
page,
|
page,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
onOpen,
|
onOpen,
|
||||||
}) => {
|
}) => {
|
||||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
const [query, setQuery] = usePersistedQuery(view);
|
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
|
|
||||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
|
||||||
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
|
// после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже
|
||||||
// иначе поиск бил бы запросом по каждой набранной букве.
|
// «отложенный», отдельный debounce здесь был бы вторым по счёту.
|
||||||
const debouncedQuery = useDebouncedQuery(query);
|
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
||||||
|
const debouncedQuery = query;
|
||||||
|
|
||||||
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
||||||
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
||||||
@@ -314,18 +322,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
|
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
|
||||||
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
const [loadedKey, setLoadedKey] = React.useState<string>('');
|
||||||
|
|
||||||
// Смена ЗАПРОСА сбрасывает страницу: набор результатов другой, и «страница 3»
|
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
|
||||||
// от прошлого запроса к нему отношения не имеет. Смену вкладки/фильтра здесь
|
// в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal).
|
||||||
// НЕ обрабатываем — там страница приходит уже правильной из маршрута
|
|
||||||
// (см. RequestPortal: gotoOwnTab/setOwnStatus). Сравниваем с предыдущим
|
|
||||||
// значением, а не просто вешаемся на изменение: иначе эффект сбрасывал бы
|
|
||||||
// страницу и на первом рендере, затирая ту, что пришла из адреса.
|
|
||||||
const prevQuery = React.useRef<string>(debouncedQuery);
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (prevQuery.current === debouncedQuery) return;
|
|
||||||
prevQuery.current = debouncedQuery;
|
|
||||||
if (page !== 1) onPageChange(1);
|
|
||||||
}, [debouncedQuery, page, onPageChange]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -491,8 +489,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
type="search"
|
type="search"
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
placeholder="Поиск по заявкам…"
|
placeholder="Поиск по заявкам…"
|
||||||
value={query}
|
value={input}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
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 { IMyRequest } from './models/IMyRequest';
|
||||||
import { IRequestType, StatusBadge } from '../../shared/approval';
|
import { IRequestType, StatusBadge } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
import { pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import styles from './SearchView.module.scss';
|
import styles from './SearchView.module.scss';
|
||||||
@@ -26,10 +26,29 @@ export interface ISearchViewProps {
|
|||||||
*/
|
*/
|
||||||
page: number;
|
page: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
/** Текст поиска и его смена — тоже из маршрута (см. page выше). */
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||||
onOpen: (request: IMyRequest) => void;
|
onOpen: (request: IMyRequest) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowKey = (r: IMyRequest): string => `${r.requestTypeId}-${r.id}`;
|
||||||
|
|
||||||
|
// Автор, уже дочитанный ранее (см. authors в SearchView), — проставляем его в
|
||||||
|
// свежий результат поиска до сравнения с кешем. Иначе фоновое обновление
|
||||||
|
// приносило бы строки без автора, слепок страницы отличался бы от кешированного
|
||||||
|
// на ровном месте, таблица моргала бы и автор загружался заново.
|
||||||
|
function applyKnownAuthors(rows: IMyRequest[], known: Map<string, { name: string; email: string }>): 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 {
|
function formatDate(iso: string): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -100,27 +119,33 @@ const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => voi
|
|||||||
// после решения согласующего) — но т.к. это по большей части Search, свежая
|
// после решения согласующего) — но т.к. это по большей части Search, свежая
|
||||||
// правка может проявиться не мгновенно (частично компенсируется довеском
|
// правка может проявиться не мгновенно (частично компенсируется довеском
|
||||||
// последних 10 минут через REST — см. myRequests.service.ts).
|
// последних 10 минут через REST — см. myRequests.service.ts).
|
||||||
export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageChange, onOpen }) => {
|
export const SearchView: React.FC<ISearchViewProps> = ({
|
||||||
|
context,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
onOpen,
|
||||||
|
}) => {
|
||||||
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
|
||||||
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
|
||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
const [query, setQuery] = usePersistedQuery('search');
|
|
||||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
|
||||||
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
|
|
||||||
// ходит сразу по двум источникам (свои заявки + где участвую), то есть
|
|
||||||
// каждая набранная буква стоила бы двух серий запросов.
|
|
||||||
const debouncedQuery = useDebouncedQuery(query);
|
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
|
|
||||||
// Новый запрос — новый набор результатов, страница от прошлого к нему не
|
// Дочитанные авторы, ключ — requestTypeId-id. Живёт между запросами намеренно:
|
||||||
// относится. Сравниваем с предыдущим значением, чтобы не сбросить страницу,
|
// ключ глобально уникален, так что при возврате к прежнему поиску автор уже
|
||||||
// пришедшую из адреса, на первом же рендере (см. RequestsTableView).
|
// известен и запрос не повторяется.
|
||||||
const prevQuery = React.useRef<string>(debouncedQuery);
|
const authors = React.useRef<Map<string, { name: string; email: string }>>(new Map());
|
||||||
React.useEffect(() => {
|
|
||||||
if (prevQuery.current === debouncedQuery) return;
|
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
|
||||||
prevQuery.current = debouncedQuery;
|
// после паузы в наборе. Здесь пауза особенно важна: поиск ходит сразу по двум
|
||||||
if (page !== 1) onPageChange(1);
|
// источникам (свои заявки + где участвую), то есть каждая набранная буква
|
||||||
}, [debouncedQuery, page, onPageChange]);
|
// стоила бы двух серий запросов.
|
||||||
|
const [input, setInput] = useRoutedQuery(query, onQueryChange);
|
||||||
|
const debouncedQuery = query;
|
||||||
|
|
||||||
|
// Страницу при смене запроса здесь НЕ сбрасываем — это делает setQuery в
|
||||||
|
// RequestPortal одним переходом вместе с самим запросом.
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -136,6 +161,11 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageCh
|
|||||||
};
|
};
|
||||||
}, [context]);
|
}, [context]);
|
||||||
|
|
||||||
|
// Поиск зависит ТОЛЬКО от запроса, не от страницы: результат приходит целиком
|
||||||
|
// и кешируется целиком, а страницы нарезаются ниже, в рендере. Раньше страница
|
||||||
|
// входила в ключ кеша — и каждое листание было промахом, который заново гонял
|
||||||
|
// обе серии запросов (свои + где участвую) и показывал «Ищем…», хотя все эти
|
||||||
|
// строки уже были получены при загрузке первой страницы.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (types === undefined) return;
|
if (types === undefined) return;
|
||||||
if (debouncedQuery.trim().length === 0) {
|
if (debouncedQuery.trim().length === 0) {
|
||||||
@@ -144,7 +174,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageCh
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const cacheKey = pageKey('search', [debouncedQuery.trim(), page]);
|
const cacheKey = pageKey('search', [debouncedQuery.trim()]);
|
||||||
|
|
||||||
// Уже искали это раньше — показываем прежний результат сразу, обновление
|
// Уже искали это раньше — показываем прежний результат сразу, обновление
|
||||||
// идёт фоном (см. RequestsTableView, там та же схема и те же причины).
|
// идёт фоном (см. RequestsTableView, там та же схема и те же причины).
|
||||||
@@ -154,8 +184,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageCh
|
|||||||
|
|
||||||
const load = async (): Promise<void> => {
|
const load = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const fresh = await searchMyRequests(context, types, debouncedQuery, page);
|
const fresh = await searchMyRequests(context, types, debouncedQuery);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
applyKnownAuthors(fresh.rows, authors.current);
|
||||||
const prev = readPage(cacheKey);
|
const prev = readPage(cacheKey);
|
||||||
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
|
||||||
writePage(cacheKey, fresh);
|
writePage(cacheKey, fresh);
|
||||||
@@ -180,10 +211,39 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageCh
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [context, types, debouncedQuery, page]);
|
}, [context, types, debouncedQuery]);
|
||||||
|
|
||||||
const hasQuery = debouncedQuery.trim().length > 0;
|
const hasQuery = debouncedQuery.trim().length > 0;
|
||||||
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
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 (
|
return (
|
||||||
<section className={styles.view}>
|
<section className={styles.view}>
|
||||||
@@ -196,8 +256,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageCh
|
|||||||
type="search"
|
type="search"
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
|
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
|
||||||
value={query}
|
value={input}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +270,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, page, onPageCh
|
|||||||
)}
|
)}
|
||||||
{!error && hasQuery && rows && totalRows > 0 && (
|
{!error && hasQuery && rows && totalRows > 0 && (
|
||||||
<>
|
<>
|
||||||
<Table rows={rows} onOpen={onOpen} />
|
<Table rows={visible ?? []} onOpen={onOpen} />
|
||||||
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
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(
|
export async function searchMyRequests(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
query: string,
|
query: string
|
||||||
page: number
|
|
||||||
): Promise<ISearchPage> {
|
): Promise<ISearchPage> {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return { rows: [], totalRows: 0 };
|
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 involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
|
||||||
|
|
||||||
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
|
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
|
||||||
const totalRows = merged.length;
|
return { rows: merged, 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMyRequest(
|
function toMyRequest(
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
persistOwnStatus,
|
persistOwnStatus,
|
||||||
persistOwnTab,
|
persistOwnTab,
|
||||||
persistPage,
|
persistPage,
|
||||||
|
persistQuery,
|
||||||
routeUrl,
|
routeUrl,
|
||||||
storedOwnStatus,
|
storedOwnStatus,
|
||||||
storedOwnTab,
|
storedOwnTab,
|
||||||
storedPageFor,
|
storedPageFor,
|
||||||
|
storedQueryFor,
|
||||||
} from './routing';
|
} from './routing';
|
||||||
|
|
||||||
// buildQuery/navigate читают и пишут window.location через history API, а
|
// buildQuery/navigate читают и пишут window.location через history API, а
|
||||||
@@ -23,7 +25,7 @@ beforeEach(() => {
|
|||||||
describe('parseRoute', () => {
|
describe('parseRoute', () => {
|
||||||
it('пустой адрес — подача заявки в разделе «Новая заявка»', () => {
|
it('пустой адрес — подача заявки в разделе «Новая заявка»', () => {
|
||||||
// page всегда определён (1 — значение по умолчанию), в адрес не пишется.
|
// page всегда определён (1 — значение по умолчанию), в адрес не пишется.
|
||||||
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1 });
|
expect(parseRoute('')).toEqual({ mode: 'submit', nav: 'new', page: 1, query: '' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rpnav задаёт раздел бокового меню', () => {
|
it('rpnav задаёт раздел бокового меню', () => {
|
||||||
@@ -45,6 +47,7 @@ describe('parseRoute', () => {
|
|||||||
formKey: 'hire',
|
formKey: 'hire',
|
||||||
itemId: 42,
|
itemId: 42,
|
||||||
page: 1,
|
page: 1,
|
||||||
|
query: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ describe('parseRoute', () => {
|
|||||||
formKey: 'hire',
|
formKey: 'hire',
|
||||||
itemId: 7,
|
itemId: 7,
|
||||||
page: 1,
|
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 — запоминание подвкладки/фильтра между визитами', () => {
|
describe('persistOwnTab/persistOwnStatus — запоминание подвкладки/фильтра между визитами', () => {
|
||||||
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
it('без явного rptab в адресе подставляется запомненная подвкладка', () => {
|
||||||
persistOwnTab('rejected');
|
persistOwnTab('rejected');
|
||||||
@@ -388,6 +476,7 @@ describe('navigate', () => {
|
|||||||
ownTab: 'created',
|
ownTab: 'created',
|
||||||
ownStatus: undefined,
|
ownStatus: undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
|
query: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ const OWN_STATUS_STORAGE_KEY = 'rp-own-status';
|
|||||||
// уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего
|
// уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего
|
||||||
// одна, пользователь попал бы в пустоту.
|
// одна, пользователь попал бы в пустоту.
|
||||||
const PAGE_STORAGE_PREFIX = 'rp-page:';
|
const PAGE_STORAGE_PREFIX = 'rp-page:';
|
||||||
|
// Текст поиска — тоже свой на каждую таблицу. Префикс НЕ меняем: под ним
|
||||||
|
// поисковые запросы уже лежат (раньше их хранил хук usePersistedQuery), и
|
||||||
|
// сохранённое у пользователей должно пережить переезд в маршрут.
|
||||||
|
const QUERY_STORAGE_PREFIX = 'rp-search-query:';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в
|
* Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в
|
||||||
@@ -132,6 +136,34 @@ export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number {
|
|||||||
return readStoredPage(viewKeyOf(nav, tab)) ?? 1;
|
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 =
|
const page =
|
||||||
Number.isInteger(pageRaw) && pageRaw > 1 ? pageRaw : readStoredPage(viewKeyOf(nav, ownTab)) ?? 1;
|
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)) {
|
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)) {
|
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)) {
|
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||||
}
|
}
|
||||||
return { mode: 'submit', nav, ownTab, ownStatus, page };
|
return { mode: 'submit', nav, ownTab, ownStatus, page, query };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
|
||||||
@@ -277,6 +312,7 @@ function buildQuery(route: IRoute): string {
|
|||||||
params.delete('rptab');
|
params.delete('rptab');
|
||||||
params.delete('rpstatus');
|
params.delete('rpstatus');
|
||||||
params.delete('rppage');
|
params.delete('rppage');
|
||||||
|
params.delete('rpq');
|
||||||
|
|
||||||
if (route.nav !== 'new') {
|
if (route.nav !== 'new') {
|
||||||
params.set('rpnav', route.nav);
|
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.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) {
|
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
|
||||||
params.set('rpmode', route.mode);
|
params.set('rpmode', route.mode);
|
||||||
params.set('rptype', route.formKey);
|
params.set('rptype', route.formKey);
|
||||||
@@ -307,9 +344,19 @@ function buildQuery(route: IRoute): string {
|
|||||||
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
|
* Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет
|
||||||
* query-параметры адресной строки через history.pushState и оповещает
|
* query-параметры адресной строки через history.pushState и оповещает
|
||||||
* useRoute() своим событием (popstate на pushState сам не срабатывает).
|
* useRoute() своим событием (popstate на pushState сам не срабатывает).
|
||||||
|
*
|
||||||
|
* replace=true заменяет текущую запись истории вместо добавления новой —
|
||||||
|
* для набора текста в поиске: иначе каждая буква становилась бы отдельным
|
||||||
|
* шагом «назад», и чтобы уйти со страницы, пришлось бы стереть весь запрос
|
||||||
|
* по одному нажатию.
|
||||||
*/
|
*/
|
||||||
export function navigate(route: IRoute): void {
|
export function navigate(route: IRoute, options?: { replace?: boolean }): void {
|
||||||
window.history.pushState({}, '', buildQuery(route));
|
const url = buildQuery(route);
|
||||||
|
if (options?.replace) {
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
}
|
||||||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,5 @@
|
|||||||
import * as React from 'react';
|
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 полных циклов
|
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
|
||||||
// (Search + дочитывание через REST по каждому типу заявок), из которых нужен
|
// (Search + дочитывание через REST по каждому типу заявок), из которых нужен
|
||||||
@@ -74,3 +25,56 @@ export function useDebouncedQuery(value: string, delayMs: number = SEARCH_DEBOUN
|
|||||||
|
|
||||||
return debounced;
|
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