Редактирование заявок у конкретного пользователя #14

Merged
gorbenko.pavel merged 9 commits from edit-by-user into main 2026-08-19 12:58:07 +00:00
7 changed files with 224 additions and 77 deletions
Showing only changes of commit 30fb3fa2b1 - Show all commits
@@ -15,6 +15,7 @@ import {
} from '../../models'; } from '../../models';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useRequestTypeListPartitions } from '../../hooks/useRequestTypeListPartitions';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { findEmployeeByEmail } from '../../utils/employee'; import { findEmployeeByEmail } from '../../utils/employee';
@@ -47,6 +48,14 @@ export interface IManageRequestsScreenProps {
route?: IManageRequestRouteParams; route?: IManageRequestRouteParams;
} }
// Подпись заявки в комбобоксе — уникальный код (RequestUniqueCode, тот же, что
// видит заявитель на портале), а не служебный числовой Id списка SharePoint.
// Запасной вариант на #id — для заявок без заполненного кода (старые заявки,
// либо список данных этой колонки не заводит).
function requestLabel(r: Pick<ManagedRequestSummary, 'id' | 'title' | 'uniqueCode'>): string {
return `${r.uniqueCode || `#${r.id}`} ${r.title}`;
}
// Цвет плашки статуса — те же группы смысла, что и в request-portal // Цвет плашки статуса — те же группы смысла, что и в request-portal
// (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик. // (StatusBadge.tsx): решено положительно/отрицательно/ожидает/черновик.
function statusVariant(status: string): BadgeVariant { function statusVariant(status: string): BadgeVariant {
@@ -158,7 +167,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!route) return; if (!route) return;
setSelection({ setSelection({
requestTypeId: route.requestTypeId, requestTypeId: route.requestTypeId,
selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' } // listId из ссылки — какая партиция, если ссылку скопировали уже после
// этого фикса; пусто у старых ссылок — тогда reloadMatrix ниже
// подставит текущую партицию (тот же эффект, что был раньше, но теперь
// явный, а не единственно возможный).
selectedRequest: {
id: Number(route.requestId),
title: '',
status: '',
created: '',
authorName: '',
uniqueCode: null,
listId: route.listId
}
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -167,11 +188,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка // прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка
// состояния текущего экрана, а не переход на новую страницу с отдельным // состояния текущего экрана, а не переход на новую страницу с отдельным
// шагом в истории браузера. title/status заявки в URL не пишем — их выводит // шагом в истории браузера. title/status заявки в URL не пишем — их выводит
// сам requestId, хранить производные данные в ссылке незачем. // сам requestId, хранить производные данные в ссылке незачем. listId —
// пишем: без него по ссылке нельзя однозначно понять, в какой партиции
// искать requestId (см. IManageRequestRouteParams).
React.useEffect(() => { React.useEffect(() => {
replaceRoute({ replaceRoute({
tab: 'manageRequests', tab: 'manageRequests',
manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined manage: selectedRequest
? { requestTypeId, requestId: String(selectedRequest.id), listId: selectedRequest.listId }
: undefined
}); });
}, [requestTypeId, selectedRequest]); }, [requestTypeId, selectedRequest]);
const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>(); const [matrix, setMatrix] = React.useState<ManagedRequestMatrix | undefined>();
@@ -192,6 +217,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
// и читать заявки этого типа отсюда просто негде. // и читать заявки этого типа отсюда просто негде.
const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl); const manageableRequestTypes = requestTypes.items.filter(rt => rt.listId && rt.siteUrl);
// Вся серия партиций выбранного типа (архив + текущая) — заявки этого типа
// физически лежат в любой из них (см. ТЗ по архивации), искать и открывать
// нужно по всем сразу, не только по текущей.
const partitions = useRequestTypeListPartitions(api, requestType?.id);
const selectRequestType = (id: string): void => { const selectRequestType = (id: string): void => {
// Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит // Заявка выбрана для ДРУГОГО типа — сбрасываем, иначе комбобокс продолжит
// показывать заявку не того типа, что выбран сейчас. // показывать заявку не того типа, что выбран сейчас.
@@ -205,10 +235,12 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const searchRequests = React.useCallback( const searchRequests = React.useCallback(
(query: string, page: number) => { (query: string, page: number) => {
if (!requestType) return Promise.resolve({ items: [], hasMore: false }); if (!requestType || !requestType.siteUrl || partitions.items.length === 0) {
return requestManage.searchRequests(requestType, query, page); return Promise.resolve({ items: [], hasMore: false });
}
return requestManage.searchRequests(partitions.items, requestType.siteUrl, query, page);
}, },
[requestManage, requestType] [requestManage, requestType, partitions.items]
); );
// silent=true — фоновое обновление (после своего же действия, автообновление // silent=true — фоновое обновление (после своего же действия, автообновление
@@ -219,18 +251,38 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const reloadMatrix = React.useCallback( const reloadMatrix = React.useCallback(
(silent = false) => { (silent = false) => {
if (!requestType || !selectedRequest) return; if (!requestType || !selectedRequest) return;
// Партиции ещё не подтянулись (первый рендер после смены типа/захода по
// ссылке) — без них нельзя даже подставить запасной вариант (текущая
// партиция) для ссылок без listId, ждём следующего рендера.
if (partitions.isLoading) return;
if (!requestType.siteUrl) {
setMatrixError('У типа заявки не указан список SharePoint.');
return;
}
// listId заявки уже известен (выбрана из комбобокса, либо ссылка после
// этого фикса) — используем его. Иначе (старая ссылка без listId) —
// единственный разумный запасной вариант: текущая партиция, тот же
// список, что искался бы и раньше.
const listId = selectedRequest.listId || partitions.items.filter(p => p.isCurrent)[0]?.listId;
if (!listId) {
setMatrixError('Для типа заявки не найдено ни одной партиции списка SharePoint.');
return;
}
if (!silent) setMatrixLoading(true); if (!silent) setMatrixLoading(true);
setMatrixError(undefined); setMatrixError(undefined);
requestManage requestManage
.getMatrix(requestType, selectedRequest.id) .getMatrix(requestType.siteUrl, listId, selectedRequest.id)
.then(m => { .then(m => {
setMatrix(m); setMatrix(m);
// title/status в комбобоксе (а значит, и в localStorage) держим // title/status/listId в комбобоксе (а значит, и в localStorage) держим
// синхронными с сервером — особенно важно после захода по глубокой // синхронными с сервером — особенно важно после захода по глубокой
// ссылке, где изначально они пустые (см. эффект-затравку выше). // ссылке, где изначально они пустые (см. эффект-затравку выше), и
// после подстановки запасной (текущей) партиции выше: как только
// реальная партиция заявки известна, оседает в выборе — следующий
// reloadMatrix пойдёт уже по ней напрямую.
// //
// ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если title/status не // ВАЖНО: возвращаем ТОТ ЖЕ prev (без изменений), если ничего не
// отличаются — m.request это каждый раз НОВЫЙ объект (свежий парсинг // отличается — m.request это каждый раз НОВЫЙ объект (свежий парсинг
// ответа), и если писать его в состояние безусловно, selectedRequest // ответа), и если писать его в состояние безусловно, selectedRequest
// получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не // получает новую ссылку на КАЖДОЙ загрузке, даже когда содержимое не
// поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму // поменялось. Это меняет зависимости reloadMatrix → пересоздаёт саму
@@ -240,7 +292,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
setSelection(prev => { setSelection(prev => {
const sel = prev.selectedRequest; const sel = prev.selectedRequest;
if (!sel || sel.id !== m.request.id) return prev; if (!sel || sel.id !== m.request.id) return prev;
const same = sel.title === m.request.title && sel.status === m.request.status; const same = sel.title === m.request.title && sel.status === m.request.status && sel.listId === m.request.listId;
return same ? prev : { ...prev, selectedRequest: m.request }; return same ? prev : { ...prev, selectedRequest: m.request };
}); });
}) })
@@ -249,7 +301,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
if (!silent) setMatrixLoading(false); if (!silent) setMatrixLoading(false);
}); });
}, },
[requestManage, requestType, selectedRequest] [requestManage, requestType, selectedRequest, partitions.items, partitions.isLoading]
); );
React.useEffect(() => { React.useEffect(() => {
@@ -533,14 +585,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
<AsyncSearchableSelect<ManagedRequestSummary> <AsyncSearchableSelect<ManagedRequestSummary>
key={requestTypeId || 'none'} key={requestTypeId || 'none'}
value={selectedRequest ? String(selectedRequest.id) : ''} value={selectedRequest ? String(selectedRequest.id) : ''}
selectedLabel={selectedRequest ? `#${selectedRequest.id} ${selectedRequest.title}` : undefined} selectedLabel={selectedRequest ? requestLabel(selectedRequest) : undefined}
selectedSublabel={selectedRequest?.status} selectedSublabel={selectedRequest?.status}
disabled={!requestType} disabled={!requestType}
placeholder={texts.manageRequests.requestPlaceholder} placeholder={texts.manageRequests.requestPlaceholder}
searchPlaceholder={texts.manageRequests.requestSearchPlaceholder} searchPlaceholder={texts.manageRequests.requestSearchPlaceholder}
search={searchRequests} search={searchRequests}
getValue={r => String(r.id)} getValue={r => String(r.id)}
getLabel={r => `#${r.id} ${r.title}`} getLabel={requestLabel}
getSublabel={r => r.status} getSublabel={r => r.status}
onChange={(_, item) => selectRequest(item)} onChange={(_, item) => selectRequest(item)}
/> />
@@ -0,0 +1,20 @@
import * as React from 'react';
import { RequestListPartition } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/**
* Серия партиций (архив + текущая) ОДНОГО типа заявки — нужна экрану
* редактирования заявок: заявка может лежать в любой из них, не только в
* текущей (см. requestManageService.ts).
*/
export function useRequestTypeListPartitions(
api: ApprovalRouteApi,
requestTypeId: number | undefined
): AsyncList<RequestListPartition> {
const load = React.useCallback(
() => (requestTypeId ? api.requestTypes.lists(requestTypeId) : Promise.resolve([])),
[api, requestTypeId]
);
return useAsyncList(load);
}
@@ -78,6 +78,21 @@ export interface ManagedRequestSummary {
status: string; status: string;
created: string; // ISO created: string; // ISO
authorName: string; authorName: string;
/**
* Уникальный код заявки (колонка RequestUniqueCode списка данных) — то, что
* реально видит заявитель, в отличие от служебного числового Id списка
* SharePoint. null — у заявки его нет (старая заявка, колонка не заполнена)
* или список данных этой колонки не заводит; тогда в UI показывается id.
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть
uniqueCode: string | null;
/**
* Список-партиция, в котором физически лежит эта заявка. У разных партиций
* независимая нумерация Id (см. ТЗ по архивации), поэтому один и тот же
* requestId в разных партициях — это РАЗНЫЕ заявки: без listId открыть
* заявку однозначно нельзя.
*/
listId: string;
} }
export interface ManagedRequestPage { export interface ManagedRequestPage {
@@ -19,7 +19,12 @@ describe('parseRoute', () => {
it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => { it('manageRequests с aamtype+aamreq — даёт заполненный manage', () => {
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42'); const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42');
expect(route.tab).toBe('manageRequests'); expect(route.tab).toBe('manageRequests');
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42' }); expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '' });
});
it('manageRequests с aamlist — партиция заявки читается в manage.listId', () => {
const route = parseRoute('?aatab=manageRequests&aamtype=5&aamreq=42&aamlist=%2Fsites%2Fx%2FLists%2FForm2');
expect(route.manage).toEqual({ requestTypeId: '5', requestId: '42', listId: '/sites/x/Lists/Form2' });
}); });
it('manageRequests без aamreq — manage не собирается (id заявки обязателен)', () => { it('manageRequests без aamreq — manage не собирается (id заявки обязателен)', () => {
@@ -78,14 +83,14 @@ describe('parseRoute', () => {
describe('navigate', () => { describe('navigate', () => {
it('после navigate() параметры читаются обратно тем же parseRoute (round-trip)', () => { it('после navigate() параметры читаются обратно тем же parseRoute (round-trip)', () => {
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } }); navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } });
const roundTripped = parseRoute(window.location.search); const roundTripped = parseRoute(window.location.search);
expect(roundTripped.tab).toBe('manageRequests'); expect(roundTripped.tab).toBe('manageRequests');
expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10' }); expect(roundTripped.manage).toEqual({ requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' });
}); });
it('navigate на dashboard полностью убирает параметры вкладки из адреса', () => { it('navigate на dashboard полностью убирает параметры вкладки из адреса', () => {
navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10' } }); navigate({ tab: 'manageRequests', manage: { requestTypeId: '3', requestId: '10', listId: '/sites/x/Lists/Form' } });
navigate({ tab: 'dashboard' }); navigate({ tab: 'dashboard' });
expect(window.location.search).toBe(''); expect(window.location.search).toBe('');
}); });
+11 -2
View File
@@ -36,6 +36,14 @@ export interface IResolveRouteParams {
export interface IManageRequestRouteParams { export interface IManageRequestRouteParams {
requestTypeId: string; requestTypeId: string;
requestId: string; requestId: string;
/**
* Партиция (список-архив данных), где физически лежит заявка — у разных
* партиций независимая нумерация Id (см. ТЗ по архивации), поэтому без
* listId requestId неоднозначен. Пусто — старая ссылка (до архивации) или
* ссылка ещё не резолвнута; тогда экран считает, что заявка в текущей
* партиции (см. ManageRequestsScreen).
*/
listId: string;
} }
export interface IAdminRoute { export interface IAdminRoute {
@@ -64,7 +72,7 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'ma
const PARAM_KEYS = [ const PARAM_KEYS = [
'aatab', 'aaroute', 'aarfilter', 'aatab', 'aaroute', 'aarfilter',
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept', 'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
'aamtype', 'aamreq' 'aamtype', 'aamreq', 'aamlist'
]; ];
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null). // raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
@@ -114,7 +122,7 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
const manageReqId = params.get('aamreq'); const manageReqId = params.get('aamreq');
const manage: IManageRequestRouteParams | undefined = const manage: IManageRequestRouteParams | undefined =
tab === 'manageRequests' && manageTypeId && manageReqId tab === 'manageRequests' && manageTypeId && manageReqId
? { requestTypeId: manageTypeId, requestId: manageReqId } ? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
: undefined; : undefined;
return { return {
@@ -152,6 +160,7 @@ function buildQuery(route: IAdminRoute): string {
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) { if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
params.set('aamtype', route.manage.requestTypeId); params.set('aamtype', route.manage.requestTypeId);
params.set('aamreq', route.manage.requestId); params.set('aamreq', route.manage.requestId);
if (route.manage.listId) params.set('aamlist', route.manage.listId);
} }
const query = params.toString(); const query = params.toString();
@@ -6,6 +6,7 @@ import {
PatchRequestTypeInput, PatchRequestTypeInput,
PatchTemplateRouteInput, PatchTemplateRouteInput,
PatchTemplateRouteStepInput, PatchTemplateRouteStepInput,
RequestListPartition,
RequestType, RequestType,
RequestTypeListSeries, RequestTypeListSeries,
ResolveTemplateRouteQuery, ResolveTemplateRouteQuery,
@@ -47,7 +48,13 @@ export class ApprovalRouteApi {
// портала, которому нужен весь список адресов разом, а не по одному запросу // портала, которому нужен весь список адресов разом, а не по одному запросу
// на тип. // на тип.
listsAll: (): Promise<RequestTypeListSeries[]> => listsAll: (): Promise<RequestTypeListSeries[]> =>
this.http.get<RequestTypeListSeries[]>('/api/request-types/lists') this.http.get<RequestTypeListSeries[]>('/api/request-types/lists'),
// Вся серия партиций ОДНОГО типа заявки (архив + текущая) — нужна экрану
// редактирования заявок: заявка может лежать в любой из них, не только в
// текущей (см. requestManageService.ts).
lists: (requestTypeId: number): Promise<RequestListPartition[]> =>
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
}; };
public readonly templateRoutes = { public readonly templateRoutes = {
@@ -12,7 +12,7 @@ import {
ManagedRequestMatrix, ManagedRequestMatrix,
ManagedRequestPage, ManagedRequestPage,
ManagedRequestSummary, ManagedRequestSummary,
RequestType RequestListPartition
} from '../models'; } from '../models';
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование» // Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
@@ -86,15 +86,19 @@ interface IRawDataRow {
RequestStatus?: string; RequestStatus?: string;
Created?: string; Created?: string;
Author?: { Title?: string }; Author?: { Title?: string };
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
RequestUniqueCode?: string | null;
} }
function toSummary(r: IRawDataRow): ManagedRequestSummary { function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary {
return { return {
id: r.Id, id: r.Id,
title: r.Title || `#${r.Id}`, title: r.Title || `#${r.Id}`,
status: r.RequestStatus || '', status: r.RequestStatus || '',
created: r.Created || '', created: r.Created || '',
authorName: r.Author?.Title || '' authorName: r.Author?.Title || '',
uniqueCode: r.RequestUniqueCode || null,
listId
}; };
} }
@@ -124,24 +128,41 @@ export class RequestManageService {
return { dataListTitle: info.Title, approvalListTitle }; return { dataListTitle: info.Title, approvalListTitle };
} }
// К какой партиции относится хит Search — по префиксу его Path. Партиции
// определяются вызывающей стороной (ListPartitionService.ListAsync), их
// список короткий (единицы), линейный поиск тут не проблема.
private partitionForPath(
siteUrl: string,
partitions: RequestListPartition[],
path: string
): RequestListPartition | undefined {
return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0];
}
/** /**
* Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала * Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
* Search по названию (свободный терм, пустой query — просто последние без * постранично, для комбобокса выбора заявки. Сначала Search по названию
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не * (свободный терм, пустой query — просто последние без фильтра), затем
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в * дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
* request-portal). * поля списка надёжно (та же двухшаговая схема, что в request-portal).
*
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
* заявки из архивных партиций в комбобоксе просто не найти.
*/ */
public async searchRequests( public async searchRequests(
requestType: RequestType, partitions: RequestListPartition[],
siteUrl: string,
query: string, query: string,
page: number, page: number,
pageSize = 20 pageSize = 20
): Promise<ManagedRequestPage> { ): Promise<ManagedRequestPage> {
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false }; if (partitions.length === 0) return { items: [], hasMore: false };
const sp = this.clientFor(requestType.siteUrl); const sp = this.clientFor(siteUrl);
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId); const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
const clauses = [`Path:"${absUrl}*"`]; const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
const clauses = [pathClause];
const text = freeTextClause(query); const text = freeTextClause(query);
if (text) clauses.push(text); if (text) clauses.push(text);
@@ -154,65 +175,83 @@ export class RequestManageService {
SortList: [{ Property: 'Created', Direction: 1 }] SortList: [{ Property: 'Created', Direction: 1 }]
}); });
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>; const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
// Дедуп по Id обязателен: TrimDuplicates: false выше выключает штатную // Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
// дедупликацию Search (она иногда схлопывает нужные нам заявки), и тогда // партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
// индекс может отдать один и тот же элемент несколькими хитами // одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
// (переиндексация, несколько «версий» в crawl). Без этого фильтра хиты // false выше выключает штатную дедупликацию Search (она иногда схлопывает
// превращались в пункты комбобокса один-в-один, и заявка показывалась в // нужные нам заявки), и тогда индекс может отдать один и тот же элемент
// списке дважды — с одинаковым React-ключом. Та же причина и то же лечение, // несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
// что в request-portal (features/myRequests/myRequests.service.ts/hitsToEntries). // причина и то же лечение, что в request-portal
const seen = new Set<number>(); // (features/myRequests/myRequests.service.ts/hitsToEntries).
const ids = rawRows const seen = new Set<string>();
.map(r => Number(r.ListItemID)) const hits: Array<{ id: number; partition: RequestListPartition }> = [];
.filter(id => !isNaN(id) && id > 0) rawRows.forEach(r => {
.filter(id => { const id = Number(r.ListItemID);
if (seen.has(id)) return false; if (isNaN(id) || id <= 0 || !r.Path) return;
seen.add(id); const partition = this.partitionForPath(siteUrl, partitions, r.Path);
return true; if (!partition) return;
}); const key = `${partition.listId}|${id}`;
if (ids.length === 0) return { items: [], hasMore: false }; if (seen.has(key)) return;
seen.add(key);
hits.push({ id, partition });
});
if (hits.length === 0) return { items: [], hasMore: false };
const list = sp.web.getList(requestType.listId); const idsByListId = new Map<string, number[]>();
const dataRows = (await list.items hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title') const rowByKey = new Map<string, IRawDataRow>();
.expand('Author') await Promise.all(
.top(ids.length)()) as IRawDataRow[]; Array.from(idsByListId.entries()).map(async ([listId, ids]) => {
const list = sp.web.getList(listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r));
})
);
const byId = new Map(dataRows.map(r => [r.Id, r]));
// Порядок задаёт Search (SortList по дате), не порядок ответа REST. // Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = ids const items = hits
.map(id => byId.get(id)) .map(h => {
.filter((r): r is IRawDataRow => !!r) const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
.map(toSummary); return row ? toSummary(row, h.partition.listId) : undefined;
})
.filter((r): r is ManagedRequestSummary => !!r);
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length; const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows }; return { items, hasMore: page * pageSize < totalRows };
} }
/** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */ /**
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> { * Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
if (!requestType.listId || !requestType.siteUrl) { *
throw new Error('У типа заявки не указан список SharePoint.'); * listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
} * ManagedRequestSummary.listId, отданного searchRequests/предыдущей
const sp = this.clientFor(requestType.siteUrl); * getMatrix) — не requestType.listId: та партиция может быть уже не той, где
const dataList = sp.web.getList(requestType.listId); * лежит эта заявка (см. ТЗ по архивации).
*/
public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise<ManagedRequestMatrix> {
const sp = this.clientFor(siteUrl);
const dataList = sp.web.getList(listId);
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([ const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
dataList.items dataList.items
.getById(requestId) .getById(requestId)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title') .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')() as Promise<IRawDataRow>, .expand('Author')() as Promise<IRawDataRow>,
this.listTitles(sp, requestType.listId) this.listTitles(sp, listId)
]); ]);
const dataListUrl = listAbsUrl(requestType.siteUrl, requestType.listId); const dataListUrl = listAbsUrl(siteUrl, listId);
if (!approvalListTitle) { if (!approvalListTitle) {
return { return {
request: toSummary(requestRow), request: toSummary(requestRow, listId),
siteUrl: requestType.siteUrl, siteUrl,
dataListTitle, dataListTitle,
dataListUrl, dataListUrl,
approvalListTitle: '', approvalListTitle: '',
@@ -264,7 +303,7 @@ export class RequestManageService {
}; };
}); });
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows }; return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
} }
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */ /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */