правка списка
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
|
||||
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
|
||||
import { RequestType } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useSharePointLists } from '../../hooks/useSharePointLists';
|
||||
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import {
|
||||
@@ -35,12 +36,16 @@ interface IFormState {
|
||||
pageUrl: string;
|
||||
section: string;
|
||||
siteUrl: string;
|
||||
/** Заголовок сайта — только для отображения в триггере поиска, на бэкенд не уходит. */
|
||||
siteTitle: string;
|
||||
listName: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', siteTitle: '', listName: '' };
|
||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' };
|
||||
|
||||
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
|
||||
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
|
||||
const { title } = useSharePointSiteTitle(client, siteUrl);
|
||||
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
|
||||
};
|
||||
|
||||
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
|
||||
const list = useRequestTypes(api);
|
||||
@@ -50,6 +55,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||
const submit = useAsyncSubmit();
|
||||
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
||||
const searchSites = React.useCallback(
|
||||
(query: string, page: number) => sharePoint.searchSites(query, page),
|
||||
[sharePoint]
|
||||
@@ -70,7 +76,6 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
pageUrl: item.pageUrl,
|
||||
section: item.section,
|
||||
siteUrl: item.siteUrl ?? '',
|
||||
siteTitle: item.siteUrl ?? '',
|
||||
listName: item.listName ?? ''
|
||||
});
|
||||
setShowForm(true);
|
||||
@@ -126,7 +131,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
{ 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: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => r.siteUrl ?? texts.common.empty },
|
||||
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
@@ -179,7 +184,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
|
||||
<AsyncSearchableSelect<SharePointSiteSuggestion>
|
||||
value={form.siteUrl}
|
||||
selectedLabel={form.siteTitle || form.siteUrl}
|
||||
selectedLabel={siteTitle.title ?? form.siteUrl}
|
||||
selectedSublabel={siteTitle.title ? form.siteUrl : undefined}
|
||||
allowClear
|
||||
clearLabel={texts.select.notSelected}
|
||||
placeholder={texts.requestTypes.siteUrlPlaceholder}
|
||||
@@ -188,7 +194,10 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
||||
getValue={s => s.url}
|
||||
getLabel={s => s.title}
|
||||
getSublabel={s => s.url}
|
||||
onChange={(siteUrl, item) => setForm({ ...form, siteUrl, siteTitle: item?.title ?? siteUrl, listName: '' })}
|
||||
onChange={(siteUrl, item) => {
|
||||
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
|
||||
setForm({ ...form, siteUrl, listName: '' });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface IAsyncSearchableSelectProps<T> {
|
||||
value: string;
|
||||
/** Название выбранного значения — показываем в триггере без похода на сервер. */
|
||||
selectedLabel?: string;
|
||||
/** Вторая строка триггера мельче и серым — например, url под названием сайта. */
|
||||
selectedSublabel?: string;
|
||||
onChange: (value: string, item: T | undefined) => void;
|
||||
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
|
||||
getValue: (item: T) => string;
|
||||
@@ -30,6 +32,7 @@ export interface IAsyncSearchableSelectProps<T> {
|
||||
export function AsyncSearchableSelect<T>({
|
||||
value,
|
||||
selectedLabel,
|
||||
selectedSublabel,
|
||||
onChange,
|
||||
search,
|
||||
getValue,
|
||||
@@ -166,9 +169,16 @@ export function AsyncSearchableSelect<T>({
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
||||
{value ? (selectedLabel ?? value) : placeholder}
|
||||
</span>
|
||||
{value && selectedSublabel ? (
|
||||
<span className={styles.valueStack}>
|
||||
<span className={styles.valueMain}>{selectedLabel ?? value}</span>
|
||||
<span className={styles.valueSub}>{selectedSublabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
||||
{value ? (selectedLabel ?? value) : placeholder}
|
||||
</span>
|
||||
)}
|
||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -35,6 +35,31 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Двухстрочный триггер — название сверху, мелкая серая подпись (url) снизу,
|
||||
// как itemSub в списке. Отдельно от .value, у которого одна строка целиком
|
||||
// участвует в ellipsis и не может параллельно нести подпись другим кеглем.
|
||||
.valueStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.valueMain {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.valueSub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: $gray-400;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.placeholder { color: $gray-400; }
|
||||
|
||||
.chevron { color: $gray-400; flex-shrink: 0; }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { SharePointListsClient } from '../services/sharePointLists';
|
||||
|
||||
export interface SharePointSiteTitleState {
|
||||
title: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */
|
||||
export function useSharePointSiteTitle(
|
||||
client: SharePointListsClient | undefined,
|
||||
siteUrl: string | undefined | null
|
||||
): SharePointSiteTitleState {
|
||||
const [title, setTitle] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!client || !siteUrl?.trim()) {
|
||||
setTitle(null);
|
||||
setIsLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
client.getSiteTitle(siteUrl.trim())
|
||||
.then(result => { if (!cancelled) setTitle(result); })
|
||||
.catch(() => { if (!cancelled) setTitle(null); })
|
||||
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [client, siteUrl]);
|
||||
|
||||
return { title, isLoading };
|
||||
}
|
||||
@@ -45,6 +45,11 @@ export function isNumericFieldType(typeAsString: string): boolean {
|
||||
return NUMERIC_FIELD_TYPES.has(typeAsString);
|
||||
}
|
||||
|
||||
/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */
|
||||
export function formatSiteLabel(title: string | null | undefined, url: string): string {
|
||||
return title ? `${title} — ${url}` : url;
|
||||
}
|
||||
|
||||
interface IListItem {
|
||||
Id: string;
|
||||
Title: string;
|
||||
@@ -66,8 +71,33 @@ function clientFor(context: WebPartContext, siteUrl: string): SPFI {
|
||||
}
|
||||
|
||||
export class SharePointListsClient {
|
||||
// Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем
|
||||
// на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы
|
||||
// или переоткрытие формы с тем же значением.
|
||||
private readonly siteTitleCache = new Map<string, string | null>();
|
||||
|
||||
constructor(private readonly context: WebPartContext) {}
|
||||
|
||||
/** Подмешать уже известный заголовок сайта (например, из результата поиска) — без похода в сеть. */
|
||||
public cacheSiteTitle(siteUrl: string, title: string): void {
|
||||
this.siteTitleCache.set(siteUrl, title);
|
||||
}
|
||||
|
||||
/** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */
|
||||
public async getSiteTitle(siteUrl: string): Promise<string | null> {
|
||||
const cached = this.siteTitleCache.get(siteUrl);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
try {
|
||||
const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string };
|
||||
this.siteTitleCache.set(siteUrl, web.Title);
|
||||
return web.Title;
|
||||
} catch {
|
||||
this.siteTitleCache.set(siteUrl, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
|
||||
public async listLists(siteUrl: string): Promise<SharePointList[]> {
|
||||
const sp = clientFor(this.context, siteUrl);
|
||||
|
||||
Reference in New Issue
Block a user