This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string? SiteUrl, string Section);
|
public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
|
||||||
|
|
||||||
public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section);
|
public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
|
||||||
|
|
||||||
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section);
|
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
|
||||||
|
|
||||||
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? SiteUrl, string? Section);
|
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListId, string? SiteUrl, string? Section);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
{
|
{
|
||||||
var items = await db.RequestTypes.AsNoTracking()
|
var items = await db.RequestTypes.AsNoTracking()
|
||||||
.OrderBy(x => x.Name)
|
.OrderBy(x => x.Name)
|
||||||
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.SiteUrl, x.Section))
|
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
||||||
@@ -29,15 +29,15 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
|
|
||||||
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
|
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
|
if (!RequestTypeFields.IsListReferenceValid(request.ListId, request.SiteUrl))
|
||||||
return Result.Fail<RequestTypeDto>(new ValidationError(
|
return Result.Fail<RequestTypeDto>(new ValidationError(
|
||||||
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
var entity = new RequestType
|
var entity = new RequestType
|
||||||
{
|
{
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
PageUrl = request.PageUrl,
|
PageUrl = request.PageUrl,
|
||||||
ListName = request.ListName,
|
ListId = request.ListId,
|
||||||
SiteUrl = request.SiteUrl,
|
SiteUrl = request.SiteUrl,
|
||||||
Section = request.Section
|
Section = request.Section
|
||||||
};
|
};
|
||||||
@@ -53,13 +53,13 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
if (entity is null)
|
if (entity is null)
|
||||||
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
|
if (!RequestTypeFields.IsListReferenceValid(request.ListId, request.SiteUrl))
|
||||||
return Result.Fail<RequestTypeDto>(new ValidationError(
|
return Result.Fail<RequestTypeDto>(new ValidationError(
|
||||||
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
entity.Name = request.Name;
|
entity.Name = request.Name;
|
||||||
entity.PageUrl = request.PageUrl;
|
entity.PageUrl = request.PageUrl;
|
||||||
entity.ListName = request.ListName;
|
entity.ListId = request.ListId;
|
||||||
entity.SiteUrl = request.SiteUrl;
|
entity.SiteUrl = request.SiteUrl;
|
||||||
entity.Section = request.Section;
|
entity.Section = request.Section;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -73,16 +73,16 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
if (entity is null)
|
if (entity is null)
|
||||||
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
var listName = request.ListName ?? entity.ListName;
|
var listId = request.ListId ?? entity.ListId;
|
||||||
var siteUrl = request.SiteUrl ?? entity.SiteUrl;
|
var siteUrl = request.SiteUrl ?? entity.SiteUrl;
|
||||||
|
|
||||||
if (!RequestTypeFields.IsListReferenceValid(listName, siteUrl))
|
if (!RequestTypeFields.IsListReferenceValid(listId, siteUrl))
|
||||||
return Result.Fail<RequestTypeDto>(new ValidationError(
|
return Result.Fail<RequestTypeDto>(new ValidationError(
|
||||||
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
entity.Name = request.Name ?? entity.Name;
|
entity.Name = request.Name ?? entity.Name;
|
||||||
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
|
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
|
||||||
entity.ListName = listName;
|
entity.ListId = listId;
|
||||||
entity.SiteUrl = siteUrl;
|
entity.SiteUrl = siteUrl;
|
||||||
entity.Section = request.Section ?? entity.Section;
|
entity.Section = request.Section ?? entity.Section;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -102,5 +102,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.SiteUrl, e.Section);
|
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
|
|||||||
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
|
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
|
||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl))
|
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||||
.WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<Update
|
|||||||
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
|
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
|
||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl))
|
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||||
.WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +43,6 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRe
|
|||||||
|
|
||||||
internal static class RequestTypeFields
|
internal static class RequestTypeFields
|
||||||
{
|
{
|
||||||
public static bool IsListReferenceValid(string? listName, string? siteUrl) =>
|
public static bool IsListReferenceValid(string? listId, string? siteUrl) =>
|
||||||
string.IsNullOrWhiteSpace(listName) == string.IsNullOrWhiteSpace(siteUrl);
|
string.IsNullOrWhiteSpace(listId) == string.IsNullOrWhiteSpace(siteUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -46,9 +46,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
|
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
|
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||||
|
|
||||||
var duplicate = await HasDuplicateAsync(
|
var duplicate = await HasDuplicateAsync(
|
||||||
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
|
||||||
@@ -95,9 +95,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
|
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
|
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||||
|
|
||||||
var duplicate = await HasDuplicateAsync(
|
var duplicate = await HasDuplicateAsync(
|
||||||
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
|
||||||
@@ -155,9 +155,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||||
|
|
||||||
if (RequiresListName(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
|
if (RequiresList(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
|
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||||
|
|
||||||
var duplicate = await HasDuplicateAsync(
|
var duplicate = await HasDuplicateAsync(
|
||||||
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
|
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
|
||||||
@@ -192,7 +192,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool RequiresListName(string? fieldName, ComparisonOperator? operation, string? value) =>
|
private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) =>
|
||||||
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
|
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
|
||||||
|
|
||||||
private Task<bool> HasDuplicateAsync(
|
private Task<bool> HasDuplicateAsync(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public class RequestType
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string PageUrl { get; set; } = string.Empty;
|
public string PageUrl { get; set; } = string.Empty;
|
||||||
public string? ListName { get; set; }
|
public string? ListId { get; set; }
|
||||||
public string? SiteUrl { get; set; }
|
public string? SiteUrl { get; set; }
|
||||||
public string Section { get; set; } = string.Empty;
|
public string Section { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
e.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||||
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
|
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
|
||||||
e.Property(x => x.ListName).HasMaxLength(256);
|
e.Property(x => x.ListId).HasMaxLength(512);
|
||||||
e.Property(x => x.SiteUrl).HasMaxLength(512);
|
e.Property(x => x.SiteUrl).HasMaxLength(512);
|
||||||
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
e.Property(x => x.Section).HasMaxLength(256).IsRequired();
|
||||||
});
|
});
|
||||||
|
|||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||||
|
[Migration("20260803110943_RenameRequestTypeListNameToListId")]
|
||||||
|
partial class RenameRequestTypeListNameToListId
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("PageUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("page_url");
|
||||||
|
|
||||||
|
b.Property<string>("Section")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("section");
|
||||||
|
|
||||||
|
b.Property<string>("SiteUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("site_url");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_types");
|
||||||
|
|
||||||
|
b.ToTable("request_types", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("ByProject")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("by_project");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CompanyId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DepartmentId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<bool>("RequiresControl")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("requires_control");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
|
b.ToTable("template_routes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
|
b.Property<string>("ExecutorEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("FieldName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("field_name");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
|
b.Property<string>("Operation")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<int>("OrderNo")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
|
b.Property<string>("Position")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("position");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectRole")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
|
b.Property<int>("RouteId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_route_steps");
|
||||||
|
|
||||||
|
b.HasIndex("RouteId", "OrderNo")
|
||||||
|
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||||
|
|
||||||
|
b.ToTable("template_route_steps", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
|
.WithMany("Routes")
|
||||||
|
.HasForeignKey("RequestTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||||
|
|
||||||
|
b.Navigation("RequestType");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||||
|
.WithMany("Steps")
|
||||||
|
.HasForeignKey("RouteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||||
|
|
||||||
|
b.Navigation("Route");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Routes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Steps");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RenameRequestTypeListNameToListId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "list_name",
|
||||||
|
table: "request_types");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "list_id",
|
||||||
|
table: "request_types",
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "list_id",
|
||||||
|
table: "request_types");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "list_name",
|
||||||
|
table: "request_types",
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -31,10 +31,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("ListName")
|
b.Property<string>("ListId")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(512)")
|
||||||
.HasColumnName("list_name");
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
|
|||||||
+7
-7
@@ -74,7 +74,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
|
|||||||
|
|
||||||
// Action
|
// Action
|
||||||
var patched = await _service.PatchAsync(
|
var patched = await _service.PatchAsync(
|
||||||
created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None);
|
created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListId: null, SiteUrl: null, Section: null), CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
patched.IsSuccess.ShouldBeTrue();
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
@@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
|
|||||||
{
|
{
|
||||||
// Action
|
// Action
|
||||||
var result = await _service.PatchAsync(
|
var result = await _service.PatchAsync(
|
||||||
999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None);
|
999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListId: null, SiteUrl: null, Section: null), CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.IsFailed.ShouldBeTrue();
|
result.IsFailed.ShouldBeTrue();
|
||||||
@@ -109,8 +109,8 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
|
|||||||
fetched.IsFailed.ShouldBeTrue();
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Создание типа заявки с list_name без site_url возвращает неуспешный результат")]
|
[Fact(DisplayName = "Создание типа заявки с list_id без site_url возвращает неуспешный результат")]
|
||||||
public async Task CreateAsync_ListNameWithoutSiteUrl_ReturnsFailedResult()
|
public async Task CreateAsync_ListIdWithoutSiteUrl_ReturnsFailedResult()
|
||||||
{
|
{
|
||||||
// Action
|
// Action
|
||||||
var result = await _service.CreateAsync(
|
var result = await _service.CreateAsync(
|
||||||
@@ -120,8 +120,8 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
|
|||||||
result.IsFailed.ShouldBeTrue();
|
result.IsFailed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Создание типа заявки с list_name и site_url успешно")]
|
[Fact(DisplayName = "Создание типа заявки с list_id и site_url успешно")]
|
||||||
public async Task CreateAsync_ListNameWithSiteUrl_ReturnsCreatedEntity()
|
public async Task CreateAsync_ListIdWithSiteUrl_ReturnsCreatedEntity()
|
||||||
{
|
{
|
||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
@@ -131,7 +131,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
created.IsSuccess.ShouldBeTrue();
|
created.IsSuccess.ShouldBeTrue();
|
||||||
created.Value.ListName.ShouldBe("Requests");
|
created.Value.ListId.ShouldBe("Requests");
|
||||||
created.Value.SiteUrl.ShouldBe("https://tnsukz.sharepoint.com/sites/hrm");
|
created.Value.SiteUrl.ShouldBe("https://tnsukz.sharepoint.com/sites/hrm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
||||||
|
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
import {
|
import {
|
||||||
@@ -36,10 +37,10 @@ interface IFormState {
|
|||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
section: string;
|
section: string;
|
||||||
siteUrl: 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, хранящемуся на бэкенде. */
|
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
|
||||||
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
|
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}</>;
|
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 }) => {
|
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
|
||||||
const list = useRequestTypes(api);
|
const list = useRequestTypes(api);
|
||||||
|
|
||||||
@@ -76,7 +83,7 @@ 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 ?? '',
|
||||||
listName: item.listName ?? ''
|
listId: item.listId ?? ''
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
@@ -87,8 +94,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const siteUrl = form.siteUrl.trim();
|
const siteUrl = form.siteUrl.trim();
|
||||||
const listName = form.listName.trim();
|
const listId = form.listId.trim();
|
||||||
if (Boolean(siteUrl) !== Boolean(listName)) {
|
if (Boolean(siteUrl) !== Boolean(listId)) {
|
||||||
submit.fail(texts.requestTypes.validationListReference);
|
submit.fail(texts.requestTypes.validationListReference);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -98,7 +105,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
pageUrl: form.pageUrl.trim(),
|
pageUrl: form.pageUrl.trim(),
|
||||||
section: form.section.trim(),
|
section: form.section.trim(),
|
||||||
siteUrl: siteUrl || null,
|
siteUrl: siteUrl || null,
|
||||||
listName: listName || null
|
listId: listId || null
|
||||||
};
|
};
|
||||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
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 (
|
return (
|
||||||
<div>
|
<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: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||||
{ 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: '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: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -196,29 +204,29 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
getSublabel={s => s.url}
|
getSublabel={s => s.url}
|
||||||
onChange={(siteUrl, item) => {
|
onChange={(siteUrl, item) => {
|
||||||
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
|
if (siteUrl && item) sharePoint.cacheSiteTitle(siteUrl, item.title);
|
||||||
setForm({ ...form, siteUrl, listName: '' });
|
setForm({ ...form, siteUrl, listId: '' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label={texts.requestTypes.listNameLabel}
|
label={texts.requestTypes.listIdLabel}
|
||||||
full
|
full
|
||||||
hint={sharePointLists.error ? texts.requestTypes.listNameError(sharePointLists.error) : texts.requestTypes.listNameHint}
|
hint={sharePointLists.error ? texts.requestTypes.listIdError(sharePointLists.error) : texts.requestTypes.listIdHint}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={listNameOptions}
|
options={listIdOptions}
|
||||||
value={form.listName}
|
value={form.listId}
|
||||||
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
|
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
|
||||||
placeholder={
|
placeholder={
|
||||||
!form.siteUrl.trim()
|
!form.siteUrl.trim()
|
||||||
? texts.requestTypes.listNamePlaceholder
|
? texts.requestTypes.listIdPlaceholder
|
||||||
: sharePointLists.isLoading
|
: sharePointLists.isLoading
|
||||||
? texts.requestTypes.listNameLoading
|
? texts.requestTypes.listIdLoading
|
||||||
: listNameOptions.length === 0
|
: listIdOptions.length === 0
|
||||||
? texts.requestTypes.listNameEmpty
|
? texts.requestTypes.listIdEmpty
|
||||||
: texts.select.placeholder
|
: texts.select.placeholder
|
||||||
}
|
}
|
||||||
onChange={e => setForm({ ...form, listName: e.target.value })}
|
onChange={e => setForm({ ...form, listId: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
|
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
|
||||||
|
|
||||||
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||||
const hasListName = Boolean(requestType?.listName);
|
const hasListId = Boolean(requestType?.listId);
|
||||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
|
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||||
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||||
|
|
||||||
const resolved = useResolveRoute(api);
|
const resolved = useResolveRoute(api);
|
||||||
@@ -180,12 +180,12 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
<div className={styles.conditions}>
|
<div className={styles.conditions}>
|
||||||
<div className={styles.conditionsHeader}>
|
<div className={styles.conditionsHeader}>
|
||||||
<span className={styles.conditionsTitle}>{texts.resolve.conditionsLabel}</span>
|
<span className={styles.conditionsTitle}>{texts.resolve.conditionsLabel}</span>
|
||||||
{requestTypeId && !hasListName && (
|
{requestTypeId && !hasListId && (
|
||||||
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
|
<span className={styles.conditionsHint}>{texts.resolve.conditionsDisabledHint}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasListName && (
|
{hasListId && (
|
||||||
<>
|
<>
|
||||||
{fieldValues.map((fv, index) => {
|
{fieldValues.map((fv, index) => {
|
||||||
const field = sharePointFields.items.find(f => f.internalName === fv.fieldName);
|
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 [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
const hasListName = Boolean(requestType?.listName);
|
const hasListId = Boolean(requestType?.listId);
|
||||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
|
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId);
|
||||||
|
|
||||||
const fieldNameOptions = React.useMemo(() => {
|
const fieldNameOptions = React.useMemo(() => {
|
||||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||||
@@ -283,16 +283,16 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</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
|
<Checkbox
|
||||||
label={texts.routeSteps.conditionCheckbox}
|
label={texts.routeSteps.conditionCheckbox}
|
||||||
checked={form.hasCondition}
|
checked={form.hasCondition}
|
||||||
disabled={!hasListName}
|
disabled={!hasListId}
|
||||||
onChange={v => setForm({ ...form, hasCondition: v })}
|
onChange={v => setForm({ ...form, hasCondition: v })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{form.hasCondition && hasListName && (
|
{form.hasCondition && hasListId && (
|
||||||
<>
|
<>
|
||||||
<Field
|
<Field
|
||||||
label={texts.routeSteps.conditionFieldNameLabel}
|
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;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
/** Имя списка SharePoint (Title) — источник field_name для шагов маршрута. */
|
/** Id списка SharePoint (устойчив к переименованию Title) — источник field_name для шагов маршрута. */
|
||||||
listName: string | null;
|
listId: string | null;
|
||||||
/** Сайт SharePoint, где живёт listName. Заполнен тогда и только тогда, когда заполнен listName. */
|
/** Сайт SharePoint, где живёт listId. Заполнен тогда и только тогда, когда заполнен listId. */
|
||||||
siteUrl: string | null;
|
siteUrl: string | null;
|
||||||
section: string;
|
section: string;
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ export interface RequestType {
|
|||||||
export interface CreateRequestTypeInput {
|
export interface CreateRequestTypeInput {
|
||||||
name: string;
|
name: string;
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
listName: string | null;
|
listId: string | null;
|
||||||
siteUrl: string | null;
|
siteUrl: string | null;
|
||||||
section: string;
|
section: string;
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
|||||||
export interface PatchRequestTypeInput {
|
export interface PatchRequestTypeInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
pageUrl?: string;
|
pageUrl?: string;
|
||||||
listName?: string | null;
|
listId?: string | null;
|
||||||
siteUrl?: string | null;
|
siteUrl?: string | null;
|
||||||
section?: string;
|
section?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '@pnp/sp/fields';
|
|||||||
import '@pnp/sp/search';
|
import '@pnp/sp/search';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
|
||||||
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
|
// Списки, из которых типы заявок берут list_id/field_name, живут на произвольном
|
||||||
// сайте SharePoint (разном для разных типов заявок) — не обязательно на том, где
|
// сайте SharePoint (разном для разных типов заявок) — не обязательно на том, где
|
||||||
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
|
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
|
||||||
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
|
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
|
||||||
@@ -16,7 +16,12 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|||||||
// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url).
|
// поэтому там клиент строится под текущий сайт веб-части (SPFx(context) без url).
|
||||||
|
|
||||||
export interface SharePointList {
|
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;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +56,8 @@ export function formatSiteLabel(title: string | null | undefined, url: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface IListItem {
|
interface IListItem {
|
||||||
Id: string;
|
|
||||||
Title: string;
|
Title: string;
|
||||||
|
RootFolder: { ServerRelativeUrl: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFieldItem {
|
interface IFieldItem {
|
||||||
@@ -71,10 +76,11 @@ function clientFor(context: WebPartContext, siteUrl: string): SPFI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SharePointListsClient {
|
export class SharePointListsClient {
|
||||||
// Заголовок сайта по site_url бэкенд не хранит (только сам url) — кэшируем
|
// Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд
|
||||||
// на клиенте, чтобы не дёргать сайт заново на каждый рендер строки таблицы
|
// не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт
|
||||||
// или переоткрытие формы с тем же значением.
|
// заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением.
|
||||||
private readonly siteTitleCache = new Map<string, string | null>();
|
private readonly siteTitleCache = new Map<string, string | null>();
|
||||||
|
private readonly listTitleCache = new Map<string, string | null>();
|
||||||
|
|
||||||
constructor(private readonly context: WebPartContext) {}
|
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) на указанном сайте. */
|
/** Списки (не скрытые, обычные — 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);
|
||||||
const items = await sp.web.lists
|
const items = await sp.web.lists
|
||||||
.select('Id', 'Title')
|
.select('Title', 'RootFolder/ServerRelativeUrl')
|
||||||
|
.expand('RootFolder')
|
||||||
.filter('Hidden eq false and BaseTemplate eq 100')
|
.filter('Hidden eq false and BaseTemplate eq 100')
|
||||||
.orderBy('Title')() as IListItem[];
|
.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 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')
|
.select('InternalName', 'Title', 'TypeAsString')
|
||||||
.filter('Hidden eq false and ReadOnlyField eq false')
|
.filter('Hidden eq false and ReadOnlyField eq false')
|
||||||
.orderBy('Title')() as IFieldItem[];
|
.orderBy('Title')() as IFieldItem[];
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const texts = {
|
|||||||
columnName: 'Название',
|
columnName: 'Название',
|
||||||
columnPageUrl: 'Url страницы',
|
columnPageUrl: 'Url страницы',
|
||||||
columnSection: 'Раздел',
|
columnSection: 'Раздел',
|
||||||
columnListName: 'Список SharePoint',
|
columnListId: 'Список SharePoint',
|
||||||
columnSiteUrl: 'Сайт SharePoint',
|
columnSiteUrl: 'Сайт SharePoint',
|
||||||
createTitle: 'Новый тип заявки',
|
createTitle: 'Новый тип заявки',
|
||||||
editTitle: 'Редактировать тип заявки',
|
editTitle: 'Редактировать тип заявки',
|
||||||
@@ -118,12 +118,12 @@ export const texts = {
|
|||||||
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
|
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
|
||||||
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
|
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
|
||||||
siteUrlSearchPlaceholder: 'Поиск сайта по названию...',
|
siteUrlSearchPlaceholder: 'Поиск сайта по названию...',
|
||||||
listNameLabel: 'Список SharePoint',
|
listIdLabel: 'Список SharePoint',
|
||||||
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
|
listIdHint: 'Источник полей (field_name) для условий в шагах маршрута.',
|
||||||
listNamePlaceholder: 'Сначала укажите сайт SharePoint',
|
listIdPlaceholder: 'Сначала укажите сайт SharePoint',
|
||||||
listNameLoading: 'Загрузка списков сайта...',
|
listIdLoading: 'Загрузка списков сайта...',
|
||||||
listNameEmpty: 'На этом сайте нет доступных списков',
|
listIdEmpty: 'На этом сайте нет доступных списков',
|
||||||
listNameError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
|
listIdError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
|
||||||
validationRequired: 'Заполните имя, Url страницы и раздел.',
|
validationRequired: 'Заполните имя, Url страницы и раздел.',
|
||||||
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
|
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
|
||||||
deleteTitle: 'Удалить тип заявки?',
|
deleteTitle: 'Удалить тип заявки?',
|
||||||
|
|||||||
Reference in New Issue
Block a user