Новый виды условных шагов #6
@@ -11,11 +11,14 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
|||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("resolve")]
|
// POST, а не GET: запрос принимает список условий (field_name/value) переменной длины,
|
||||||
|
// который не ложится на плоскую query-строку без индексной эквилибристики. Единственный
|
||||||
|
// потребитель — диагностический экран «Проверка маршрута» в админке, внешних вызовов нет.
|
||||||
|
[HttpPost("resolve")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
public async Task<IActionResult> Resolve([FromBody] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
||||||
(await service.ResolveAsync(request, ct)).ToActionResult();
|
(await service.ResolveAsync(request, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
+5
-1
@@ -15,8 +15,12 @@ public sealed record ResolveTemplateRouteResultDto(
|
|||||||
bool RequiresControl,
|
bool RequiresControl,
|
||||||
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
||||||
|
|
||||||
|
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
|
||||||
|
public sealed record ResolveFieldValueDto(string FieldName, string Value);
|
||||||
|
|
||||||
public sealed record ResolveTemplateRouteRequest(
|
public sealed record ResolveTemplateRouteRequest(
|
||||||
int RequestTypeId,
|
int RequestTypeId,
|
||||||
string? UserEmail,
|
string? UserEmail,
|
||||||
Guid? ProjectId,
|
Guid? ProjectId,
|
||||||
RouteStepStatus Status);
|
RouteStepStatus Status,
|
||||||
|
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -165,8 +166,14 @@ public sealed class TemplateRouteService(
|
|||||||
if (request.ProjectId is { } projectId)
|
if (request.ProjectId is { } projectId)
|
||||||
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
||||||
|
|
||||||
|
var fieldValues = (request.FieldValues ?? [])
|
||||||
|
.Where(f => !string.IsNullOrWhiteSpace(f.FieldName))
|
||||||
|
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var matchingSteps = route.Steps
|
var matchingSteps = route.Steps
|
||||||
.Where(s => s.Status == request.Status)
|
.Where(s => s.Status == request.Status)
|
||||||
|
.Where(s => MatchesCondition(s, fieldValues))
|
||||||
.OrderBy(s => s.OrderNo)
|
.OrderBy(s => s.OrderNo)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -240,6 +247,37 @@ public sealed class TemplateRouteService(
|
|||||||
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
|
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе.
|
||||||
|
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля
|
||||||
|
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный
|
||||||
|
/// сценарий, и отсутствие значения означает, что условие не выполняется.
|
||||||
|
/// </summary>
|
||||||
|
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var expected = step.Value;
|
||||||
|
return step.Operation switch
|
||||||
|
{
|
||||||
|
ComparisonOperator.Equal =>
|
||||||
|
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected),
|
||||||
|
ComparisonOperator.GreaterThan =>
|
||||||
|
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
|
||||||
|
ComparisonOperator.LessThan =>
|
||||||
|
TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected,
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseNumber(string? value, out double number) =>
|
||||||
|
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||||
|
|
||||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||||
{
|
{
|
||||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -33,5 +33,10 @@ public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<Res
|
|||||||
public ResolveTemplateRouteRequestValidator()
|
public ResolveTemplateRouteRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
RuleForEach(x => x.FieldValues).ChildRules(v =>
|
||||||
|
{
|
||||||
|
v.RuleFor(f => f.FieldName).NotEmpty().WithMessage("Имя поля обязательно.");
|
||||||
|
v.RuleFor(f => f.Value).NotEmpty().WithMessage("Значение поля обязательно.");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+115
@@ -40,6 +40,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
private async Task<int> CreateRequestTypeAsync(string name) =>
|
private async Task<int> CreateRequestTypeAsync(string name) =>
|
||||||
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id;
|
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id;
|
||||||
|
|
||||||
|
private async Task<int> CreateRequestTypeWithListAsync(string name) =>
|
||||||
|
(await _requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest(name, $"/{name}", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"),
|
||||||
|
CancellationToken.None)).Value.Id;
|
||||||
|
|
||||||
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
|
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
|
||||||
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
|
||||||
{
|
{
|
||||||
@@ -226,6 +231,116 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
|
||||||
|
public async Task ResolveAsync_ConditionGreaterThan_SatisfiedByFieldValue_IncludesStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request satisfied");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
|
||||||
|
[new ResolveFieldValueDto("Amount", "1500")]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("big-amount@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута исключает шаг с условием, когда значение поля его не удовлетворяет")]
|
||||||
|
public async Task ResolveAsync_ConditionGreaterThan_NotSatisfiedByFieldValue_ExcludesStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request not satisfied");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
|
||||||
|
[new ResolveFieldValueDto("Amount", "500")]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("always@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута исключает шаг с условием, если значение поля вообще не передано")]
|
||||||
|
public async Task ResolveAsync_ConditionField_MissingFromRequest_ExcludesStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request missing value");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action — fieldValues не переданы вовсе
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с условием «равно» сравнивает значения как строки без учёта регистра")]
|
||||||
|
public async Task ResolveAsync_ConditionEqual_MatchesCaseInsensitive()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request equal");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", ComparisonOperator.Equal, "Urgent"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
|
||||||
|
[new ResolveFieldValueDto("Priority", "URGENT")]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("urgent@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
|
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
|
||||||
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
|
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
|
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
|
||||||
import { RequestType } from '../../models';
|
import { RequestType } from '../../models';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useSharePointLists } from '../../hooks/useSharePointLists';
|
import { useSharePointLists } from '../../hooks/useSharePointLists';
|
||||||
|
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import {
|
import {
|
||||||
@@ -35,12 +36,16 @@ interface IFormState {
|
|||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
section: string;
|
section: string;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
/** Заголовок сайта — только для отображения в триггере поиска, на бэкенд не уходит. */
|
|
||||||
siteTitle: string;
|
|
||||||
listName: 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 }) => {
|
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
|
||||||
const list = useRequestTypes(api);
|
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 [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
|
||||||
|
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
|
||||||
const searchSites = React.useCallback(
|
const searchSites = React.useCallback(
|
||||||
(query: string, page: number) => sharePoint.searchSites(query, page),
|
(query: string, page: number) => sharePoint.searchSites(query, page),
|
||||||
[sharePoint]
|
[sharePoint]
|
||||||
@@ -70,7 +76,6 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
pageUrl: item.pageUrl,
|
pageUrl: item.pageUrl,
|
||||||
section: item.section,
|
section: item.section,
|
||||||
siteUrl: item.siteUrl ?? '',
|
siteUrl: item.siteUrl ?? '',
|
||||||
siteTitle: item.siteUrl ?? '',
|
|
||||||
listName: item.listName ?? ''
|
listName: item.listName ?? ''
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
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: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
||||||
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
|
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
|
||||||
{ key: 'listName', header: texts.requestTypes.columnListName, render: r => r.listName ?? texts.common.empty },
|
{ 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',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -179,7 +184,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
|
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
|
||||||
<AsyncSearchableSelect<SharePointSiteSuggestion>
|
<AsyncSearchableSelect<SharePointSiteSuggestion>
|
||||||
value={form.siteUrl}
|
value={form.siteUrl}
|
||||||
selectedLabel={form.siteTitle || form.siteUrl}
|
selectedLabel={siteTitle.title ?? form.siteUrl}
|
||||||
|
selectedSublabel={siteTitle.title ? form.siteUrl : undefined}
|
||||||
allowClear
|
allowClear
|
||||||
clearLabel={texts.select.notSelected}
|
clearLabel={texts.select.notSelected}
|
||||||
placeholder={texts.requestTypes.siteUrlPlaceholder}
|
placeholder={texts.requestTypes.siteUrlPlaceholder}
|
||||||
@@ -188,7 +194,10 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
getValue={s => s.url}
|
getValue={s => s.url}
|
||||||
getLabel={s => s.title}
|
getLabel={s => s.title}
|
||||||
getSublabel={s => s.url}
|
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>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface IAsyncSearchableSelectProps<T> {
|
|||||||
value: string;
|
value: string;
|
||||||
/** Название выбранного значения — показываем в триггере без похода на сервер. */
|
/** Название выбранного значения — показываем в триггере без похода на сервер. */
|
||||||
selectedLabel?: string;
|
selectedLabel?: string;
|
||||||
|
/** Вторая строка триггера мельче и серым — например, url под названием сайта. */
|
||||||
|
selectedSublabel?: string;
|
||||||
onChange: (value: string, item: T | undefined) => void;
|
onChange: (value: string, item: T | undefined) => void;
|
||||||
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
|
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
|
||||||
getValue: (item: T) => string;
|
getValue: (item: T) => string;
|
||||||
@@ -30,6 +32,7 @@ export interface IAsyncSearchableSelectProps<T> {
|
|||||||
export function AsyncSearchableSelect<T>({
|
export function AsyncSearchableSelect<T>({
|
||||||
value,
|
value,
|
||||||
selectedLabel,
|
selectedLabel,
|
||||||
|
selectedSublabel,
|
||||||
onChange,
|
onChange,
|
||||||
search,
|
search,
|
||||||
getValue,
|
getValue,
|
||||||
@@ -166,9 +169,16 @@ export function AsyncSearchableSelect<T>({
|
|||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
{value && selectedSublabel ? (
|
||||||
{value ? (selectedLabel ?? value) : placeholder}
|
<span className={styles.valueStack}>
|
||||||
</span>
|
<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} />
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,31 @@
|
|||||||
white-space: nowrap;
|
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; }
|
.placeholder { color: $gray-400; }
|
||||||
|
|
||||||
.chevron { color: $gray-400; flex-shrink: 0; }
|
.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);
|
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 {
|
interface IListItem {
|
||||||
Id: string;
|
Id: string;
|
||||||
Title: string;
|
Title: string;
|
||||||
@@ -66,8 +71,33 @@ function clientFor(context: WebPartContext, siteUrl: string): SPFI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SharePointListsClient {
|
export class SharePointListsClient {
|
||||||
|
// Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем
|
||||||
|
// на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы
|
||||||
|
// или переоткрытие формы с тем же значением.
|
||||||
|
private readonly siteTitleCache = new Map<string, string | null>();
|
||||||
|
|
||||||
constructor(private readonly context: WebPartContext) {}
|
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) на указанном сайте. */
|
/** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
|
||||||
public async listLists(siteUrl: string): Promise<SharePointList[]> {
|
public async listLists(siteUrl: string): Promise<SharePointList[]> {
|
||||||
const sp = clientFor(this.context, siteUrl);
|
const sp = clientFor(this.context, siteUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user