This commit is contained in:
@@ -6,6 +6,7 @@ import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useSharePointLists } from '../../hooks/useSharePointLists';
|
||||
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
||||
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import {
|
||||
@@ -36,10 +37,10 @@ interface IFormState {
|
||||
pageUrl: string;
|
||||
section: string;
|
||||
siteUrl: string;
|
||||
listName: string;
|
||||
listId: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' };
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' };
|
||||
|
||||
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
|
||||
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
|
||||
@@ -47,6 +48,12 @@ const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | n
|
||||
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
/** Ячейка «Список SharePoint» — на бэкенде хранится id списка (устойчив к переименованию), тут подтягиваем Title для читаемости. */
|
||||
const ListIdCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null; listId: string | null }> = ({ client, siteUrl, listId }) => {
|
||||
const { title } = useSharePointListTitle(client, siteUrl, listId);
|
||||
return <>{listId ? (title ?? listId) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
|
||||
const list = useRequestTypes(api);
|
||||
|
||||
@@ -76,7 +83,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
pageUrl: item.pageUrl,
|
||||
section: item.section,
|
||||
siteUrl: item.siteUrl ?? '',
|
||||
listName: item.listName ?? ''
|
||||
listId: item.listId ?? ''
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
@@ -87,8 +94,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
return;
|
||||
}
|
||||
const siteUrl = form.siteUrl.trim();
|
||||
const listName = form.listName.trim();
|
||||
if (Boolean(siteUrl) !== Boolean(listName)) {
|
||||
const listId = form.listId.trim();
|
||||
if (Boolean(siteUrl) !== Boolean(listId)) {
|
||||
submit.fail(texts.requestTypes.validationListReference);
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +105,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
pageUrl: form.pageUrl.trim(),
|
||||
section: form.section.trim(),
|
||||
siteUrl: siteUrl || null,
|
||||
listName: listName || null
|
||||
listId: listId || null
|
||||
};
|
||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||
|
||||
@@ -110,7 +117,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
);
|
||||
};
|
||||
|
||||
const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title }));
|
||||
// value — server-relative url списка (устойчив к переименованию Title в SharePoint), label — Title для читаемости.
|
||||
const listIdOptions = sharePointLists.items.map(l => ({ value: l.url, label: l.title }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -130,7 +138,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
||||
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
|
||||
{ key: 'listName', header: texts.requestTypes.columnListName, render: r => r.listName ?? texts.common.empty },
|
||||
{ key: 'listId', header: texts.requestTypes.columnListId, render: r => <ListIdCell client={sharePoint} siteUrl={r.siteUrl} listId={r.listId} /> },
|
||||
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
@@ -196,29 +204,29 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
getSublabel={s => s.url}
|
||||
onChange={(siteUrl, item) => {
|
||||
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
|
||||
setForm({ ...form, siteUrl, listName: '' });
|
||||
setForm({ ...form, siteUrl, listId: '' });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={texts.requestTypes.listNameLabel}
|
||||
label={texts.requestTypes.listIdLabel}
|
||||
full
|
||||
hint={sharePointLists.error ? texts.requestTypes.listNameError(sharePointLists.error) : texts.requestTypes.listNameHint}
|
||||
hint={sharePointLists.error ? texts.requestTypes.listIdError(sharePointLists.error) : texts.requestTypes.listIdHint}
|
||||
>
|
||||
<Select
|
||||
options={listNameOptions}
|
||||
value={form.listName}
|
||||
options={listIdOptions}
|
||||
value={form.listId}
|
||||
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
|
||||
placeholder={
|
||||
!form.siteUrl.trim()
|
||||
? texts.requestTypes.listNamePlaceholder
|
||||
? texts.requestTypes.listIdPlaceholder
|
||||
: sharePointLists.isLoading
|
||||
? texts.requestTypes.listNameLoading
|
||||
: listNameOptions.length === 0
|
||||
? texts.requestTypes.listNameEmpty
|
||||
? texts.requestTypes.listIdLoading
|
||||
: listIdOptions.length === 0
|
||||
? texts.requestTypes.listIdEmpty
|
||||
: texts.select.placeholder
|
||||
}
|
||||
onChange={e => setForm({ ...form, listName: e.target.value })}
|
||||
onChange={e => setForm({ ...form, listId: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
|
||||
@@ -69,8 +69,8 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
|
||||
|
||||
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||
const hasListName = Boolean(requestType?.listName);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
|
||||
const hasListId = Boolean(requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
|
||||
const resolved = useResolveRoute(api);
|
||||
@@ -180,12 +180,12 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
||||
<div className={styles.conditions}>
|
||||
<div className={styles.conditionsHeader}>
|
||||
<span className={styles.conditionsTitle}>{texts.resolve.conditionsLabel}</span>
|
||||
{requestTypeId && !hasListName && (
|
||||
{requestTypeId && !hasListId && (
|
||||
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasListName && (
|
||||
{hasListId && (
|
||||
<>
|
||||
{fieldValues.map((fv, index) => {
|
||||
const field = sharePointFields.items.find(f => f.internalName === fv.fieldName);
|
||||
|
||||
@@ -112,8 +112,8 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
}) => {
|
||||
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||
const submit = useAsyncSubmit();
|
||||
const hasListName = Boolean(requestType?.listName);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
|
||||
const hasListId = Boolean(requestType?.listId);
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||
|
||||
const fieldNameOptions = React.useMemo(() => {
|
||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
@@ -283,16 +283,16 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListName ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||||
<Checkbox
|
||||
label={texts.routeSteps.conditionCheckbox}
|
||||
checked={form.hasCondition}
|
||||
disabled={!hasListName}
|
||||
disabled={!hasListId}
|
||||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.hasCondition && hasListName && (
|
||||
{form.hasCondition && hasListId && (
|
||||
<>
|
||||
<Field
|
||||
label={texts.routeSteps.conditionFieldNameLabel}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { SharePointListsClient } from '../services/sharePointLists';
|
||||
|
||||
export interface SharePointListTitleState {
|
||||
title: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** Заголовок списка SharePoint по (site_url, list_id) — для отображения «Название (id)» вместо голого GUID. */
|
||||
export function useSharePointListTitle(
|
||||
client: SharePointListsClient | undefined,
|
||||
siteUrl: string | undefined | null,
|
||||
listId: string | undefined | null
|
||||
): SharePointListTitleState {
|
||||
const [title, setTitle] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!client || !siteUrl?.trim() || !listId?.trim()) {
|
||||
setTitle(null);
|
||||
setIsLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
client.getListTitle(siteUrl.trim(), listId.trim())
|
||||
.then(result => { if (!cancelled) setTitle(result); })
|
||||
.catch(() => { if (!cancelled) setTitle(null); })
|
||||
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [client, siteUrl, listId]);
|
||||
|
||||
return { title, isLoading };
|
||||
}
|
||||
@@ -4,9 +4,9 @@ export interface RequestType {
|
||||
id: number;
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
/** Имя списка SharePoint (Title) — источник field_name для шагов маршрута. */
|
||||
listName: string | null;
|
||||
/** Сайт SharePoint, где живёт listName. Заполнен тогда и только тогда, когда заполнен listName. */
|
||||
/** Id списка SharePoint (устойчив к переименованию Title) — источник field_name для шагов маршрута. */
|
||||
listId: string | null;
|
||||
/** Сайт SharePoint, где живёт listId. Заполнен тогда и только тогда, когда заполнен listId. */
|
||||
siteUrl: string | null;
|
||||
section: string;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export interface RequestType {
|
||||
export interface CreateRequestTypeInput {
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
listName: string | null;
|
||||
listId: string | null;
|
||||
siteUrl: string | null;
|
||||
section: string;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
||||
export interface PatchRequestTypeInput {
|
||||
name?: string;
|
||||
pageUrl?: string;
|
||||
listName?: string | null;
|
||||
listId?: string | null;
|
||||
siteUrl?: string | null;
|
||||
section?: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '@pnp/sp/fields';
|
||||
import '@pnp/sp/search';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
|
||||
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
|
||||
// Списки, из которых типы заявок берут list_id/field_name, живут на произвольном
|
||||
// сайте SharePoint (разном для разных типов заявок) — не обязательно на том, где
|
||||
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
|
||||
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
|
||||
@@ -16,7 +16,12 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url).
|
||||
|
||||
export interface SharePointList {
|
||||
id: string;
|
||||
/**
|
||||
* Server-relative URL списка (например, /sites/hrm/Lists/List1) — часть ссылки
|
||||
* вида .../Lists/List1/AllItems.aspx. Стабилен при переименовании Title, как и
|
||||
* GUID, но человекочитаем. Именно это значение хранится в request_type.list_id.
|
||||
*/
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -51,8 +56,8 @@ export function formatSiteLabel(title: string | null | undefined, url: string):
|
||||
}
|
||||
|
||||
interface IListItem {
|
||||
Id: string;
|
||||
Title: string;
|
||||
RootFolder: { ServerRelativeUrl: string };
|
||||
}
|
||||
|
||||
interface IFieldItem {
|
||||
@@ -71,10 +76,11 @@ function clientFor(context: WebPartContext, siteUrl: string): SPFI {
|
||||
}
|
||||
|
||||
export class SharePointListsClient {
|
||||
// Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем
|
||||
// на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы
|
||||
// или переоткрытие формы с тем же значением.
|
||||
// Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд
|
||||
// не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт
|
||||
// заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением.
|
||||
private readonly siteTitleCache = new Map<string, string | null>();
|
||||
private readonly listTitleCache = new Map<string, string | null>();
|
||||
|
||||
constructor(private readonly context: WebPartContext) {}
|
||||
|
||||
@@ -98,21 +104,42 @@ export class SharePointListsClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Заголовок списка по (site_url, list_id — server-relative url списка) — для отображения там, где хранится только он. */
|
||||
public async getListTitle(siteUrl: string, listId: string): Promise<string | null> {
|
||||
const key = `${siteUrl}|${listId}`;
|
||||
const cached = this.listTitleCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
try {
|
||||
const list = await clientFor(this.context, siteUrl).web.getList(listId).select('Title')() as { Title: string };
|
||||
this.listTitleCache.set(key, list.Title);
|
||||
return list.Title;
|
||||
} catch {
|
||||
this.listTitleCache.set(key, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
|
||||
public async listLists(siteUrl: string): Promise<SharePointList[]> {
|
||||
const sp = clientFor(this.context, siteUrl);
|
||||
const items = await sp.web.lists
|
||||
.select('Id', 'Title')
|
||||
.select('Title', 'RootFolder/ServerRelativeUrl')
|
||||
.expand('RootFolder')
|
||||
.filter('Hidden eq false and BaseTemplate eq 100')
|
||||
.orderBy('Title')() as IListItem[];
|
||||
|
||||
return items.map(x => ({ id: x.Id, title: x.Title }));
|
||||
return items.map(x => ({ url: x.RootFolder.ServerRelativeUrl, title: x.Title }));
|
||||
}
|
||||
|
||||
/** Заголовки столбцов (внутреннее + отображаемое имя + тип) списка — исключая служебные поля. */
|
||||
public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> {
|
||||
/**
|
||||
* Заголовки столбцов (внутреннее + отображаемое имя + тип) списка — исключая
|
||||
* служебные поля. По server-relative url списка (list_id), а не по Title:
|
||||
* Title можно переименовать в SharePoint в любой момент, а url списка неизменен.
|
||||
*/
|
||||
public async listFields(siteUrl: string, listId: string): Promise<SharePointListField[]> {
|
||||
const sp = clientFor(this.context, siteUrl);
|
||||
const items = await sp.web.lists.getByTitle(listTitle).fields
|
||||
const items = await sp.web.getList(listId).fields
|
||||
.select('InternalName', 'Title', 'TypeAsString')
|
||||
.filter('Hidden eq false and ReadOnlyField eq false')
|
||||
.orderBy('Title')() as IFieldItem[];
|
||||
|
||||
@@ -103,7 +103,7 @@ export const texts = {
|
||||
columnName: 'Название',
|
||||
columnPageUrl: 'Url страницы',
|
||||
columnSection: 'Раздел',
|
||||
columnListName: 'Список SharePoint',
|
||||
columnListId: 'Список SharePoint',
|
||||
columnSiteUrl: 'Сайт SharePoint',
|
||||
createTitle: 'Новый тип заявки',
|
||||
editTitle: 'Редактировать тип заявки',
|
||||
@@ -118,12 +118,12 @@ export const texts = {
|
||||
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
|
||||
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
|
||||
siteUrlSearchPlaceholder: 'Поиск сайта по названию...',
|
||||
listNameLabel: 'Список SharePoint',
|
||||
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
|
||||
listNamePlaceholder: 'Сначала укажите сайт SharePoint',
|
||||
listNameLoading: 'Загрузка списков сайта...',
|
||||
listNameEmpty: 'На этом сайте нет доступных списков',
|
||||
listNameError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
|
||||
listIdLabel: 'Список SharePoint',
|
||||
listIdHint: 'Источник полей (field_name) для условий в шагах маршрута.',
|
||||
listIdPlaceholder: 'Сначала укажите сайт SharePoint',
|
||||
listIdLoading: 'Загрузка списков сайта...',
|
||||
listIdEmpty: 'На этом сайте нет доступных списков',
|
||||
listIdError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
|
||||
validationRequired: 'Заполните имя, Url страницы и раздел.',
|
||||
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
|
||||
deleteTitle: 'Удалить тип заявки?',
|
||||
|
||||
Reference in New Issue
Block a user