наконецто работает поиск верно
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
|||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
import { usePersistedQuery } from '../../shared/usePersistedQuery';
|
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import styles from './RequestsTableView.module.scss';
|
import styles from './RequestsTableView.module.scss';
|
||||||
|
|
||||||
@@ -153,8 +153,12 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const [page, setPage] = React.useState<number>(1);
|
const [page, setPage] = React.useState<number>(1);
|
||||||
const [query, setQuery] = usePersistedQuery(kind);
|
const [query, setQuery] = usePersistedQuery(kind);
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
|
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||||
|
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
|
||||||
|
// иначе поиск бил бы запросом по каждой набранной букве.
|
||||||
|
const debouncedQuery = useDebouncedQuery(query);
|
||||||
|
|
||||||
React.useEffect(() => setPage(1), [query, kind]);
|
React.useEffect(() => setPage(1), [debouncedQuery, kind]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -177,9 +181,9 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const load = async (): Promise<void> => {
|
const load = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
if (kind === 'own') {
|
if (kind === 'own') {
|
||||||
const hasOwnQuery = query.trim().length > 0;
|
const hasOwnQuery = debouncedQuery.trim().length > 0;
|
||||||
const r = hasOwnQuery
|
const r = hasOwnQuery
|
||||||
? await searchOwnRequests(context, types, query, page)
|
? await searchOwnRequests(context, types, debouncedQuery, page)
|
||||||
: await getOwnRequests(context, types, page);
|
: await getOwnRequests(context, types, page);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRows(r.rows);
|
setRows(r.rows);
|
||||||
@@ -188,7 +192,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasQuery = query.trim().length > 0;
|
const hasQuery = debouncedQuery.trim().length > 0;
|
||||||
|
|
||||||
if (!hasQuery) {
|
if (!hasQuery) {
|
||||||
// Без поиска — страница уже нарезана и дочитана сервером.
|
// Без поиска — страница уже нарезана и дочитана сервером.
|
||||||
@@ -208,7 +212,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const idx = await getPendingApprovalIndex(context, 'involved');
|
const idx = await getPendingApprovalIndex(context, 'involved');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
console.table(idx.debug);
|
console.table(idx.debug);
|
||||||
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, query);
|
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
await hydratePendingAuthors(context, idx.types, pageRows);
|
await hydratePendingAuthors(context, idx.types, pageRows);
|
||||||
@@ -229,10 +233,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [context, types, kind, query, page]);
|
}, [context, types, kind, debouncedQuery, page]);
|
||||||
|
|
||||||
// Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
|
// Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
|
||||||
const q = query.trim();
|
const q = debouncedQuery.trim();
|
||||||
const total = totalRows;
|
const total = totalRows;
|
||||||
const visible = rows || [];
|
const visible = rows || [];
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
@@ -260,7 +264,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
{!error && rows && total === 0 && (
|
{!error && rows && total === 0 && (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
{q
|
{q
|
||||||
? `По запросу «${query}» ничего не найдено.`
|
? `По запросу «${debouncedQuery}» ничего не найдено.`
|
||||||
: kind === 'approvals'
|
: kind === 'approvals'
|
||||||
? 'Нет заявок, ждущих вашего решения.'
|
? 'Нет заявок, ждущих вашего решения.'
|
||||||
: 'Вы ещё не создавали заявок.'}
|
: 'Вы ещё не создавали заявок.'}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getLinkedTypes, searchMyRequests } from './myRequests.service';
|
|||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||||
import { usePersistedQuery } from '../../shared/usePersistedQuery';
|
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import styles from './SearchView.module.scss';
|
import styles from './SearchView.module.scss';
|
||||||
|
|
||||||
@@ -109,9 +109,14 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
const [totalRows, setTotalRows] = React.useState<number>(0);
|
const [totalRows, setTotalRows] = React.useState<number>(0);
|
||||||
const [page, setPage] = React.useState<number>(1);
|
const [page, setPage] = React.useState<number>(1);
|
||||||
const [query, setQuery] = usePersistedQuery('search');
|
const [query, setQuery] = usePersistedQuery('search');
|
||||||
|
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||||
|
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
|
||||||
|
// ходит сразу по двум источникам (свои заявки + где участвую), то есть
|
||||||
|
// каждая набранная буква стоила бы двух серий запросов.
|
||||||
|
const debouncedQuery = useDebouncedQuery(query);
|
||||||
const [error, setError] = React.useState<string>('');
|
const [error, setError] = React.useState<string>('');
|
||||||
|
|
||||||
React.useEffect(() => setPage(1), [query]);
|
React.useEffect(() => setPage(1), [debouncedQuery]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -129,7 +134,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (types === undefined) return;
|
if (types === undefined) return;
|
||||||
if (query.trim().length === 0) {
|
if (debouncedQuery.trim().length === 0) {
|
||||||
setRows(undefined);
|
setRows(undefined);
|
||||||
setTotalRows(0);
|
setTotalRows(0);
|
||||||
return;
|
return;
|
||||||
@@ -138,7 +143,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
|
|
||||||
const load = async (): Promise<void> => {
|
const load = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const r = await searchMyRequests(context, types, query, page);
|
const r = await searchMyRequests(context, types, debouncedQuery, page);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRows(r.rows);
|
setRows(r.rows);
|
||||||
setTotalRows(r.totalRows);
|
setTotalRows(r.totalRows);
|
||||||
@@ -156,9 +161,9 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [context, types, query, page]);
|
}, [context, types, debouncedQuery, page]);
|
||||||
|
|
||||||
const hasQuery = query.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));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -182,7 +187,7 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
|
|||||||
{!error && !hasQuery && <div className={styles.empty}>Введите текст, чтобы начать поиск.</div>}
|
{!error && !hasQuery && <div className={styles.empty}>Введите текст, чтобы начать поиск.</div>}
|
||||||
{!error && hasQuery && rows === undefined && <div className={styles.empty}>Ищем…</div>}
|
{!error && hasQuery && rows === undefined && <div className={styles.empty}>Ищем…</div>}
|
||||||
{!error && hasQuery && rows && totalRows === 0 && (
|
{!error && hasQuery && rows && totalRows === 0 && (
|
||||||
<div className={styles.empty}>По запросу «{query}» ничего не найдено.</div>
|
<div className={styles.empty}>По запросу «{debouncedQuery}» ничего не найдено.</div>
|
||||||
)}
|
)}
|
||||||
{!error && hasQuery && rows && totalRows > 0 && (
|
{!error && hasQuery && rows && totalRows > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spfi, SPFx } from '@pnp/sp';
|
import { spForSite } from '../../shared/pnp/pnpjs';
|
||||||
import '@pnp/sp/webs';
|
import '@pnp/sp/webs';
|
||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
import { IList } from '@pnp/sp/lists';
|
import { IList } from '@pnp/sp/lists';
|
||||||
@@ -28,6 +28,15 @@ const RECENT_MS = 10 * 60 * 1000;
|
|||||||
// лимита пишем предупреждение в лог, а не молча обрезаем.
|
// лимита пишем предупреждение в лог, а не молча обрезаем.
|
||||||
const SEARCH_ROW_LIMIT = 500;
|
const SEARCH_ROW_LIMIT = 500;
|
||||||
|
|
||||||
|
// Managed properties списка согласования. Имена включают группу колонок сайта
|
||||||
|
// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
|
||||||
|
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
|
||||||
|
// Суффикс — тип исходного поля: OWSUSER для Person/Group.
|
||||||
|
const MP_APPROVER = 'TansuSiteApproverOWSUSER';
|
||||||
|
// Статус строки маршрута — Choice-поле, отсюда суффикс OWSCHCS (подтверждено
|
||||||
|
// отладочным перебором 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER).
|
||||||
|
const MP_STATUS = 'RequestStatusOWSCHCS';
|
||||||
|
|
||||||
export function currentUserEmail(context: WebPartContext): string {
|
export function currentUserEmail(context: WebPartContext): string {
|
||||||
return (context.pageContext.user.email || '').toLowerCase();
|
return (context.pageContext.user.email || '').toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -158,7 +167,7 @@ async function indexApprovalsForType(
|
|||||||
try {
|
try {
|
||||||
if (!tokenEmail) return { rows: [], debug };
|
if (!tokenEmail) return { rows: [], debug };
|
||||||
|
|
||||||
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
const list = sp.web.getList(type.listId as string);
|
const list = sp.web.getList(type.listId as string);
|
||||||
|
|
||||||
const dataListInfo = await list.select('Title')();
|
const dataListInfo = await list.select('Title')();
|
||||||
@@ -171,15 +180,16 @@ async function indexApprovalsForType(
|
|||||||
debug.approvalListTitle = approvalListTitle;
|
debug.approvalListTitle = approvalListTitle;
|
||||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||||
|
|
||||||
// Approver/EMail eq '...' в $filter по Person-полю ненадёжен в SharePoint
|
// TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в
|
||||||
// REST — резолвим email в числовой Id пользователя (как и везде в проекте,
|
// SharePoint REST — резолвим email в числовой Id пользователя (как и везде в
|
||||||
// см. ensureUser) и фильтруем по ApproverId.
|
// проекте, см. ensureUser) и фильтруем по TansuSiteApproverId.
|
||||||
//
|
//
|
||||||
// Status в $filter НЕ используем: подтверждено на практике — фильтр
|
// RequestStatus в $filter НЕ используем: подтверждено на практике — фильтр
|
||||||
// `Status eq 'На согласовании'` (кириллица в $filter) молча возвращает 0
|
// вида `RequestStatus eq 'На согласовании'` (кириллица в $filter) молча
|
||||||
// строк, даже когда без него те же строки находятся с этим же значением
|
// возвращает 0 строк, даже когда без него те же строки находятся с этим же
|
||||||
// Status. Поэтому фильтруем только по ApproverId (числовое значение,
|
// значением статуса. Поэтому фильтруем только по TansuSiteApproverId
|
||||||
// проблем нет), а Status сравниваем в JS после получения строк.
|
// (числовое значение, проблем нет), а статус сравниваем в JS после
|
||||||
|
// получения строк.
|
||||||
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`;
|
||||||
const approver = await sp.web.ensureUser(claimsLogin);
|
const approver = await sp.web.ensureUser(claimsLogin);
|
||||||
|
|
||||||
@@ -195,16 +205,23 @@ async function indexApprovalsForType(
|
|||||||
// точное, даже когда список кандидатов пришёл из Search.
|
// точное, даже когда список кандидатов пришёл из Search.
|
||||||
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
const recentRows = (await approvalList.items
|
const recentRows = (await approvalList.items
|
||||||
.filter(`ApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
|
.filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`)
|
||||||
.select('Id', 'TicketId', 'Status')
|
.select('Id', 'TicketId', 'RequestStatus')
|
||||||
.top(2000)()) as Array<Record<string, unknown>>;
|
.top(2000)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList);
|
// Статусы уходят в KQL: сервер сразу отдаёт только активные строки,
|
||||||
|
// а не всё, где пользователь есть в маршруте.
|
||||||
|
const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, [
|
||||||
|
REQUEST_STATUS.onApproval,
|
||||||
|
REQUEST_STATUS.onExecution,
|
||||||
|
]);
|
||||||
const seenIds = new Set(recentRows.map((r) => r.Id));
|
const seenIds = new Set(recentRows.map((r) => r.Id));
|
||||||
const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id)));
|
||||||
|
|
||||||
stepRows = allRows.filter(
|
stepRows = allRows.filter(
|
||||||
(r) => r.Status === REQUEST_STATUS.onApproval || r.Status === REQUEST_STATUS.onExecution
|
(r) =>
|
||||||
|
r.RequestStatus === REQUEST_STATUS.onApproval ||
|
||||||
|
r.RequestStatus === REQUEST_STATUS.onExecution
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}`
|
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}`
|
||||||
@@ -224,7 +241,7 @@ async function indexApprovalsForType(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status })));
|
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, RequestStatus: r.RequestStatus })));
|
||||||
debug.approvalRowCount = stepRows.length;
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
|
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
|
||||||
@@ -259,35 +276,38 @@ async function indexApprovalsForType(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'involved' — строки согласования текущего пользователя через Search, чтобы не
|
// Строки согласования текущего пользователя через Search, чтобы не сканировать
|
||||||
// сканировать список целиком (он растёт с каждой заявкой).
|
// список целиком (он растёт с каждой заявкой).
|
||||||
//
|
//
|
||||||
// Ищем ОТОБРАЖАЕМЫМ ИМЕНЕМ СВОБОДНЫМ ТЕРМОМ, а не через managed property
|
// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное
|
||||||
// ApproverOWSUSER. Подтверждено диагностикой 2026-08-05 в этом тенанте:
|
// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого»
|
||||||
// ApproverOWSUSER отдаёт null у всех строк (автосозданного свойства не было —
|
// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго
|
||||||
// нашлось только чужое ApprooverOWSUSER с опечаткой; созданное вручную и
|
// возвращали 0.
|
||||||
// смапленное на ows_Approver так и не наполнилось), а вот свободный терм
|
//
|
||||||
// `"Павел Горбенко"` находит именно свои 12 строк из 27. То есть Person-поле в
|
// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима
|
||||||
// полнотекстовый индекс попадает как текст, просто через managed property не
|
// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР:
|
||||||
// достаётся.
|
// без него в выдачу попали бы все строки, где пользователь вообще есть в
|
||||||
|
// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры.
|
||||||
|
// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота).
|
||||||
//
|
//
|
||||||
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём
|
// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём
|
||||||
// свежими через REST по этим Id, а не из индекса (индекс мог устареть).
|
// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на
|
||||||
|
// устаревшем статусе решение «моя очередь» строить нельзя).
|
||||||
async function getInvolvedRowsViaSearch(
|
async function getInvolvedRowsViaSearch(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
type: IRequestType,
|
type: IRequestType,
|
||||||
approvalList: IList
|
approvalList: IList,
|
||||||
|
statuses: string[] = []
|
||||||
): Promise<Array<Record<string, unknown>>> {
|
): Promise<Array<Record<string, unknown>>> {
|
||||||
try {
|
try {
|
||||||
// Кавычки сломали бы фразу в KQL — убираем (в ФИО их быть не должно, но
|
const email = currentUserEmail(context);
|
||||||
// значение приходит извне, поэтому не доверяем).
|
if (!email) {
|
||||||
const displayName = (context.pageContext.user.displayName || '').replace(/"/g, ' ').trim();
|
|
||||||
if (!displayName) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Текущие заявки] «${type.name}»: у текущего пользователя нет отображаемого имени — Search по согласующему невозможен`
|
`[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен`
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const claimsLogin = `i:0#.f|membership|${email}`;
|
||||||
|
|
||||||
const rootFolder = (await approvalList
|
const rootFolder = (await approvalList
|
||||||
.select('RootFolder/ServerRelativeUrl')
|
.select('RootFolder/ServerRelativeUrl')
|
||||||
@@ -296,8 +316,13 @@ async function getInvolvedRowsViaSearch(
|
|||||||
if (!relUrl) return [];
|
if (!relUrl) return [];
|
||||||
const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`;
|
const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`;
|
||||||
|
|
||||||
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
const kql = `Path:"${approvalListAbsUrl}*" AND "${displayName}"`;
|
const pathClause = `Path:"${approvalListAbsUrl}*"`;
|
||||||
|
const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`];
|
||||||
|
if (statuses.length > 0) {
|
||||||
|
clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`);
|
||||||
|
}
|
||||||
|
const kql = clauses.join(' AND ');
|
||||||
|
|
||||||
const results = await sp.search({
|
const results = await sp.search({
|
||||||
Querytext: kql,
|
Querytext: kql,
|
||||||
@@ -314,6 +339,7 @@ async function getInvolvedRowsViaSearch(
|
|||||||
console.log(
|
console.log(
|
||||||
`[Текущие заявки] Search «${type.name}» (involved): KQL = ${kql}, найдено: ${total}, годных Id: ${ids.length}`
|
`[Текущие заявки] Search «${type.name}» (involved): KQL = ${kql}, найдено: ${total}, годных Id: ${ids.length}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (total > SEARCH_ROW_LIMIT) {
|
if (total > SEARCH_ROW_LIMIT) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
|
`[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку`
|
||||||
@@ -323,7 +349,7 @@ async function getInvolvedRowsViaSearch(
|
|||||||
|
|
||||||
return (await approvalList.items
|
return (await approvalList.items
|
||||||
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
.select('Id', 'TicketId', 'Status')
|
.select('Id', 'TicketId', 'RequestStatus')
|
||||||
.top(ids.length)()) as Array<Record<string, unknown>>;
|
.top(ids.length)()) as Array<Record<string, unknown>>;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -360,7 +386,7 @@ async function hydrateAuthors(
|
|||||||
const type = typeById.get(requestTypeId);
|
const type = typeById.get(requestTypeId);
|
||||||
if (!type) return;
|
if (!type) return;
|
||||||
try {
|
try {
|
||||||
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
const authorRows = (await sp.web
|
const authorRows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(type.listId as string)
|
||||||
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
@@ -462,7 +488,7 @@ async function loadByIds(
|
|||||||
const byKey = new Map<string, IMyRequest>();
|
const byKey = new Map<string, IMyRequest>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(idsByType.entries()).map(async ([type, ids]) => {
|
Array.from(idsByType.entries()).map(async ([type, ids]) => {
|
||||||
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
const rows = (await sp.web
|
const rows = (await sp.web
|
||||||
.getList(type.listId as string)
|
.getList(type.listId as string)
|
||||||
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
.items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
|
||||||
@@ -483,7 +509,7 @@ async function loadByIds(
|
|||||||
// матчить название не нужно, отдаём как есть.
|
// матчить название не нужно, отдаём как есть.
|
||||||
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise<IMyRequest[]> {
|
||||||
try {
|
try {
|
||||||
const sp = spfi(type.siteUrl as string).using(SPFx(context));
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
const me = await sp.web.currentUser.select('Id')();
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
|
||||||
|
|
||||||
@@ -550,7 +576,7 @@ export async function searchOwnRequests(
|
|||||||
): Promise<IOwnRequestsPage> {
|
): Promise<IOwnRequestsPage> {
|
||||||
if (types.length === 0) return { rows: [], totalRows: 0 };
|
if (types.length === 0) return { rows: [], totalRows: 0 };
|
||||||
|
|
||||||
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
|
const sp = spForSite(types[0].siteUrl as string, context);
|
||||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||||
|
|
||||||
const clauses = [
|
const clauses = [
|
||||||
@@ -606,7 +632,7 @@ export async function filterPendingByQuery(
|
|||||||
const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id));
|
const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id));
|
||||||
if (scopeTypes.length === 0) return [];
|
if (scopeTypes.length === 0) return [];
|
||||||
|
|
||||||
const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context));
|
const sp = spForSite(scopeTypes[0].siteUrl as string, context);
|
||||||
const kql = [
|
const kql = [
|
||||||
`(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
`(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
||||||
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
|
`(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`,
|
||||||
@@ -654,7 +680,7 @@ async function searchOwnAll(
|
|||||||
): Promise<IMyRequest[]> {
|
): Promise<IMyRequest[]> {
|
||||||
if (types.length === 0) return [];
|
if (types.length === 0) return [];
|
||||||
|
|
||||||
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
|
const sp = spForSite(types[0].siteUrl as string, context);
|
||||||
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
|
||||||
const clauses = [
|
const clauses = [
|
||||||
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
||||||
|
//
|
||||||
|
// decisionSeq входит в reloadKey: после решения согласующего строки маршрута
|
||||||
|
// меняются на сервере (своя — на «Согласовано», следующая — на «На
|
||||||
|
// согласовании»), поэтому счётчик инкрементируется и useMatrixEditor
|
||||||
|
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
|
||||||
|
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const approval = useMemo(() => hireApprovalService(), []);
|
const approval = useMemo(() => hireApprovalService(), []);
|
||||||
const loadApprovers = useCallback(
|
const loadApprovers = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -58,7 +65,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||||
[approval, itemId]
|
[approval, itemId]
|
||||||
);
|
);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
@@ -68,7 +75,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||||
[approval, itemId]
|
[approval, itemId]
|
||||||
);
|
);
|
||||||
const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) =>
|
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -161,7 +168,14 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel service={approverService} requestId={itemId} />
|
<DecisionPanel
|
||||||
|
service={approverService}
|
||||||
|
requestId={itemId}
|
||||||
|
onDecided={(newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"dataListTitle": "Прием на работу — согласование",
|
"dataListTitle": "Прием на работу — согласование",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] },
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] },
|
||||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
|
||||||
|
//
|
||||||
|
// decisionSeq входит в reloadKey: после решения согласующего строки маршрута
|
||||||
|
// меняются на сервере (своя — на «Согласовано», следующая — на «На
|
||||||
|
// согласовании»), поэтому счётчик инкрементируется и useMatrixEditor
|
||||||
|
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
|
||||||
|
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
const approval = useMemo(() => workplaceApprovalService(), []);
|
const approval = useMemo(() => workplaceApprovalService(), []);
|
||||||
const loadApprovers = useCallback(
|
const loadApprovers = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -47,7 +54,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||||
[approval, itemId]
|
[approval, itemId]
|
||||||
);
|
);
|
||||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) =>
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
@@ -57,7 +64,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||||
[approval, itemId]
|
[approval, itemId]
|
||||||
);
|
);
|
||||||
const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) =>
|
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -138,7 +145,14 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<DecisionPanel service={approverService} requestId={itemId} />
|
<DecisionPanel
|
||||||
|
service={approverService}
|
||||||
|
requestId={itemId}
|
||||||
|
onDecided={(newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"dataListTitle": "Организация рабочего места — согласование",
|
"dataListTitle": "Организация рабочего места — согласование",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] },
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] },
|
||||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
|||||||
|
|
||||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||||
const FIELDS = {
|
const FIELDS = {
|
||||||
approver: 'Approver', // «Согласующий» (User) → ApproverId
|
// Внутренние имена с префиксом группы колонок сайта (TansuSite) и «Request» —
|
||||||
status: 'Status', // «Статус» (Choice)
|
// так они заведены в SharePoint. Из них SharePoint выводит имена managed
|
||||||
|
// properties для поиска: TansuSiteApproverOWSUSER и RequestStatusOWSCHCS
|
||||||
|
// (см. MP_APPROVER/MP_STATUS в features/myRequests/myRequests.service.ts).
|
||||||
|
approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId
|
||||||
|
status: 'RequestStatus', // «Статус» (Choice)
|
||||||
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
||||||
date: 'Date', // «Дата» (DateTime)
|
date: 'Date', // «Дата» (DateTime)
|
||||||
comment: 'Comment', // «Комментарий» (Text)
|
comment: 'Comment', // «Комментарий» (Text)
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ import '@pnp/sp/lists';
|
|||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/attachments';
|
import '@pnp/sp/attachments';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
|
import { BrowserFetchWithRetry } from '@pnp/queryable';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { DATA_SITE_URL } from '../config';
|
import { DATA_SITE_URL } from '../config';
|
||||||
|
|
||||||
|
// Мягкая ретрай-политика вместо стандартной PnPjs (retries: 3, interval: 200).
|
||||||
|
// Дефолт слишком напорист: при отказе (429 «слишком много запросов», 503) он
|
||||||
|
// начинает повторять почти сразу, что при массовой загрузке только усугубляет
|
||||||
|
// throttling со стороны SharePoint. Здесь меньше попыток и заметно длиннее
|
||||||
|
// первая пауза; интервал в PnPjs удваивается с каждой попыткой, так что выходит
|
||||||
|
// примерно 1.5с → 3с.
|
||||||
|
const RETRY = { retries: 2, interval: 1500 };
|
||||||
|
|
||||||
// Единый инициализированный клиент PnPjs на весь web part.
|
// Единый инициализированный клиент PnPjs на весь web part.
|
||||||
let _sp: SPFI | undefined;
|
let _sp: SPFI | undefined;
|
||||||
|
|
||||||
@@ -16,7 +25,18 @@ let _sp: SPFI | undefined;
|
|||||||
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
||||||
*/
|
*/
|
||||||
export function initSP(context: WebPartContext): void {
|
export function initSP(context: WebPartContext): void {
|
||||||
_sp = spfi(DATA_SITE_URL).using(SPFx(context));
|
_sp = spfi(DATA_SITE_URL).using(SPFx(context), BrowserFetchWithRetry(RETRY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клиент для ПРОИЗВОЛЬНОГО сайта (списки типов заявок живут на разных сайтах,
|
||||||
|
* см. approval-route API). В отличие от getSP() не кэшируется — адрес сайта
|
||||||
|
* приходит из данных. Ретрай-политика та же, что и у основного клиента:
|
||||||
|
* BrowserFetchWithRetry идёт ПОСЛЕ SPFx, поэтому заменяет его обработчик
|
||||||
|
* отправки (у behavior replace: true по умолчанию).
|
||||||
|
*/
|
||||||
|
export function spForSite(siteUrl: string, context: WebPartContext): SPFI {
|
||||||
|
return spfi(siteUrl).using(SPFx(context), BrowserFetchWithRetry(RETRY));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
|
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
|
||||||
|
|||||||
@@ -33,3 +33,29 @@ export function usePersistedQuery(key: string): [string, (value: string) => void
|
|||||||
|
|
||||||
return [value, setPersisted];
|
return [value, setPersisted];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
|
||||||
|
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
|
||||||
|
// (Search + дочитывание через REST по каждому типу заявок), из которых нужен
|
||||||
|
// только последний. Заодно снимает часть нагрузки, из-за которой SharePoint
|
||||||
|
// начинает throttling'ить (429).
|
||||||
|
const SEARCH_DEBOUNCE_MS = 450;
|
||||||
|
|
||||||
|
// Значение, отстающее от переданного на SEARCH_DEBOUNCE_MS: в поле ввода текст
|
||||||
|
// меняется сразу (без «залипания»), а в запрос уходит только когда пользователь
|
||||||
|
// перестал печатать. Пустая строка проходит без задержки — очистка поиска
|
||||||
|
// должна возвращать полный список мгновенно.
|
||||||
|
export function useDebouncedQuery(value: string, delayMs: number = SEARCH_DEBOUNCE_MS): string {
|
||||||
|
const [debounced, setDebounced] = React.useState<string>(value);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setDebounced('');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user