diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx index 2212523..91c840e 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getLinkedTypes, getPendingApproval, searchOwnRequests } from './myRequests.service'; +import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { navigate, routeUrl } from '../../shared/routing'; @@ -66,12 +66,12 @@ const RequestRow: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({ {r.title || `#${r.id}`} + {formatDate(r.created)} {variant === 'approval' && ( {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} )} - {formatDate(r.created)} {variant === 'own' && ( {r.status || '—'} @@ -86,8 +86,8 @@ const RequestsTable: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval' Тип Название - {variant === 'approval' && Автор} Дата + {variant === 'approval' && Автор} {variant === 'own' && Статус} @@ -185,9 +185,10 @@ const PendingSection: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => { ); }; -// «Созданы вами»: один общий постраничный поиск по всем типам сразу через -// SharePoint Search REST API (см. myRequests.service.searchOwnRequests) — -// единая пагинация, без разбивки списка по типам заявок. +// «Созданы вами»: один общий постраничный список по всем типам сразу через +// обычный REST (см. myRequests.service.getOwnRequests) — единая пагинация, +// без разбивки по типам, данные всегда актуальные (не из поискового индекса), +// поэтому секция перечитывается и по событию rp-requests-changed. const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({ context, types, @@ -206,18 +207,25 @@ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; que return; } let cancelled = false; - searchOwnRequests(context, types, query, page) - .then((result) => { - if (cancelled) return; - setRows(result.rows); - setTotalRows(result.totalRows); - setError(''); - }) - .catch((e) => { - if (!cancelled) setError((e as Error).message); - }); + + const load = (): void => { + getOwnRequests(context, types, query, page) + .then((result) => { + if (cancelled) return; + setRows(result.rows); + setTotalRows(result.totalRows); + setError(''); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + }; + + load(); + const unsubscribe = onRequestsChanged(load); return () => { cancelled = true; + unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [context, types, query, page]); @@ -230,7 +238,11 @@ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; que return
Загрузка…
; } if (totalRows === 0) { - return
Вы ещё не создавали заявок.
; + return ( +
+ {query ? `По запросу «${query}» ничего не найдено.` : 'Вы ещё не создавали заявок.'} +
+ ); } const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); @@ -250,18 +262,14 @@ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; que // Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. // -// «Ждёт вашего решения» перечитывается по событию rp-requests-changed +// Обе секции перечитываются по событию rp-requests-changed // (shared/requestEvents) — оно шлётся после сохранения формы и после решения -// согласующего. «Созданы вами» идёт через SharePoint Search (см. -// myRequests.service) постранично — намеренно НЕ обновляется по этому -// событию: только что созданная заявка появится там, когда её проиндексирует -// поиск (задержка приемлема). +// согласующего, так что список обновляется сам, без перезагрузки страницы. // // Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже -// загружены целиком), «Созданы вами» — реальный запрос к Search REST API -// (может слегка отставать от только что созданных/переименованных заявок). +// загружены целиком), «Созданы вами» — серверным OData-фильтром по Title. // -// Диагностика (listId/siteUrl/rowCount/error, KQL-запросы) в UI не выводится — +// Диагностика (listId/siteUrl/rowCount/error) в UI не выводится — // см. console.log/console.table в devtools при загрузке панели. const MyRequestsPanel: React.FC = ({ context, collapsed, onToggleCollapsed }) => { const [linkedTypes, setLinkedTypes] = React.useState(undefined); diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 5cb1e9e..3ac786c 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -3,7 +3,6 @@ import '@pnp/sp/webs'; import '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; -import '@pnp/sp/search'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval'; import { IMyRequest } from './models/IMyRequest'; @@ -172,116 +171,117 @@ function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean { } // --------------------------------------------------------------------------- -// «Созданы вами» — по каждому типу отдельно, через SharePoint Search REST API -// (search/query, KQL), с постраничной выборкой (RowLimit/StartRow). Заявок -// автора может накопиться много со временем, поэтому весь список целиком не -// грузим — только текущую страницу. Расплата за это — результаты идут из -// поискового индекса, который переиндексируется с задержкой (только что -// созданная/переименованная заявка может не найтись сразу). +// «Созданы вами» — обычный REST по спискам данных (НЕ Search): данные всегда +// актуальны, только что созданная заявка видна сразу (у поискового индекса +// была бы задержка переиндексации). // -// Search отдаёт только Title/Path/Id — Status и прочие поля не запрашиваем -// через Search (для кастомных колонок нет гарантии, что они вообще -// проиндексированы как managed property), а дочитываем обычным REST-запросом -// по найденным Id из самого списка данных — так надёжнее. -export interface IOwnSearchPage { +// Пагинация «сшивает» несколько списков в один нумерованный список: сначала по +// каждому типу берём только лёгкие поля (Id + Created) всех заявок автора, +// сортируем объединённый набор по дате создания и лишь для нужной страницы +// дочитываем полные поля. Так на экран не тянется всё содержимое заявок, +// но общее число страниц известно точно. +export interface IOwnRequestsPage { rows: IMyRequest[]; totalRows: number; } const PAGE_SIZE = 20; +// Столько «лёгких» строк (Id + Created) максимум читаем из одного списка, чтобы +// посчитать общее число страниц. Хватает с большим запасом; если у кого-то +// заявок окажется больше — в консоль уйдёт предупреждение. +const INDEX_LIMIT = 5000; -function siteOrigin(siteUrl: string): string { - const match = /^https?:\/\/[^/]+/.exec(siteUrl); - return match ? match[0] : siteUrl; +// В OData-строке одинарная кавычка экранируется удвоением. +function escapeOData(text: string): string { + return text.replace(/'/g, "''"); } -// KQL не терпит спецсимволов в значении — оставляем буквы (лат./кир.), цифры и пробелы. -// (без \p{L} — целевой ES-таргет проекта его не поддерживает) -function sanitizeForKql(text: string): string { - return text - .replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); +interface IOwnIndexEntry { + id: number; + created: string; + type: IRequestType; } -// Один запрос на ВСЕ типы сразу (не по одному на тип) — так у результата единая -// пагинация (одна StartRow/RowLimit/TotalRows), без разбивки по типам заявок. -// Path одного типа ищем через OR внутри запроса; после поиска по Path каждого -// найденного элемента определяем, какому типу он принадлежит, и дочитываем -// поля из ЕГО списка данных (см. комментарий выше — Search отдаёт не все поля). -export async function searchOwnRequests( +export async function getOwnRequests( context: WebPartContext, types: IRequestType[], query: string, page: number -): Promise { +): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; - const sp = spfi(types[0].siteUrl as string).using(SPFx(context)); - const tokenEmail = currentUserEmail(context); - const claimsLogin = `i:0#.f|membership|${tokenEmail}`; + const q = query.trim(); - const typeInfo = types.map((t) => ({ type: t, listAbsUrl: `${siteOrigin(t.siteUrl as string)}${t.listId}` })); - const pathClause = typeInfo.map((t) => `Path:"${t.listAbsUrl}*"`).join(' OR '); + // 1) Лёгкий индекс по каждому типу: только Id + Created заявок автора. + const perType = await Promise.all( + types.map(async (type): Promise => { + try { + const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const me = await sp.web.currentUser.select('Id')(); + const filters = [`AuthorId eq ${me.Id}`]; + if (q) filters.push(`substringof('${escapeOData(q)}',Title)`); - const clauses = [`(${pathClause})`, `AuthorOWSUSER:"${claimsLogin}"`]; - const q = sanitizeForKql(query); - if (q) clauses.push(`Title:${q}*`); - const kql = clauses.join(' AND '); + const rows = (await sp.web + .getList(type.listId as string) + .items.filter(filters.join(' and ')) + .select('Id', 'Created') + .orderBy('Created', false) + .top(INDEX_LIMIT)()) as Array>; - const results = await sp.search({ - Querytext: kql, - RowLimit: PAGE_SIZE, - StartRow: (page - 1) * PAGE_SIZE, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - }); + if (rows.length === INDEX_LIMIT) { + console.warn( + `[Текущие заявки] «${type.name}»: индекс своих заявок обрезан на ${INDEX_LIMIT} — часть страниц не видна` + ); + } + return rows.map((r) => ({ id: r.Id as number, created: (r.Created as string) || '', type })); + } catch (e) { + console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message); + return []; + } + }) + ); - console.log(`[Текущие заявки] Search «Созданы вами»: KQL = ${kql}, всего найдено: ${results.TotalRows}`); - console.table(results.PrimarySearchResults); + // 2) Общий список всех типов, свежие сверху. + const index = perType + .reduce((all, rows) => all.concat(rows), [] as IOwnIndexEntry[]) + .sort((a, b) => b.created.localeCompare(a.created)); - const hits = results.PrimarySearchResults.map((r) => { - const rec = r as Record; - return { id: Number(rec.ListItemID), path: String(rec.Path || '') }; - }).filter((h) => !isNaN(h.id) && h.id > 0); - - if (hits.length === 0) { - return { rows: [], totalRows: results.TotalRows || 0 }; + const pageEntries = index.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + if (pageEntries.length === 0) { + return { rows: [], totalRows: index.length }; } - // Каждый найденный элемент → его тип (по префиксу Path), чтобы дочитать поля - // из правильного списка данных. + // 3) Полные поля — только для заявок текущей страницы, по своему списку на тип. const idsByType = new Map(); - hits.forEach((h) => { - const matched = typeInfo.find((t) => h.path.toLowerCase().indexOf(t.listAbsUrl.toLowerCase()) === 0); - if (!matched) return; - const ids = idsByType.get(matched.type) || []; - ids.push(h.id); - idsByType.set(matched.type, ids); + pageEntries.forEach((e) => { + const ids = idsByType.get(e.type) || []; + ids.push(e.id); + idsByType.set(e.type, ids); }); - const byId = new Map(); + const byKey = new Map(); await Promise.all( Array.from(idsByType.entries()).map(async ([type, ids]) => { - const typeSp = spfi(type.siteUrl as string).using(SPFx(context)); - const list = typeSp.web.getList(type.listId as string); - const filter = ids.map((id) => `Id eq ${id}`).join(' or '); - const rows = (await list.items - .filter(filter) + const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const rows = (await sp.web + .getList(type.listId as string) + .items.filter(ids.map((id) => `Id eq ${id}`).join(' or ')) .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') .top(PAGE_SIZE)()) as Array>; rows.forEach((r) => { const author = r.Author as { EMail?: string; Title?: string } | undefined; - byId.set(r.Id as number, toMyRequest(r, author, type, 'own')); + byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, 'own')); }); }) ); - // Сохраняем порядок релевантности из поиска, а не порядок ответов REST. - const ordered = hits.map((h) => byId.get(h.id)).filter((r): r is IMyRequest => !!r); + // Порядок страницы задаёт индекс (по дате), а не порядок ответов REST. + const rows = pageEntries + .map((e) => byKey.get(`${e.type.id}-${e.id}`)) + .filter((r): r is IMyRequest => !!r); - return { rows: ordered, totalRows: results.TotalRows || 0 }; + return { rows, totalRows: index.length }; } function toMyRequest(