From 8b6330aef03836f73c22ff454a091540fd1e309b Mon Sep 17 00:00:00 2001 From: Manuel Rodriguez Date: Wed, 8 Nov 2023 14:10:28 -0800 Subject: [PATCH] Allow Products with multiple projects (#3578) * Updated backend and frontend to allow to reuse products on multiple projects * Fixed delete flow * fixed lint * PR comments and fixes * Feedback enhancements * Updated test snaps --- .../Projects/Controllers/ProjectController.cs | 10 +- .../api/Models/Concepts/Product/ProductMap.cs | 5 +- .../Models/Concepts/Product/ProductModel.cs | 13 +- .../api/Models/Concepts/Project/ProjectMap.cs | 4 +- .../Models/Concepts/Project/ProjectModel.cs | 6 +- .../Concepts/Project/ProjectProductMap.cs | 26 ++++ .../Concepts/Project/ProjectProductModel.cs | 24 ++++ .../backend/api/Services/IProjectService.cs | 9 +- source/backend/api/Services/ProjectService.cs | 97 +++++++++++--- .../dal/Exceptions/OverrideExceptions.cs | 17 ++- .../Interfaces/IProductRepository.cs | 4 + .../dal/Repositories/ProductRepository.cs | 28 +++- .../dal/Repositories/ProjectRepository.cs | 24 +++- .../Projects/ProjectControllerTest.cs | 11 +- .../unit/api/Services/ProjectServiceTest.cs | 125 ++++++------------ .../project/add/AddProjectContainer.test.tsx | 2 +- .../project/add/AddProjectContainer.tsx | 12 +- .../mapSideBar/project/add/ProductSubForm.tsx | 6 +- .../AddProjectForm.test.tsx.snap | 4 + .../hooks/useAddProjectFormManagement.tsx | 10 +- .../src/features/mapSideBar/project/models.ts | 25 +++- .../detail/ProjectProductView.tsx | 10 +- .../detail/ProjectSummaryView.tsx | 10 +- .../update/UpdateProjectContainer.test.tsx | 25 +--- .../update/UpdateProjectContainer.tsx | 48 ++++--- .../activity/edit/InvoiceForm.tsx | 2 +- .../tabs/takes/detail/TakesDetailView.tsx | 26 ++-- .../tabs/takes/update/TakeSubForm.tsx | 3 +- .../TakesUpdateForm.test.tsx.snap | 10 +- .../src/hooks/pims-api/useApiProjects.ts | 15 ++- .../hooks/repositories/useProjectProvider.ts | 18 ++- .../src/mocks/acquisitionFiles.mock.ts | 5 +- source/frontend/src/mocks/projects.mock.ts | 64 +++++---- .../frontend/src/mocks/researchFile.mock.ts | 2 +- .../src/models/api/AcquisitionFile.ts | 1 + source/frontend/src/models/api/Project.ts | 26 ++-- .../src/models/api/UserOverrideCode.ts | 1 + 37 files changed, 449 insertions(+), 279 deletions(-) create mode 100644 source/backend/api/Models/Concepts/Project/ProjectProductMap.cs create mode 100644 source/backend/api/Models/Concepts/Project/ProjectProductModel.cs diff --git a/source/backend/api/Areas/Projects/Controllers/ProjectController.cs b/source/backend/api/Areas/Projects/Controllers/ProjectController.cs index e31697a3ee..869e124e87 100644 --- a/source/backend/api/Areas/Projects/Controllers/ProjectController.cs +++ b/source/backend/api/Areas/Projects/Controllers/ProjectController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using MapsterMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,6 +9,7 @@ using Pims.Api.Services; using Pims.Core.Exceptions; using Pims.Core.Json; +using Pims.Dal.Exceptions; using Pims.Dal.Security; using Swashbuckle.AspNetCore.Annotations; @@ -113,11 +115,11 @@ public IActionResult GetAll() [ProducesResponseType(typeof(ProjectModel), 200)] [ProducesResponseType(typeof(Api.Models.ErrorResponseModel), 400)] [SwaggerOperation(Tags = new[] { "project" })] - public IActionResult AddProject(ProjectModel projectModel) + public IActionResult AddProject(ProjectModel projectModel, [FromQuery] string[] userOverrideCodes) { try { - var newProject = _projectService.Add(_mapper.Map(projectModel)); + var newProject = _projectService.Add(_mapper.Map(projectModel), userOverrideCodes.Select(oc => UserOverrideCode.Parse(oc))); return new JsonResult(_mapper.Map(newProject)); } catch (DuplicateEntityException e) @@ -135,7 +137,7 @@ public IActionResult AddProject(ProjectModel projectModel) [Produces("application/json")] [ProducesResponseType(typeof(ProjectModel), 200)] [SwaggerOperation(Tags = new[] { "project" })] - public IActionResult UpdateProject([FromRoute] long id, [FromBody] ProjectModel model) + public IActionResult UpdateProject([FromRoute] long id, [FromBody] ProjectModel model, [FromQuery] string[] userOverrideCodes) { if (id != model.Id) { @@ -144,7 +146,7 @@ public IActionResult UpdateProject([FromRoute] long id, [FromBody] ProjectModel try { - var updatedProject = _projectService.Update(_mapper.Map(model)); + var updatedProject = _projectService.Update(_mapper.Map(model), userOverrideCodes.Select(oc => UserOverrideCode.Parse(oc))); return new JsonResult(updatedProject); } catch (DuplicateEntityException e) diff --git a/source/backend/api/Models/Concepts/Product/ProductMap.cs b/source/backend/api/Models/Concepts/Product/ProductMap.cs index 8069898bbc..982abc60cc 100644 --- a/source/backend/api/Models/Concepts/Product/ProductMap.cs +++ b/source/backend/api/Models/Concepts/Product/ProductMap.cs @@ -9,8 +9,7 @@ public void Register(TypeAdapterConfig config) { config.NewConfig() .Map(dest => dest.Id, src => src.Internal_Id) - //.Map(dest => dest.ParentProject, src => src.ParentProject) // Todo: Fix This. - //.Map(dest => dest.ParentProjectId, src => src.ParentProjectId) // Todo: Fix This. + .Map(dest => dest.ProjectProducts, src => src.PimsProjectProducts) .Map(dest => dest.AcquisitionFiles, src => src.PimsAcquisitionFiles) .Map(dest => dest.Code, src => src.Code) .Map(dest => dest.Description, src => src.Description) @@ -23,7 +22,7 @@ public void Register(TypeAdapterConfig config) config.NewConfig() .Map(dest => dest.Internal_Id, src => src.Id) - //.Map(dest => dest.ParentProjectId, src => src.ParentProjectId) // Todo: Fix This. + .Map(dest => dest.PimsProjectProducts, src => src.ProjectProducts) .Map(dest => dest.PimsAcquisitionFiles, src => src.AcquisitionFiles) .Map(dest => dest.Code, src => src.Code) .Map(dest => dest.Description, src => src.Description) diff --git a/source/backend/api/Models/Concepts/Product/ProductModel.cs b/source/backend/api/Models/Concepts/Product/ProductModel.cs index 2470b6dfd9..5e577d2d66 100644 --- a/source/backend/api/Models/Concepts/Product/ProductModel.cs +++ b/source/backend/api/Models/Concepts/Product/ProductModel.cs @@ -3,6 +3,10 @@ namespace Pims.Api.Models.Concepts { + /* + * Front end model + * LINK @frontend/src\models\api\Project.ts + */ public class ProductModel : BaseAppModel { #region Properties @@ -13,14 +17,9 @@ public class ProductModel : BaseAppModel public long? Id { get; set; } /// - /// get/set - The project associated to this product. + /// get/set - The project associations to this product. /// - public virtual ProjectModel ParentProject { get; set; } - - /// - /// get/set - The project's id. - /// - public long? ParentProjectId { get; set; } + public List ProjectProducts { get; set; } /// /// get/set - The product associated files. diff --git a/source/backend/api/Models/Concepts/Project/ProjectMap.cs b/source/backend/api/Models/Concepts/Project/ProjectMap.cs index 5b2d901694..e3bd6ba889 100644 --- a/source/backend/api/Models/Concepts/Project/ProjectMap.cs +++ b/source/backend/api/Models/Concepts/Project/ProjectMap.cs @@ -17,7 +17,7 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.Code, src => src.Code) .Map(dest => dest.Description, src => src.Description) .Map(dest => dest.Note, src => src.Note) - //.Map(dest => dest.Products, src => src.PimsProducts) // TODO: Fix this. + .Map(dest => dest.ProjectProducts, src => src.PimsProjectProducts) .Map(dest => dest.AppLastUpdateUserid, src => src.AppLastUpdateUserid) .Map(dest => dest.AppLastUpdateTimestamp, src => src.AppLastUpdateTimestamp) .Inherits(); @@ -32,7 +32,7 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.Code, src => src.Code) .Map(dest => dest.Description, src => src.Description) .Map(dest => dest.Note, src => src.Note) - //.Map(dest => dest.PimsProducts, src => src.Products) // TODO: Fix this. + .Map(dest => dest.PimsProjectProducts, src => src.ProjectProducts) .Inherits(); } } diff --git a/source/backend/api/Models/Concepts/Project/ProjectModel.cs b/source/backend/api/Models/Concepts/Project/ProjectModel.cs index 180790c186..16c6488ee1 100644 --- a/source/backend/api/Models/Concepts/Project/ProjectModel.cs +++ b/source/backend/api/Models/Concepts/Project/ProjectModel.cs @@ -2,6 +2,10 @@ namespace Pims.Api.Models.Concepts { + /* + * Frontend model + * LINK @frontend/src\models\api\Project.ts:10 + */ public class ProjectModel : BaseAppModel { #region Properties @@ -54,7 +58,7 @@ public class ProjectModel : BaseAppModel /// /// get/set - Project products. /// - public List Products { get; set; } + public List ProjectProducts { get; set; } #endregion } } diff --git a/source/backend/api/Models/Concepts/Project/ProjectProductMap.cs b/source/backend/api/Models/Concepts/Project/ProjectProductMap.cs new file mode 100644 index 0000000000..fd340121b8 --- /dev/null +++ b/source/backend/api/Models/Concepts/Project/ProjectProductMap.cs @@ -0,0 +1,26 @@ +using Mapster; +using Entity = Pims.Dal.Entities; + +namespace Pims.Api.Models.Concepts +{ + public class ProjectProductMap : IRegister + { + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .Map(dest => dest.Id, src => src.ProjectProductId) + .Map(dest => dest.ProjectId, src => src.ProjectId) + .Map(dest => dest.Product, src => src.Product) + .Map(dest => dest.ProductId, src => src.ProductId) + .Map(dest => dest.Project, src => src.Project) + .Inherits(); + + config.NewConfig() + .Map(dest => dest.ProjectProductId, src => src.Id) + .Map(dest => dest.ProjectId, src => src.ProjectId) + .Map(dest => dest.ProductId, src => src.Product.Id) + .Map(dest => dest.Product, src => src.Product) + .Inherits(); + } + } +} diff --git a/source/backend/api/Models/Concepts/Project/ProjectProductModel.cs b/source/backend/api/Models/Concepts/Project/ProjectProductModel.cs new file mode 100644 index 0000000000..60977c9e41 --- /dev/null +++ b/source/backend/api/Models/Concepts/Project/ProjectProductModel.cs @@ -0,0 +1,24 @@ + +namespace Pims.Api.Models.Concepts +{ + /* + * Frontend model + * LINK @frontend/src\models\api\Project.ts + */ + public class ProjectProductModel : BaseAppModel + { + #region Properties + + public long Id { get; set; } + + public long ProjectId { get; set; } + + public ProductModel Product { get; set; } + + public long ProductId { get; set; } + + public ProjectModel Project { get; set; } + + #endregion + } +} diff --git a/source/backend/api/Services/IProjectService.cs b/source/backend/api/Services/IProjectService.cs index 978d31415a..9ec17de99e 100644 --- a/source/backend/api/Services/IProjectService.cs +++ b/source/backend/api/Services/IProjectService.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Pims.Dal.Entities; using Pims.Dal.Entities.Models; +using Pims.Dal.Exceptions; namespace Pims.Api.Services { @@ -13,14 +14,14 @@ public interface IProjectService Task> GetPage(ProjectFilter filter); - PimsProject Add(PimsProject project); - PimsProject GetById(long projectId); IList GetProducts(long projectId); List GetProductFiles(long productId); - PimsProject Update(PimsProject project); + PimsProject Add(PimsProject project, IEnumerable userOverrides); + + PimsProject Update(PimsProject project, IEnumerable userOverrides); } -} +} \ No newline at end of file diff --git a/source/backend/api/Services/ProjectService.cs b/source/backend/api/Services/ProjectService.cs index b4411ef377..06a7800eea 100644 --- a/source/backend/api/Services/ProjectService.cs +++ b/source/backend/api/Services/ProjectService.cs @@ -3,11 +3,12 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; -using Pims.Core.Exceptions; using Pims.Core.Extensions; using Pims.Dal.Entities; using Pims.Dal.Entities.Models; +using Pims.Dal.Exceptions; using Pims.Dal.Helpers.Extensions; using Pims.Dal.Repositories; using Pims.Dal.Security; @@ -125,7 +126,7 @@ public List GetProductFiles(long productId) return _acquisitionFileRepository.GetByProductId(productId); } - public PimsProject Add(PimsProject project) + public PimsProject Add(PimsProject project, IEnumerable userOverrides) { _user.ThrowIfNotAuthorized(Permissions.ProjectAdd); _logger.LogInformation("Adding new project..."); @@ -134,8 +135,12 @@ public PimsProject Add(PimsProject project) throw new ArgumentNullException(nameof(project), "Project cannot be null."); } - // TODO: Verify this is not needed - //CheckForDuplicateProducts(project.PimsProducts, project.Id); + var externalProducts = MatchProducts(project); + if (externalProducts.Count > 0 && !userOverrides.Contains(UserOverrideCode.ProductReuse)) + { + var names = externalProducts.Select(x => $"{x.Code} {x.Description}"); + throw new UserOverrideException(UserOverrideCode.ProductReuse, $"The product(s) {string.Join(",", names)} can also be found in one or more other projects. Please verify the correct product is being added"); + } var newProject = _projectRepository.Add(project); _projectRepository.CommitTransaction(); @@ -143,35 +148,96 @@ public PimsProject Add(PimsProject project) return _projectRepository.Get(newProject.Internal_Id); } - public PimsProject Update(PimsProject project) + public PimsProject Update(PimsProject project, IEnumerable userOverrides) { _user.ThrowIfNotAuthorized(Permissions.ProjectEdit); project.ThrowIfNull(nameof(project)); _logger.LogInformation($"Updating project with id ${project.Internal_Id}"); - // TODO: Verify this is not needed - //CheckForDuplicateProducts(project.PimsProducts, project.Id); + var externalProducts = MatchProducts(project); + if (externalProducts.Count > 0 && !userOverrides.Contains(UserOverrideCode.ProductReuse)) + { + var names = externalProducts.Select(x => $"{x.Code} {x.Description}"); + throw new UserOverrideException(UserOverrideCode.ProductReuse, $"The product(s) {string.Join(",", names)} can also be found in one or more other projects. Please verify the correct product is being added"); + } + var updatedProject = _projectRepository.Update(project); + AddNoteIfStatusChanged(project); _projectRepository.CommitTransaction(); return updatedProject; } - /*private void CheckForDuplicateProducts(IEnumerable products, long projectId) + /* + * Updates the passed project with the matched products in place. + * Note: The return list contains the external products matched + */ + private List MatchProducts(PimsProject project) { - var duplicateProductsInArray = products.GroupBy(p => (p.Code, p.Description)).Where(g => g.Count() > 1).Select(g => g.Key); - if (duplicateProductsInArray.Any()) + var existingProjectProducts = _productRepository.GetProjectProductsByProject(project.Id); + + var matchedProjectProducts = new List(); + var notMatched = new List(); + + var externalProducts = new List(); + + // Try to match from the existing relationship by product code and description + foreach (var projectProduct in project.PimsProjectProducts) { - throw new DuplicateEntityException($"Unable to add project with duplicated product codes: {string.Join(", ", duplicateProductsInArray.Select(dp => dp.Code))}"); + var existing = existingProjectProducts.FirstOrDefault(x => x.Product.Code == projectProduct.Product.Code && x.Product.Description == projectProduct.Product.Description); + if (existing != null) + { + // Manually update the members with the new data + existing.Product.StartDate = projectProduct.Product.StartDate; + existing.Product.CostEstimate = projectProduct.Product.CostEstimate; + existing.Product.CostEstimateDate = projectProduct.Product.CostEstimateDate; + existing.Product.Objective = projectProduct.Product.Objective; + existing.Product.Scope = projectProduct.Product.Scope; + + projectProduct.Product = existing.Product; + + matchedProjectProducts.Add(existing); + } + else + { + notMatched.Add(projectProduct); + } } - IEnumerable duplicateProducts = _productRepository.GetByProductBatch(products, projectId); - if (duplicateProducts.Any()) + var existingProducts = _productRepository.GetProducts(notMatched.Select(x => x.Product)); + + // Try to match from the existing products code and description + foreach (var projectProduct in notMatched) { - throw new DuplicateEntityException($"Unable to add project with duplicated product codes: {string.Join(", ", duplicateProducts.Select(dp => dp.Code))}"); + var existing = existingProducts.FirstOrDefault(x => x.Code == projectProduct.Product.Code && x.Description == projectProduct.Product.Description); + if (existing != null) + { + var updatedProduct = existing; + + // Manually update the members with the new data + updatedProduct.StartDate = projectProduct.Product.StartDate; + updatedProduct.CostEstimate = projectProduct.Product.CostEstimate; + updatedProduct.CostEstimateDate = projectProduct.Product.CostEstimateDate; + updatedProduct.Objective = projectProduct.Product.Objective; + updatedProduct.Scope = projectProduct.Product.Scope; + + projectProduct.Product = updatedProduct; + projectProduct.ProductId = updatedProduct.Id; + projectProduct.ProjectId = project.Id; + + externalProducts.Add(existing); + } + + // Add the updated to the list of matched ones. + matchedProjectProducts.Add(projectProduct); } - }*/ + + // Populate the products with the matched data + project.PimsProjectProducts = matchedProjectProducts; + + return externalProducts; + } private async Task> GetPageAsync(ProjectFilter filter, IEnumerable userRegions) { @@ -232,3 +298,4 @@ private string GetUpdatedNoteText(string oldStatusCode, string newStatusCode) } } } + diff --git a/source/backend/dal/Exceptions/OverrideExceptions.cs b/source/backend/dal/Exceptions/OverrideExceptions.cs index f5e1ca38e7..07740752a0 100644 --- a/source/backend/dal/Exceptions/OverrideExceptions.cs +++ b/source/backend/dal/Exceptions/OverrideExceptions.cs @@ -30,13 +30,22 @@ public static UserOverrideCode ContractorSelfRemoved get { return new UserOverrideCode("CONTRACTOR_SELFREMOVED"); } } - public string Code { get; private set; } - - private static List UserOverrideCodes + public static UserOverrideCode ProductReuse { - get { return new List() { UserOverrideCode.AddPropertyToInventory, UserOverrideCode.AddLocationToProperty, UserOverrideCode.UpdateRegion, UserOverrideCode.PoiToInventory }; } + get { return new UserOverrideCode("PRODUCT_REUSE"); } } + public string Code { get; private set; } + + private static List UserOverrideCodes => new List() { + UserOverrideCode.AddPropertyToInventory, + UserOverrideCode.AddLocationToProperty, + UserOverrideCode.UpdateRegion, + UserOverrideCode.PoiToInventory, + UserOverrideCode.ContractorSelfRemoved, + UserOverrideCode.ProductReuse, + }; + private UserOverrideCode(string code) { Code = code; diff --git a/source/backend/dal/Repositories/Interfaces/IProductRepository.cs b/source/backend/dal/Repositories/Interfaces/IProductRepository.cs index 0db424658b..88ce7f5ef3 100644 --- a/source/backend/dal/Repositories/Interfaces/IProductRepository.cs +++ b/source/backend/dal/Repositories/Interfaces/IProductRepository.cs @@ -10,6 +10,10 @@ public interface IProductRepository : IRepository { IList GetByProject(long projectId); + IList GetProjectProductsByProject(long projectId); + + IEnumerable GetProducts(IEnumerable incomingProducts); + IEnumerable GetByProductBatch(IEnumerable incomingProducts, long projectId); } } diff --git a/source/backend/dal/Repositories/ProductRepository.cs b/source/backend/dal/Repositories/ProductRepository.cs index f16302db08..3aee703784 100644 --- a/source/backend/dal/Repositories/ProductRepository.cs +++ b/source/backend/dal/Repositories/ProductRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using DocumentFormat.OpenXml.Drawing.Diagrams; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Pims.Dal.Entities; @@ -37,6 +38,30 @@ public IList GetByProject(long projectId) .ToArray(); } + /// + /// Retrieves the products for the project with the given id. + /// + /// + /// + public IList GetProjectProductsByProject(long projectId) + { + return this.Context.PimsProjectProducts.AsNoTracking() + .Where(p => p.ProjectId == projectId) + .Include(p => p.Product) + .ToList(); + } + + /// + /// Using a list of products, find a matching list of products with the same code and description. + /// + /// + /// + public IEnumerable GetProducts(IEnumerable incomingProducts) + { + var incomingCodes = incomingProducts.Select(ip => ip.Code); + return this.Context.PimsProducts.AsNoTracking().Where(p => incomingCodes.Contains(p.Code)).ToArray(); + } + /// /// Using a list of products, find a matching list of products with the same code and description. /// Ignore any products that are being "replaced" in the project referred to by the passed projectId, as those products will be removed and therefore cannot be matches to the incoming products. @@ -45,8 +70,7 @@ public IList GetByProject(long projectId) /// public IEnumerable GetByProductBatch(IEnumerable incomingProducts, long projectId) { - var incomingCodes = incomingProducts.Select(ip => ip.Code); - var matchingProductCodes = this.Context.PimsProducts.AsNoTracking().Where(databaseProduct => incomingCodes.Contains(databaseProduct.Code)).ToArray(); + var matchingProductCodes = this.GetProducts(incomingProducts); var ignoreProductCodeIds = GetByProject(projectId).Where(p => !incomingProducts.Any(ip => p.Id == ip.Id)).Select(p => p.Id); // These codes are being removed, so should not be treated as duplicates. return matchingProductCodes.Where(mc => incomingProducts.Any(ip => ip.Id != mc.Id && ip.Description == mc.Description && ip.Code == mc.Code) && !ignoreProductCodeIds.Contains(mc.Id)); } diff --git a/source/backend/dal/Repositories/ProjectRepository.cs b/source/backend/dal/Repositories/ProjectRepository.cs index 0efd7a377e..5e0f124df2 100644 --- a/source/backend/dal/Repositories/ProjectRepository.cs +++ b/source/backend/dal/Repositories/ProjectRepository.cs @@ -1,3 +1,4 @@ +using System.Data.Entity.Migrations; using System; using System.Collections.Generic; using System.Linq; @@ -10,6 +11,8 @@ using Pims.Dal.Entities.Models; using Pims.Dal.Helpers.Extensions; using Pims.Dal.Security; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace Pims.Dal.Repositories { @@ -92,15 +95,20 @@ public PimsProject Get(long id) /// public PimsProject Add(PimsProject project) { - User.ThrowIfNotAuthorized(Permissions.ProjectAdd); - - // TODO: Fix this - /*foreach (var product in project.PimsProducts) + foreach (var projectProduct in project.PimsProjectProducts) { - product.ParentProject = project; - }*/ + if (projectProduct.ProductId != 0) + { + this.Context.Entry(projectProduct.Product).State = EntityState.Modified; + } + else + { + this.Context.Entry(projectProduct.Product).State = EntityState.Added; + } + } Context.PimsProjects.Add(project); + return project; } @@ -117,7 +125,9 @@ public PimsProject Update(PimsProject project) var existingProject = Context.PimsProjects .FirstOrDefault(x => x.Id == project.Id) ?? throw new KeyNotFoundException(); - this.Context.UpdateGrandchild(p => p.PimsProjectProducts, pp => pp.Product, project.Id, project.PimsProjectProducts.ToArray(), true); + Func canDeleteGrandchild = (context, pa) => !context.PimsProducts.Any(o => o.Id == pa.ProductId); + + this.Context.UpdateGrandchild(p => p.PimsProjectProducts, pp => pp.Product, project.Id, project.PimsProjectProducts.ToArray(), canDeleteGrandchild); Context.Entry(existingProject).CurrentValues.SetValues(project); diff --git a/source/backend/tests/unit/api/Controllers/Projects/ProjectControllerTest.cs b/source/backend/tests/unit/api/Controllers/Projects/ProjectControllerTest.cs index c6b409b760..71153bbd5f 100644 --- a/source/backend/tests/unit/api/Controllers/Projects/ProjectControllerTest.cs +++ b/source/backend/tests/unit/api/Controllers/Projects/ProjectControllerTest.cs @@ -14,6 +14,7 @@ using Pims.Core.Exceptions; using Pims.Core.Test; using Pims.Dal.Entities; +using Pims.Dal.Exceptions; using Pims.Dal.Repositories; using Pims.Dal.Security; using Xunit; @@ -81,7 +82,7 @@ public void SearchProjects_BadRequest() public void UpdateProject_BadRequest() { // Act - var result = this._controller.UpdateProject(1, new ProjectModel { Id = 2 }); + var result = this._controller.UpdateProject(1, new ProjectModel { Id = 2 }, Array.Empty()); // Assert result.Should().BeOfType(typeof(BadRequestObjectResult)); @@ -92,10 +93,10 @@ public void UpdateProject_Conflict() { var helper = new TestHelper(); - this._service.Setup(x => x.Update(It.IsAny())).Throws(new DuplicateEntityException()); + this._service.Setup(x => x.Update(It.IsAny(), It.IsAny>())).Throws(new DuplicateEntityException()); // Act - var result = this._controller.UpdateProject(1, new ProjectModel { Id = 1 }); + var result = this._controller.UpdateProject(1, new ProjectModel { Id = 1 }, Array.Empty()); // Assert result.Should().BeOfType(typeof(ConflictObjectResult)); @@ -106,10 +107,10 @@ public void AddProject_Conflict() { var helper = new TestHelper(); - this._service.Setup(x => x.Add(It.IsAny())).Throws(new DuplicateEntityException()); + this._service.Setup(x => x.Add(It.IsAny(), It.IsAny>())).Throws(new DuplicateEntityException()); // Act - var result = this._controller.AddProject(new ProjectModel { Id = 2 }); + var result = this._controller.AddProject(new ProjectModel { Id = 2 }, Array.Empty()); // Assert result.Should().BeOfType(typeof(ConflictObjectResult)); diff --git a/source/backend/tests/unit/api/Services/ProjectServiceTest.cs b/source/backend/tests/unit/api/Services/ProjectServiceTest.cs index a43e866d17..633e8a9f6b 100644 --- a/source/backend/tests/unit/api/Services/ProjectServiceTest.cs +++ b/source/backend/tests/unit/api/Services/ProjectServiceTest.cs @@ -11,6 +11,7 @@ using Pims.Dal.Exceptions; using Pims.Dal.Repositories; using Pims.Dal.Security; +using Pims.Ltsa.Models; using Xunit; namespace Pims.Api.Test.Services @@ -190,7 +191,7 @@ public void Add_Project_ShouldFail_NotAuthorized() var repository = helper.GetService>(); // Act - Action result = () => service.Add(new PimsProject()); + Action result = () => service.Add(new PimsProject(), new List() { }); // Assert result.Should().Throw(); @@ -208,56 +209,50 @@ public void Add_Project_ShouldFail_IfNull() var repository = helper.GetService>(); // Act - Action result = () => service.Add(null); + Action result = () => service.Add(null, new List() { }); // Assert result.Should().Throw(); repository.Verify(x => x.Add(It.IsAny()), Times.Never); } + [Fact] + public void Add_Project_ShouldFail_IfDuplicateProduct() + { + // Arrange + var helper = new TestHelper(); + var user = PrincipalHelper.CreateForPermission(Permissions.ProjectAdd); + var service = helper.Create(user); - // TODO: Verify this is not necessary anymore - /* [Fact] - public void Add_Project_ShouldFail_IfDuplicateProduct() - { - // Arrange - var helper = new TestHelper(); - var user = PrincipalHelper.CreateForPermission(Permissions.ProjectAdd); - var service = helper.Create(user); - - var repository = helper.GetService>(); - var duplicateCode = new PimsProduct() { Code = "1" }; - - // Act - Action result = () => service.Add(new PimsProject() { PimsProducts = new List() { duplicateCode, duplicateCode } }); - - // Assert - result.Should().Throw(); - repository.Verify(x => x.Add(It.IsAny()), Times.Never); - } + var duplicateProduct = new PimsProduct() { Code = "1" }; - [Fact] - public void Add_Project_ShouldFail_IfDuplicateProductInDb() - { - // Arrange - var helper = new TestHelper(); - var user = PrincipalHelper.CreateForPermission(Permissions.ProjectAdd); - var service = helper.Create(user); + var existingProjectProducts = new List() + { + new PimsProjectProduct() { + Product = duplicateProduct + } + }; - var repository = helper.GetService>(); + var productRepo = helper.GetService>(); + productRepo.Setup(x => x.GetProjectProductsByProject(It.IsAny())).Returns(new List()); + productRepo.Setup(x => x.GetProducts(It.IsAny>())).Returns(new List { duplicateProduct }); - var duplicateCode = new PimsProduct() { Code = "1" }; - var productRepository = helper.GetService>(); - productRepository.Setup(x => x.GetByProductBatch(It.IsAny>(), It.IsAny())).Returns(new List() { duplicateCode }); + var projectRepo = helper.GetService>(); - // Act - Action result = () => service.Add(new PimsProject() { PimsProducts = new List() { duplicateCode } }); + // Act + Action result = () => service.Add( + new PimsProject() + { + PimsProjectProducts = existingProjectProducts, + }, + new List() { } + ); - // Assert - result.Should().Throw(); - repository.Verify(x => x.Add(It.IsAny()), Times.Never); - }*/ + // Assert + result.Should().Throw(); + projectRepo.Verify(x => x.Add(It.IsAny()), Times.Never); + } [Fact] public void Add_Project_Success() @@ -272,7 +267,7 @@ public void Add_Project_Success() repository.Setup(x => x.Get(It.IsAny())).Returns(new PimsProject()); // Act - var result = service.Add(new PimsProject()); + var result = service.Add(new PimsProject(), new List() { }); // Assert result.Should().NotBeNull(); @@ -363,7 +358,7 @@ public void Update_Project_ShouldFail_NotAuthorized() var repository = helper.GetService>(); // Act - Action result = () => service.Update(new PimsProject()); + Action result = () => service.Update(new PimsProject(), new List() { }); // Assert result.Should().Throw(); @@ -377,56 +372,12 @@ public void Update_Project_ShouldFail_When_Null() var repository = this._helper.GetService>(); // Act - Action result = () => service.Update(null); + Action result = () => service.Update(null, new List() { }); // Assert result.Should().Throw(); } - - // TODO: Verify this is not necessary anymore - /*[Fact] - public void Update_Project_ShouldFail_IfDuplicateProduct() - { - // Arrange - var helper = new TestHelper(); - var user = PrincipalHelper.CreateForPermission(Permissions.ProjectEdit); - var service = helper.Create(user); - - var repository = helper.GetService>(); - var duplicateCode = new PimsProduct() { Code = "1" }; - - // Act - Action result = () => service.Update(new PimsProject() { PimsProducts = new List() { duplicateCode, duplicateCode } }); - - // Assert - result.Should().Throw(); - repository.Verify(x => x.Add(It.IsAny()), Times.Never); - } - - [Fact] - public void Update_Project_ShouldFail_IfDuplicateProductInDb() - { - // Arrange - var helper = new TestHelper(); - var user = PrincipalHelper.CreateForPermission(Permissions.ProjectEdit); - var service = helper.Create(user); - - var repository = helper.GetService>(); - - var duplicateCode = new PimsProduct() { Code = "1" }; - - var productRepository = helper.GetService>(); - productRepository.Setup(x => x.GetByProductBatch(It.IsAny>(), It.IsAny())).Returns(new List() { duplicateCode }); - - // Act - Action result = () => service.Update(new PimsProject() { PimsProducts = new List() { duplicateCode } }); - - // Assert - result.Should().Throw(); - repository.Verify(x => x.Add(It.IsAny()), Times.Never); - }*/ - [Fact] public void Update_Project_Success() { @@ -441,7 +392,7 @@ public void Update_Project_Success() }); // Act - var result = service.Update(new PimsProject { Id = 1, ConcurrencyControlNumber = 100 }); + var result = service.Update(new PimsProject { Id = 1, ConcurrencyControlNumber = 100 }, new List() { }); // Assert result.Should().NotBeNull(); @@ -474,7 +425,7 @@ public void Update_Project_Success_AddsNote() },}); // Act - var result = service.Update(project); + var result = service.Update(project, new List() { }); // Assert projectRepository.Verify(x => x.Update(It.IsAny()), Times.Once); diff --git a/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.test.tsx b/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.test.tsx index 895dcc26eb..7e27cd6022 100644 --- a/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.test.tsx @@ -177,7 +177,7 @@ describe('AddProjectContainer component', () => { const axiosData: Api_Project = JSON.parse(mockAxios.history.post[0].data); const expectedValues = formValues.toApi(); - expect(mockAxios.history.post[0].url).toBe('/projects'); + expect(mockAxios.history.post[0].url).toBe('/projects?'); expect(axiosData).toEqual(expectedValues); expect(history.location.pathname).toBe('/mapview/sidebar/project/1'); diff --git a/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.tsx b/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.tsx index 979072e14c..8a5b7184da 100644 --- a/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.tsx +++ b/source/frontend/src/features/mapSideBar/project/add/AddProjectContainer.tsx @@ -7,9 +7,11 @@ import * as API from '@/constants/API'; import { FinancialCodeTypes } from '@/constants/index'; import MapSideBarLayout from '@/features/mapSideBar/layout/MapSideBarLayout'; import { useFinancialCodeRepository } from '@/hooks/repositories/useFinancialCodeRepository'; +import useApiUserOverride from '@/hooks/useApiUserOverride'; import useLookupCodeHelpers from '@/hooks/useLookupCodeHelpers'; import { Api_FinancialCode } from '@/models/api/FinancialCode'; import { Api_Project } from '@/models/api/Project'; +import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { toDropDownOptions } from '@/utils/financialCodeUtils'; import SidebarFooter from '../../shared/SidebarFooter'; @@ -34,6 +36,10 @@ const AddProjectContainer: React.FC([]); const [isValid, setIsValid] = useState(true); + const withUserOverride = useApiUserOverride< + (userOverrideCodes: UserOverrideCode[]) => Promise + >('Failed to Add Project File'); + useEffect(() => { async function fetchBusinessFunctions() { const data = (await getFinancialCodes(FinancialCodeTypes.BusinessFunction)) ?? []; @@ -107,7 +113,11 @@ const AddProjectContainer: React.FC + withUserOverride((userOverrideCodes: UserOverrideCode[]) => + helper.handleSubmit(projectForm, formikHelpers, userOverrideCodes), + ) + } validationSchema={helper.validationSchema} isCreating /> diff --git a/source/frontend/src/features/mapSideBar/project/add/ProductSubForm.tsx b/source/frontend/src/features/mapSideBar/project/add/ProductSubForm.tsx index ca50a3205d..13c8d78a40 100644 --- a/source/frontend/src/features/mapSideBar/project/add/ProductSubForm.tsx +++ b/source/frontend/src/features/mapSideBar/project/add/ProductSubForm.tsx @@ -20,18 +20,20 @@ export const ProductSubForm: React.FunctionComponent = ({ nameSpace, formikProps, }) => { + const productId = formikProps.values.products[index].id; + const isExistingProduct = productId !== 0 && productId !== null; const costEstimate = formikProps.values.products[index].costEstimate; return ( <> - + - + diff --git a/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap index 4e45a2478e..1bd3d9e8ab 100644 --- a/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap @@ -1238,6 +1238,7 @@ exports[`AddProjectForm component renders as expected with existing data 1`] = ` > ) => { + async ( + values: ProjectForm, + formikHelpers: FormikHelpers, + userOverrideCodes: UserOverrideCode[], + ) => { const project = values.toApi(); - const response = await addProject.execute(project); + const response = await addProject.execute(project, userOverrideCodes); if (!!response?.id) { formikHelpers.resetForm(); diff --git a/source/frontend/src/features/mapSideBar/project/models.ts b/source/frontend/src/features/mapSideBar/project/models.ts index 526ee16a60..2fac8a9fd9 100644 --- a/source/frontend/src/features/mapSideBar/project/models.ts +++ b/source/frontend/src/features/mapSideBar/project/models.ts @@ -1,9 +1,10 @@ -import { Api_Product, Api_Project } from '@/models/api/Project'; +import { Api_Product, Api_Project, Api_ProjectProduct } from '@/models/api/Project'; import { NumberFieldValue } from '@/typings/NumberFieldValue'; import { fromTypeCode, stringToUndefined, toTypeCode } from '@/utils/formUtils'; export class ProductForm { id: number | null = null; + code: string = ''; description: string = ''; startDate: string | '' = ''; @@ -13,11 +14,10 @@ export class ProductForm { scope: string | '' = ''; rowVersion: number | null = null; - toApi(parentId?: number | null): Api_Product { + toApi(): Api_Product { return { id: this.id, - parentProject: null, - parentProjectId: !!parentId ? parentId : null, + projectProducts: [], code: this.code, description: this.description, startDate: stringToUndefined(this.startDate), @@ -67,7 +67,16 @@ export class ProjectForm { projectStatusTypeCode: toTypeCode(this.projectStatusType) ?? null, regionCode: this.region ? toTypeCode(+this.region) ?? null : null, note: this.summary ?? null, - products: this.products?.map(x => x.toApi(this.id)), + projectProducts: this.products?.map(x => { + return { + id: 0, + projectId: 0, + product: x.toApi(), + productId: 0, + project: null, + rowVersion: 0, + }; + }), rowVersion: this.rowVersion ?? null, businessFunctionCode: this.businessFunctionCode ? toTypeCode(+this.businessFunctionCode) ?? null @@ -88,7 +97,11 @@ export class ProjectForm { newForm.region = model.regionCode?.id ? +model.regionCode?.id ?? '' : ''; newForm.summary = model.note ?? ''; newForm.rowVersion = model.rowVersion ?? null; - newForm.products = model.products?.map(x => ProductForm.fromApi(x)) || []; + newForm.products = + model.projectProducts + .map(x => x.product) + ?.filter((x): x is Api_Product => x !== null) + .map(x => ProductForm.fromApi(x)) || []; newForm.businessFunctionCode = model.businessFunctionCode?.id ? fromTypeCode(model.businessFunctionCode) ?? '' : ''; diff --git a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectProductView.tsx b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectProductView.tsx index c668b0b916..1174317447 100644 --- a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectProductView.tsx +++ b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectProductView.tsx @@ -7,18 +7,20 @@ import { Api_Product, Api_Project } from '@/models/api/Project'; import { formatMoney, prettyFormatDate } from '@/utils'; export interface IProjectProductViewProps { - project?: Api_Project; + project: Api_Project; } const ProjectProductView: React.FunctionComponent< React.PropsWithChildren > = ({ project }) => { - const productCount = project?.products?.length || 0; - + const productCount = project.projectProducts?.length || 0; + const products = project.projectProducts + .map(x => x.product) + .filter((x): x is Api_Product => x !== null); return (
- {project?.products?.map((product: Api_Product, index: number) => ( + {products?.map((product: Api_Product, index: number) => (
void; } @@ -27,18 +27,18 @@ const ProjectSummaryView: React.FunctionComponent<
- {project?.note} + {project.note}
- {project?.costTypeCode?.description ?? ''} + {project.costTypeCode?.description ?? ''} - {project?.workActivityCode?.description ?? ''} + {project.workActivityCode?.description ?? ''} - {project?.businessFunctionCode?.description ?? ''} + {project.businessFunctionCode?.description ?? ''}
diff --git a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.test.tsx b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.test.tsx index 0ec04a9c6b..586b616667 100644 --- a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.test.tsx @@ -1,4 +1,3 @@ -import { AxiosError } from 'axios'; import { FormikHelpers, FormikProps } from 'formik'; import React from 'react'; @@ -6,7 +5,7 @@ import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineCo import { SideBarContextProvider } from '@/features/mapSideBar/context/sidebarContext'; import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; import { mockProjectGetResponse, mockProjectPostResponse } from '@/mocks/projects.mock'; -import { act, render, RenderOptions, screen, waitFor } from '@/utils/test-utils'; +import { act, render, RenderOptions } from '@/utils/test-utils'; import { IAddProjectFormProps } from '../../../add/AddProjectForm'; import { ProjectForm } from '../../../models'; @@ -127,26 +126,4 @@ describe('UpdateProjectContainer', () => { expect(formikHelpers.setSubmitting).toHaveBeenCalled(); expect(formikHelpers.resetForm).toHaveBeenCalled(); }); - - it('displays expected error toast when update fails', async () => { - setup(); - const formikHelpers: Partial> = { - setSubmitting: jest.fn(), - resetForm: jest.fn(), - }; - mockApi.execute.mockRejectedValue({ - isAxiosError: true, - response: { status: 409, data: 'expected error' }, - } as AxiosError); - - await act(async () => { - return viewProps?.onSubmit( - viewProps.initialValues, - formikHelpers as FormikHelpers, - ); - }); - - expect(mockApi.execute).toHaveBeenCalled(); - await waitFor(async () => expect(screen.getByText('expected error')).toBeVisible()); - }); }); diff --git a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.tsx b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.tsx index aa45896be4..a9ad4e6a00 100644 --- a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.tsx +++ b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/update/UpdateProjectContainer.tsx @@ -1,15 +1,15 @@ -import axios from 'axios'; import { FormikHelpers, FormikProps } from 'formik'; import React, { useEffect, useMemo, useState } from 'react'; -import { toast } from 'react-toastify'; import { FinancialCodeTypes } from '@/constants'; import * as API from '@/constants/API'; import { useFinancialCodeRepository } from '@/hooks/repositories/useFinancialCodeRepository'; import { useProjectProvider } from '@/hooks/repositories/useProjectProvider'; +import useApiUserOverride from '@/hooks/useApiUserOverride'; import useLookupCodeHelpers from '@/hooks/useLookupCodeHelpers'; import { Api_FinancialCode } from '@/models/api/FinancialCode'; import { Api_Project } from '@/models/api/Project'; +import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { isExpiredCode, toDropDownOptions } from '@/utils/financialCodeUtils'; import { AddProjectYupSchema } from '../../../add/AddProjectFileYupSchema'; @@ -31,6 +31,10 @@ const UpdateProjectContainer = React.forwardRef< const { project, View, onSuccess } = props; const { costTypeCode, businessFunctionCode, workActivityCode } = project; + const withUserOverride = useApiUserOverride< + (userOverrideCodes: UserOverrideCode[]) => Promise + >('Failed to Update Project File'); + const { getFinancialCodesByType: { execute: getFinancialCodes }, } = useFinancialCodeRepository(); @@ -108,27 +112,23 @@ const UpdateProjectContainer = React.forwardRef< const initialValues = ProjectForm.fromApi(project); const projectStatusTypeCodes = getOptionsByType(API.PROJECT_STATUS_TYPES); - const handleSubmit = async (values: ProjectForm, formikHelpers: FormikHelpers) => { - try { - formikHelpers?.setSubmitting(true); - const updatedProject = values.toApi(); - const response = await updateProject(updatedProject); - - if (!!response?.id) { - formikHelpers?.resetForm(); - if (typeof onSuccess === 'function') { - onSuccess(); - } - } - } catch (e) { - if (axios.isAxiosError(e) && e.response?.status === 409) { - toast.error(e.response.data as any); - } else { - toast.error('Failed to update project.'); + const handleSubmit = async ( + values: ProjectForm, + formikHelpers: FormikHelpers, + userOverrideCodes: UserOverrideCode[], + ) => { + formikHelpers?.setSubmitting(true); + const updatedProject = values.toApi(); + const response = await updateProject(updatedProject, userOverrideCodes); + + if (!!response?.id) { + formikHelpers?.resetForm(); + if (typeof onSuccess === 'function') { + onSuccess(); } - } finally { - formikHelpers?.setSubmitting(false); } + + formikHelpers?.setSubmitting(false); }; return ( @@ -140,7 +140,11 @@ const UpdateProjectContainer = React.forwardRef< costTypeOptions={costTypeOptions ?? []} workActivityOptions={workActivityOptions ?? []} initialValues={initialValues} - onSubmit={handleSubmit} + onSubmit={(projectForm, formikHelpers) => + withUserOverride((userOverrideCodes: UserOverrideCode[]) => + handleSubmit(projectForm, formikHelpers, userOverrideCodes), + ) + } /> ); }); diff --git a/source/frontend/src/features/mapSideBar/property/tabs/propertyDetailsManagement/activity/edit/InvoiceForm.tsx b/source/frontend/src/features/mapSideBar/property/tabs/propertyDetailsManagement/activity/edit/InvoiceForm.tsx index 4a2c68b7a5..c0aa593802 100644 --- a/source/frontend/src/features/mapSideBar/property/tabs/propertyDetailsManagement/activity/edit/InvoiceForm.tsx +++ b/source/frontend/src/features/mapSideBar/property/tabs/propertyDetailsManagement/activity/edit/InvoiceForm.tsx @@ -88,7 +88,7 @@ export const InvoiceForm: React.FunctionComponent - + diff --git a/source/frontend/src/features/mapSideBar/property/tabs/takes/detail/TakesDetailView.tsx b/source/frontend/src/features/mapSideBar/property/tabs/takes/detail/TakesDetailView.tsx index 6998b43604..f5006961f8 100644 --- a/source/frontend/src/features/mapSideBar/property/tabs/takes/detail/TakesDetailView.tsx +++ b/source/frontend/src/features/mapSideBar/property/tabs/takes/detail/TakesDetailView.tsx @@ -103,20 +103,6 @@ export const TakesDetailView: React.FunctionComponent = ( {take.description} - - - - - - = ( )} + + + = ({ )} - -
- - -
-
{ return React.useMemo( () => ({ - postProject: (project: Api_Project) => api.post(`/projects`, project), + postProject: (project: Api_Project, userOverrideCodes: UserOverrideCode[]) => + api.post( + `/projects?${userOverrideCodes.map(o => `userOverrideCodes=${o}`).join('&')}`, + project, + ), searchProject: (query: string, top: number = 5) => api.get(`/projects/search=${query}&top=${top}`), searchProjects: (params: IPaginateProjects | null) => @@ -26,8 +31,12 @@ export const useApiProjects = () => { ), getAllProjects: () => api.get(`/projects`), getProject: (id: number) => api.get(`/projects/${id}`), - putProject: (project: Api_Project) => - api.put(`/projects/${project.id}`, project), + putProject: (project: Api_Project, userOverrideCodes: UserOverrideCode[]) => + api.put( + `/projects/${project.id} + ?${userOverrideCodes.map(o => `userOverrideCodes=${o}`).join('&')}`, + project, + ), getProjectProducts: (id: number) => api.get(`/projects/${id}/products`), }), [api], diff --git a/source/frontend/src/hooks/repositories/useProjectProvider.ts b/source/frontend/src/hooks/repositories/useProjectProvider.ts index ef20daa57d..0fffd374ce 100644 --- a/source/frontend/src/hooks/repositories/useProjectProvider.ts +++ b/source/frontend/src/hooks/repositories/useProjectProvider.ts @@ -7,6 +7,7 @@ import { useApiProjects } from '@/hooks/pims-api/useApiProjects'; import { useApiRequestWrapper } from '@/hooks/util/useApiRequestWrapper'; import { IApiError } from '@/interfaces/IApiError'; import { Api_Product, Api_Project } from '@/models/api/Project'; +import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { useAxiosErrorHandler, useAxiosSuccessHandler } from '@/utils'; /** @@ -35,10 +36,14 @@ export const useProjectProvider = () => { }); const addProjectApi = useApiRequestWrapper< - (project: Api_Project) => Promise> + ( + project: Api_Project, + userOverrideCodes: UserOverrideCode[], + ) => Promise> >({ requestFunction: useCallback( - async (project: Api_Project) => await postProject(project), + async (project: Api_Project, userOverrideCodes: UserOverrideCode[]) => + await postProject(project, userOverrideCodes), [postProject], ), requestName: 'AddProject', @@ -50,6 +55,7 @@ export const useProjectProvider = () => { toast.error('Failed to save project.'); } }, []), + throwError: true, }); const getProjectApi = useApiRequestWrapper< @@ -70,10 +76,14 @@ export const useProjectProvider = () => { }); const updateProject = useApiRequestWrapper< - (project: Api_Project) => Promise> + ( + project: Api_Project, + userOverrideCodes: UserOverrideCode[], + ) => Promise> >({ requestFunction: useCallback( - async (project: Api_Project) => await putProject(project), + async (project: Api_Project, userOverrideCodes: UserOverrideCode[]) => + await putProject(project, userOverrideCodes), [putProject], ), requestName: 'UpdateProject', diff --git a/source/frontend/src/mocks/acquisitionFiles.mock.ts b/source/frontend/src/mocks/acquisitionFiles.mock.ts index d6cb2f81ca..b9c1f87a09 100644 --- a/source/frontend/src/mocks/acquisitionFiles.mock.ts +++ b/source/frontend/src/mocks/acquisitionFiles.mock.ts @@ -26,18 +26,17 @@ export const mockAcquisitionFileResponse = ( workActivityCode: null, regionCode: null, note: null, - products: [], + projectProducts: [], rowVersion: null, }, projectId: null, product: { id: 1, acquisitionFiles: [], + projectProducts: [], code: '00048', description: 'MISCELLANEOUS CLAIMS', rowVersion: 1, - parentProject: null, - parentProjectId: null, startDate: '2022-06-27T00:00:00', costEstimate: 10000, costEstimateDate: null, diff --git a/source/frontend/src/mocks/projects.mock.ts b/source/frontend/src/mocks/projects.mock.ts index 9883d2a708..c450d10658 100644 --- a/source/frontend/src/mocks/projects.mock.ts +++ b/source/frontend/src/mocks/projects.mock.ts @@ -15,7 +15,7 @@ export const mockProjects = (): Api_Project[] => [ note: 'test NOTE 1', rowVersion: 1, projectStatusTypeCode: null, - products: [], + projectProducts: [], }, { id: 2, @@ -31,7 +31,7 @@ export const mockProjects = (): Api_Project[] => [ note: 'test NOTE 2', rowVersion: 1, projectStatusTypeCode: null, - products: [], + projectProducts: [], }, ]; @@ -59,7 +59,7 @@ export const mockProjectPostResponse = ( businessFunctionCode: null, workActivityCode: null, costTypeCode: null, - products: [], + projectProducts: [], note: summary, appCreateTimestamp: '2022-05-28T00:57:37.42', appLastUpdateTimestamp: '2022-07-28T00:57:37.42', @@ -84,34 +84,46 @@ export const mockProjectGetResponse = (): Api_Project => ({ code: '771', description: 'Project Cool A', note: 'Summary of the Project Cool A', - products: [ + projectProducts: [ { - id: 48, - acquisitionFiles: [], - code: '70', - description: 'Product A', - startDate: '2023-02-01T00:00:00', - costEstimate: 60.0, - costEstimateDate: '2023-02-02T00:00:00', - objective: 'Objective of Product A', - scope: 'Scope of Product A', + id: 1, + projectId: 20, + project: null, + productId: 48, + product: { + id: 48, + acquisitionFiles: [], + projectProducts: [], + code: '70', + description: 'Product A', + startDate: '2023-02-01T00:00:00', + costEstimate: 60.0, + costEstimateDate: '2023-02-02T00:00:00', + objective: 'Objective of Product A', + scope: 'Scope of Product A', + rowVersion: 1, + }, rowVersion: 1, - parentProject: null, - parentProjectId: null, }, { - id: 49, - acquisitionFiles: [], - code: '71', - description: 'Product B', - startDate: '2023-02-03T00:00:00', - costEstimate: 61.0, - costEstimateDate: '2023-02-04T00:00:00', - objective: 'Objective of Product B', - scope: 'Scope of Product B', + id: 2, + projectId: 20, + project: null, + productId: 49, + product: { + id: 49, + acquisitionFiles: [], + projectProducts: [], + code: '71', + description: 'Product B', + startDate: '2023-02-03T00:00:00', + costEstimate: 61.0, + costEstimateDate: '2023-02-04T00:00:00', + objective: 'Objective of Product B', + scope: 'Scope of Product B', + rowVersion: 1, + }, rowVersion: 1, - parentProject: null, - parentProjectId: null, }, ], businessFunctionCode: null, diff --git a/source/frontend/src/mocks/researchFile.mock.ts b/source/frontend/src/mocks/researchFile.mock.ts index 38222d600b..8a2c8da895 100644 --- a/source/frontend/src/mocks/researchFile.mock.ts +++ b/source/frontend/src/mocks/researchFile.mock.ts @@ -86,7 +86,7 @@ export const getMockResearchFile = (): Api_ResearchFile => ({ regionCode: null, workActivityCode: null, note: null, - products: [], + projectProducts: [], appCreateTimestamp: '2023-01-30T21:33:33.063', appLastUpdateTimestamp: '2023-01-30T21:33:33.063', appLastUpdateUserid: 'dbo', diff --git a/source/frontend/src/models/api/AcquisitionFile.ts b/source/frontend/src/models/api/AcquisitionFile.ts index 3444cf79d9..4f6c7a17cd 100644 --- a/source/frontend/src/models/api/AcquisitionFile.ts +++ b/source/frontend/src/models/api/AcquisitionFile.ts @@ -19,6 +19,7 @@ export enum EnumAcquisitionFileType { SECTN6 = 'SECTN6', } +// LINK @backend/api/Models/Concepts/AcquisitionFile/AcquisitionFileModel.cs export interface Api_AcquisitionFile extends Api_ConcurrentVersion, Api_AuditFields, Api_File { id?: number; fileNo?: number; diff --git a/source/frontend/src/models/api/Project.ts b/source/frontend/src/models/api/Project.ts index bb7968c27d..961795806f 100644 --- a/source/frontend/src/models/api/Project.ts +++ b/source/frontend/src/models/api/Project.ts @@ -5,6 +5,7 @@ import { Api_ConcurrentVersion_Null } from './ConcurrentVersion'; import { Api_FinancialCode } from './FinancialCode'; import Api_TypeCode from './TypeCode'; +// LINK @backend/api/Models/Concepts/Project/ProjectModel.cs export interface Api_Project extends Api_ConcurrentVersion_Null, Api_AuditFields { id: number | null; projectStatusTypeCode: Api_TypeCode | null; @@ -15,19 +16,28 @@ export interface Api_Project extends Api_ConcurrentVersion_Null, Api_AuditFields code: string | null; description: string | null; note: string | null; - products: Api_Product[]; + projectProducts: Api_ProjectProduct[]; } +// LINK @backend/api/Models/Concepts/Product/ProductModel.cs export interface Api_Product extends Api_ConcurrentVersion_Null, Api_AuditFields { - id?: number | null; - parentProject: Api_Project | null; - parentProjectId: number | null; - code: string | null; - description: string | null; + id: number | null; + projectProducts: Api_ProjectProduct[]; + acquisitionFiles: Api_AcquisitionFile[]; + code: string; + description: string; startDate: string | null; costEstimate: number | null; costEstimateDate: string | null; - objective: string | null; + objective: string; scope: string | null; - acquisitionFiles: Api_AcquisitionFile[]; +} + +// LINK @backend/api/Models/Concepts/Project/ProjectProductModel.cs +export interface Api_ProjectProduct extends Api_ConcurrentVersion_Null, Api_AuditFields { + id: number; + projectId: number; + product: Api_Product | null; + productId: number; + project: Api_Project | null; } diff --git a/source/frontend/src/models/api/UserOverrideCode.ts b/source/frontend/src/models/api/UserOverrideCode.ts index 29c2d90c26..9008961cc1 100644 --- a/source/frontend/src/models/api/UserOverrideCode.ts +++ b/source/frontend/src/models/api/UserOverrideCode.ts @@ -4,4 +4,5 @@ export enum UserOverrideCode { UPDATE_REGION = 'UPDATE_REGION', PROPERTY_OF_INTEREST_TO_INVENTORY = 'PROPERTY_OF_INTEREST_TO_INVENTORY', CONTRACTOR_SELFREMOVED = 'CONTRACTOR_SELFREMOVED', + PRODUCT_REUSE = 'PRODUCT_REUSE', }