From 06b52c5186b70e2e8f630ef46a78c0b07fec5f3d Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 31 Jul 2026 12:51:48 +0500 Subject: [PATCH] =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestTypes/RequestTypeDto.cs | 8 +- .../RequestTypes/RequestTypeService.cs | 24 ++++- .../RequestTypes/RequestTypeValidators.cs | 12 +++ .../Entities/RequestType.cs | 1 + .../Persistence/ApprovalRouteDbContext.cs | 1 + ...SectionAndStepConditionFields.Designer.cs} | 7 +- ...eListNameSectionAndStepConditionFields.cs} | 11 +++ .../ApprovalRouteDbContextModelSnapshot.cs | 5 + .../RequestTypeServiceTests.cs | 44 +++++++-- .../TemplateRouteServiceTests.cs | 10 +- .../TemplateRouteStepServiceTests.cs | 3 +- frontend/package-lock.json | 71 ++++++++++++++ frontend/package.json | 1 + .../components/ApprovalAdmin.tsx | 3 + .../approvalAdmin/components/app/AdminApp.tsx | 18 ++-- .../components/screens/RequestTypesScreen.tsx | 75 +++++++++++++- .../components/screens/RouteStepFormModal.tsx | 98 ++++++++++++++++++- .../components/screens/RouteStepsScreen.tsx | 12 ++- .../hooks/useSharePointFields.ts | 41 ++++++++ .../approvalAdmin/hooks/useSharePointLists.ts | 45 +++++++++ .../webparts/approvalAdmin/models/enums.ts | 7 ++ .../approvalAdmin/models/requestTypes.ts | 11 +++ .../models/templateRouteSteps.ts | 13 ++- .../approvalAdmin/services/sharePointLists.ts | 61 ++++++++++++ frontend/src/webparts/approvalAdmin/texts.ts | 36 ++++++- 25 files changed, 573 insertions(+), 45 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs => 20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs} (96%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs => 20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs} (86%) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useSharePointFields.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useSharePointLists.ts create mode 100644 frontend/src/webparts/approvalAdmin/services/sharePointLists.ts diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index 96e0e5e..59b6536 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -1,9 +1,9 @@ namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string Section); +public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string Section); +public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string Section); +public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? Section); +public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? SiteUrl, string? Section); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 999ccf0..83aface 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) { var items = await db.RequestTypes.AsNoTracking() .OrderBy(x => x.Name) - .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.Section)) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.SiteUrl, x.Section)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -29,11 +29,16 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) public async Task> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) { + if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl, ListName = request.ListName, + SiteUrl = request.SiteUrl, Section = request.Section }; db.RequestTypes.Add(entity); @@ -48,9 +53,14 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); + if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + entity.Name = request.Name; entity.PageUrl = request.PageUrl; entity.ListName = request.ListName; + entity.SiteUrl = request.SiteUrl; entity.Section = request.Section; await db.SaveChangesAsync(cancellationToken); @@ -63,9 +73,17 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); + var listName = request.ListName ?? entity.ListName; + var siteUrl = request.SiteUrl ?? entity.SiteUrl; + + if (!RequestTypeFields.IsListReferenceValid(listName, siteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + entity.Name = request.Name ?? entity.Name; entity.PageUrl = request.PageUrl ?? entity.PageUrl; - entity.ListName = request.ListName ?? entity.ListName; + entity.ListName = listName; + entity.SiteUrl = siteUrl; entity.Section = request.Section ?? entity.Section; await db.SaveChangesAsync(cancellationToken); @@ -84,5 +102,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(); } - private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.Section); + private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.SiteUrl, e.Section); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index 5a14f9c..33852e9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -9,6 +9,9 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); + RuleFor(x => x) + .Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl)) + .WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); } } @@ -19,6 +22,9 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); + RuleFor(x => x) + .Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl)) + .WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); } } @@ -34,3 +40,9 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator x.Section).NotEmpty().WithMessage("Раздел не может быть пустым.")); } } + +internal static class RequestTypeFields +{ + public static bool IsListReferenceValid(string? listName, string? siteUrl) => + string.IsNullOrWhiteSpace(listName) == string.IsNullOrWhiteSpace(siteUrl); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index dfcb7d6..5b9f8bf 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -6,6 +6,7 @@ public class RequestType public string Name { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty; public string? ListName { get; set; } + public string? SiteUrl { get; set; } public string Section { get; set; } = string.Empty; public ICollection Routes { get; set; } = new List(); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 401fa69..4f4bb5d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -17,6 +17,7 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.Name).HasMaxLength(256).IsRequired(); e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); e.Property(x => x.ListName).HasMaxLength(256); + e.Property(x => x.SiteUrl).HasMaxLength(512); e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs similarity index 96% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs index d62004d..a49cb0c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260731062407_AddRequestTypeListNameSectionAndStepConditionFields")] + [Migration("20260731073536_AddRequestTypeListNameSectionAndStepConditionFields")] partial class AddRequestTypeListNameSectionAndStepConditionFields { /// @@ -57,6 +57,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("section"); + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + b.HasKey("Id") .HasName("pk_request_types"); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs similarity index 86% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs index f349663..d6dfe6e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731062407_AddRequestTypeListNameSectionAndStepConditionFields.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731073536_AddRequestTypeListNameSectionAndStepConditionFields.cs @@ -45,6 +45,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations maxLength: 256, nullable: false, defaultValue: ""); + + migrationBuilder.AddColumn( + name: "site_url", + table: "request_types", + type: "character varying(512)", + maxLength: 512, + nullable: true); } /// @@ -69,6 +76,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations migrationBuilder.DropColumn( name: "section", table: "request_types"); + + migrationBuilder.DropColumn( + name: "site_url", + table: "request_types"); } } } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index fed778a..73488db 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -54,6 +54,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("section"); + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + b.HasKey("Id") .HasName("pk_request_types"); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index e46fa47..d861696 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -13,7 +13,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() { // Action - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation", null, null, "General"), CancellationToken.None); // Assert created.IsSuccess.ShouldBeTrue(); @@ -43,11 +43,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task UpdateAsync_ChangesFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old", null, null, "General"), CancellationToken.None); // Action var updated = await _service.UpdateAsync( - created.Value.Id, new UpdateRequestTypeRequest("New name", "/new", null, "General"), CancellationToken.None); + created.Value.Id, new UpdateRequestTypeRequest("New name", "/new", null, null, "General"), CancellationToken.None); // Assert updated.IsSuccess.ShouldBeTrue(); @@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.UpdateAsync( - 999999, new UpdateRequestTypeRequest("X", "/x", null, "General"), CancellationToken.None); + 999999, new UpdateRequestTypeRequest("X", "/x", null, null, "General"), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -70,11 +70,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task PatchAsync_UpdatesOnlyProvidedFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original", null, null, "General"), CancellationToken.None); // Action var patched = await _service.PatchAsync( - created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, Section: null), CancellationToken.None); + created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None); // Assert patched.IsSuccess.ShouldBeTrue(); @@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.PatchAsync( - 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, Section: null), CancellationToken.None); + 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -97,7 +97,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task DeleteAsync_RemovesEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp", null, null, "General"), CancellationToken.None); // Action var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); @@ -109,11 +109,37 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I fetched.IsFailed.ShouldBeTrue(); } + [Fact(DisplayName = "Создание типа заявки с list_name без site_url возвращает неуспешный результат")] + public async Task CreateAsync_ListNameWithoutSiteUrl_ReturnsFailedResult() + { + // Action + var result = await _service.CreateAsync( + new CreateRequestTypeRequest("Missing site url", "/missing-site-url", "Requests", null, "General"), CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Создание типа заявки с list_name и site_url успешно")] + public async Task CreateAsync_ListNameWithSiteUrl_ReturnsCreatedEntity() + { + // Action + var created = await _service.CreateAsync( + new CreateRequestTypeRequest( + "With list reference", "/with-list-reference", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.ListName.ShouldBe("Requests"); + created.Value.SiteUrl.ShouldBe("https://tnsukz.sharepoint.com/sites/hrm"); + } + [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")] public async Task ListAsync_IncludesCreatedEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed", null, "General"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed", null, null, "General"), CancellationToken.None); // Action var list = await _service.ListAsync(CancellationToken.None); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 471ce96..101fd69 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -38,7 +38,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture CreateRequestTypeAsync(string name) => - (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, "General"), CancellationToken.None)).Value.Id; + (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id; [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() @@ -196,7 +196,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); @@ -272,7 +272,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Position request", "/position-request", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Position request", "/position-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); @@ -311,7 +311,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Chief request", "/chief-request", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Chief request", "/chief-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); @@ -346,7 +346,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 8efbe4a..7586761 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -26,8 +26,9 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture CreateRouteAsync(string requestTypeName, bool byProject, string? listName = null) { + var siteUrl = listName is null ? null : "https://example.sharepoint.com/sites/test"; var requestTypeId = (await _requestTypeService.CreateAsync( - new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, "General"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, siteUrl, "General"), CancellationToken.None)).Value.Id; var route = await _routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a64856d..0a4f4b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,10 +11,12 @@ "@fluentui/react": "^8.106.4", "@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-core-library": "1.22.1", + "@microsoft/sp-http": "1.22.1", "@microsoft/sp-lodash-subset": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1", + "@pnp/sp": "^4.20.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" @@ -5051,6 +5053,75 @@ "dev": true, "license": "0BSD" }, + "node_modules/@pnp/core": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/core/-/core-4.20.0.tgz", + "integrity": "sha512-Pq421YYRxAEF3PEKSnhIpz1M5yqmehdmidn+0ImYKixI9V2UQgoWUohcb7bA2DiHXcM8HJl2nF/62PUmBUVAaQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@pnp/queryable": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/queryable/-/queryable-4.20.0.tgz", + "integrity": "sha512-JfnNrwPUYMP76gBx6rlVM4BGfsA+bU8bVYTY+RyHRPT4S0SIX2w6witvWHKTC0Ara9/JGGnbCI3LyQJ4457nGg==", + "license": "MIT", + "dependencies": { + "@pnp/core": "4.20.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/queryable/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@pnp/sp": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/sp/-/sp-4.20.0.tgz", + "integrity": "sha512-vz+45igqipK9ZJj/jzRMpK6OqI2Szi1Z48J9QtLCBeZVxT0IwKMT/HMkZca03cQL7IDRPSjq27DYcXQRYeRw3w==", + "license": "MIT", + "dependencies": { + "@pnp/core": "4.20.0", + "@pnp/queryable": "4.20.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/sp/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@rushstack/debug-certificate-manager": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7e04f47..dfde9d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@fluentui/react": "^8.106.4", + "@pnp/sp": "^4.20.0", "@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-http": "1.22.1", diff --git a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx index 7064d0c..63902c0 100644 --- a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx @@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp'; import { ApprovalRouteApi } from '../services/api'; import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { PortalDictionaryClient } from '../services/portalDictionary'; +import { SharePointListsClient } from '../services/sharePointLists'; import { texts } from '../texts'; const FONTS_LINK_ID = 'tnsu-approval-admin-fonts'; @@ -30,6 +31,7 @@ const ApprovalAdmin: React.FC = props => { const [api, setApi] = React.useState(); const [dictionary, setDictionary] = React.useState(); const [error, setError] = React.useState(); + const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]); React.useEffect(() => { ensureFontsLoaded(); @@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC = props => { )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 514922f..a2b645f 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -4,7 +4,8 @@ import { TopNav } from './TopNav'; import { ScreenKey } from './types'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; -import { TemplateRoute } from '../../models'; +import { SharePointListsClient } from '../../services/sharePointLists'; +import { RequestType, TemplateRoute } from '../../models'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RoutesScreen } from '../screens/RoutesScreen'; @@ -15,13 +16,14 @@ export interface IAdminAppProps { api: ApprovalRouteApi; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; + sharePoint: SharePointListsClient; currentUserEmail?: string; } -export const AdminApp: React.FC = ({ api, dictionary, currentUserEmail }) => { +export const AdminApp: React.FC = ({ api, dictionary, sharePoint, currentUserEmail }) => { const [screen, setScreen] = React.useState('dashboard'); const [selectedRoute, setSelectedRoute] = React.useState(); - const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState(''); + const [selectedRequestType, setSelectedRequestType] = React.useState(); const navigate = (key: ScreenKey): void => { setScreen(key); @@ -29,9 +31,10 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse const openSteps = (route: TemplateRoute): void => { setSelectedRoute(route); + setSelectedRequestType(undefined); api.requestTypes.get(route.requestTypeId) - .then(rt => setSelectedRequestTypeName(rt.name)) - .catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`)); + .then(setSelectedRequestType) + .catch(() => setSelectedRequestType(undefined)); setScreen('routeSteps'); }; @@ -40,14 +43,15 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse
{screen === 'dashboard' && } - {screen === 'requestTypes' && } + {screen === 'requestTypes' && } {screen === 'routes' && } {screen === 'routeSteps' && selectedRoute && ( setScreen('routes')} /> )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx index 2fcb723..a6c8cd4 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import { ApprovalRouteApi } from '../../services/api'; +import { SharePointListsClient } from '../../services/sharePointLists'; import { RequestType } from '../../models'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; +import { useSharePointLists } from '../../hooks/useSharePointLists'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { @@ -18,27 +20,33 @@ import { IconButton, PageHeader, PrimaryCell, + Select, TextInput } from '../ui'; export interface IRequestTypesScreenProps { api: ApprovalRouteApi; + sharePoint: SharePointListsClient; } interface IFormState { name: string; pageUrl: string; + section: string; + siteUrl: string; + listName: string; } -const EMPTY_FORM: IFormState = { name: '', pageUrl: '' }; +const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' }; -export const RequestTypesScreen: React.FC = ({ api }) => { +export const RequestTypesScreen: React.FC = ({ api, sharePoint }) => { const list = useRequestTypes(api); const [editing, setEditing] = React.useState(); const [showForm, setShowForm] = React.useState(false); const [form, setForm] = React.useState(EMPTY_FORM); const submit = useAsyncSubmit(); + const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); const deleteTarget = useDeleteTarget(item => api.requestTypes.remove(item.id), list.reload); @@ -50,16 +58,35 @@ export const RequestTypesScreen: React.FC = ({ api }) const openEdit = (item: RequestType): void => { setEditing(item); - setForm({ name: item.name, pageUrl: item.pageUrl }); + setForm({ + name: item.name, + pageUrl: item.pageUrl, + section: item.section, + siteUrl: item.siteUrl ?? '', + listName: item.listName ?? '' + }); setShowForm(true); }; const submitForm = (): void => { - if (!form.name.trim() || !form.pageUrl.trim()) { + if (!form.name.trim() || !form.pageUrl.trim() || !form.section.trim()) { submit.fail(texts.requestTypes.validationRequired); return; } - const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() }; + const siteUrl = form.siteUrl.trim(); + const listName = form.listName.trim(); + if (Boolean(siteUrl) !== Boolean(listName)) { + submit.fail(texts.requestTypes.validationListReference); + return; + } + + const input = { + name: form.name.trim(), + pageUrl: form.pageUrl.trim(), + section: form.section.trim(), + siteUrl: siteUrl || null, + listName: listName || null + }; const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input); submit.run(() => @@ -70,6 +97,8 @@ export const RequestTypesScreen: React.FC = ({ api }) ); }; + const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title })); + return (
@@ -87,6 +116,7 @@ export const RequestTypesScreen: React.FC = ({ api }) columns={[ { key: 'name', header: texts.requestTypes.columnName, render: r => {r.name} }, { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, + { key: 'section', header: texts.requestTypes.columnSection, render: r => r.section }, { key: 'actions', header: '', @@ -129,6 +159,41 @@ export const RequestTypesScreen: React.FC = ({ api }) placeholder={texts.requestTypes.pageUrlPlaceholder} /> + + setForm({ ...form, section: e.target.value })} + placeholder={texts.requestTypes.sectionPlaceholder} + /> + + + setForm({ ...form, siteUrl: e.target.value, listName: '' })} + placeholder={texts.requestTypes.siteUrlPlaceholder} + /> + + + setForm({ ...form, fieldName: e.target.value })} + /> + + +