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