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(