переработка выбора
This commit is contained in:
@@ -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('');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user