diff --git a/backend/src/Catalog.Application/Catalog.Application.csproj b/backend/src/Catalog.Application/Catalog.Application.csproj index deb88d2..27111e2 100644 --- a/backend/src/Catalog.Application/Catalog.Application.csproj +++ b/backend/src/Catalog.Application/Catalog.Application.csproj @@ -7,14 +7,14 @@ - - + + - - - + + + diff --git a/backend/src/Catalog.Application/Products/Commands/AddProduct.cs b/backend/src/Catalog.Application/Products/Commands/AddProduct.cs index d47fe5c..fbf559e 100644 --- a/backend/src/Catalog.Application/Products/Commands/AddProduct.cs +++ b/backend/src/Catalog.Application/Products/Commands/AddProduct.cs @@ -1,5 +1,6 @@ using Catalog.Domain.Products.Entities; using Catalog.Domain.Products.Repositories; +using Catalog.Domain.Tags.Repositories; using Common.Application.Commands; using Common.Application.Models; using FluentValidation; @@ -8,13 +9,13 @@ namespace Catalog.Application.Products.Commands; public static class AddProduct { - public sealed record Command(Guid Id, string Name, string Description, decimal Price) : ICommand; + public sealed record Command(Guid Id, string Name, string Description, decimal Price, List TagIds) : ICommand; - public sealed class Handler(IUnitOfWork unitOfWork, IProductRepository productRepository, IValidator validator) : ICommandHandler + public sealed class Handler(IUnitOfWork unitOfWork, IProductRepository productRepository, ITagRepository tagRepository, IValidator validator) : ICommandHandler { public async Task HandleAsync(Command command) { - var (id, name, description, price) = command; + var (id, name, description, price, tagIds) = command; var validationResult = await validator.ValidateAsync(command); if (!validationResult.IsValid) return Result.Fail("add-product-validation"); @@ -27,6 +28,13 @@ public async Task HandleAsync(Command command) Price = price }; + if (tagIds.Count > 0) + { + var tags = await tagRepository.GetTagsAsync(tagIds); + if (tagIds.Count != tags.Count) return Result.Fail("add-product-tags-count"); + product.Tags.AddRange(tags); + } + await productRepository.AddAsync(product); await unitOfWork.CommitAsync(); diff --git a/backend/src/Catalog.Application/Products/Commands/EditProduct.cs b/backend/src/Catalog.Application/Products/Commands/EditProduct.cs index 1287912..960126a 100644 --- a/backend/src/Catalog.Application/Products/Commands/EditProduct.cs +++ b/backend/src/Catalog.Application/Products/Commands/EditProduct.cs @@ -1,4 +1,5 @@ using Catalog.Domain.Products.Repositories; +using Catalog.Domain.Tags.Repositories; using Common.Application.Commands; using Common.Application.Models; using FluentValidation; @@ -7,13 +8,13 @@ namespace Catalog.Application.Products.Commands; public static class EditProduct { - public sealed record Command(Guid Id, string Name, string Description, decimal Price) : ICommand; + public sealed record Command(Guid Id, string Name, string Description, decimal Price, List TagIds) : ICommand; - public sealed class Handler(IUnitOfWork unitOfWork, IProductRepository productRepository, IValidator validator) : ICommandHandler + public sealed class Handler(IUnitOfWork unitOfWork, IProductRepository productRepository, ITagRepository tagRepository, IValidator validator) : ICommandHandler { public async Task HandleAsync(Command command) { - var (id, name, description, price) = command; + var (id, name, description, price, tagIds) = command; var validationResult = await validator.ValidateAsync(command); if (!validationResult.IsValid) return Result.Fail("edit-product-validation"); @@ -24,6 +25,15 @@ public async Task HandleAsync(Command command) product.Description = description; product.Price = price; + product.Tags.Clear(); + + if (tagIds.Count > 0) + { + var tags = await tagRepository.GetTagsAsync(tagIds); + if (tagIds.Count != tags.Count) return Result.Fail("edit-product-tags-count"); + product.Tags.AddRange(tags); + } + await unitOfWork.CommitAsync(); return Result.Ok(); diff --git a/backend/src/Catalog.Application/Products/Queries/GetProduct.cs b/backend/src/Catalog.Application/Products/Queries/GetProduct.cs new file mode 100644 index 0000000..ff7b26b --- /dev/null +++ b/backend/src/Catalog.Application/Products/Queries/GetProduct.cs @@ -0,0 +1,26 @@ +using Catalog.Domain.Products.Entities; +using Common.Application.Queries; +using Microsoft.EntityFrameworkCore; + +namespace Catalog.Application.Products.Queries; + +public static class GetProduct +{ + public sealed record Query(Guid Id) : IQuery; + + public sealed record TagDto(Guid Id, string Name); + + public sealed record ProductDto(Guid Id, string Name, string Description, decimal Price, IEnumerable Tags); + + public sealed class Handler(IQueryProcessor queryProcessor) : IQueryHandler + { + public Task HandleAsync(Query query) + { + return queryProcessor.Query() + .Include(p => p.Tags) + .Where(p => p.Id == query.Id) + .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price, p.Tags.Select(t => new TagDto(t.Id, t.Name)))) + .FirstOrDefaultAsync(); + } + } +} \ No newline at end of file diff --git a/backend/src/Catalog.Application/Tags/Queries/GetTags.cs b/backend/src/Catalog.Application/Tags/Queries/GetTags.cs index 1916e95..051a912 100644 --- a/backend/src/Catalog.Application/Tags/Queries/GetTags.cs +++ b/backend/src/Catalog.Application/Tags/Queries/GetTags.cs @@ -6,7 +6,7 @@ namespace Catalog.Application.Tags.Queries; public static class GetTags { - public sealed record Query(int PageIndex, int PageSize) : IQuery; + public sealed record Query(int? PageIndex, int? PageSize) : IQuery; public sealed record Dto(Guid Id, string Name); @@ -16,9 +16,14 @@ public sealed class Handler(IQueryProcessor queryProcessor) : IQueryHandler HandleAsync(Query query) { - var tags = await queryProcessor.Query() - .Skip(query.PageIndex * query.PageSize) - .Take(query.PageSize) + var tagsQuery = queryProcessor.Query(); + + if (query is { PageIndex: not null, PageSize: not null }) + tagsQuery = tagsQuery + .Skip(query.PageIndex.Value * query.PageSize.Value) + .Take(query.PageSize.Value); + + var tags = await tagsQuery .OrderBy(t => t.Name) .Select(t => new Dto(t.Id, t.Name)) .ToListAsync(); diff --git a/backend/src/Catalog.Domain/Products/Entities/Product.cs b/backend/src/Catalog.Domain/Products/Entities/Product.cs index 21696bc..59d5e03 100644 --- a/backend/src/Catalog.Domain/Products/Entities/Product.cs +++ b/backend/src/Catalog.Domain/Products/Entities/Product.cs @@ -1,4 +1,6 @@ -namespace Catalog.Domain.Products.Entities; +using Catalog.Domain.Tags.Entities; + +namespace Catalog.Domain.Products.Entities; public sealed class Product { @@ -6,4 +8,6 @@ public sealed class Product public required string Name { get; set; } public required string Description { get; set; } public required decimal Price { get; set; } + + public List Tags { get; } = []; } \ No newline at end of file diff --git a/backend/src/Common.Application/Common.Application.csproj b/backend/src/Common.Application/Common.Application.csproj index 01f045e..8135b59 100644 --- a/backend/src/Common.Application/Common.Application.csproj +++ b/backend/src/Common.Application/Common.Application.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/backend/src/Common.Application/Extensions/ServiceCollectionExtensions.cs b/backend/src/Common.Application/Extensions/ServiceCollectionExtensions.cs index 4150d69..f33adf1 100644 --- a/backend/src/Common.Application/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Common.Application/Extensions/ServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static IServiceCollection AddHandlers(this IServiceCollection services, A var handlerInterface = handler.GetInterfaces().Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICommandHandler<>)); services.AddTransient(handlerInterface, handler); } - + var queryHandlers = assembly.GetTypes() .Where(type => type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>))) .ToList(); diff --git a/backend/src/Common.Application/Models/Result.cs b/backend/src/Common.Application/Models/Result.cs index 0d531da..18c2dae 100644 --- a/backend/src/Common.Application/Models/Result.cs +++ b/backend/src/Common.Application/Models/Result.cs @@ -6,14 +6,14 @@ public struct Result public static Result Ok() { - return new Result(); + return new(); } public static Result Fail(string errorMessage) { - return new Result + return new() { - Error = new Error + Error = new() { Description = errorMessage } diff --git a/backend/src/Common.Application/Queries/IQueryProcessor.cs b/backend/src/Common.Application/Queries/IQueryProcessor.cs index 0eab587..3bae32a 100644 --- a/backend/src/Common.Application/Queries/IQueryProcessor.cs +++ b/backend/src/Common.Application/Queries/IQueryProcessor.cs @@ -1,6 +1,6 @@ namespace Common.Application.Queries; -public interface IQueryProcessor +public interface IQueryProcessor { IQueryable Query() where T : class; } \ No newline at end of file diff --git a/backend/src/Host.WebApi/Extensions/ResultExtensions.cs b/backend/src/Host.WebApi/Extensions/ResultExtensions.cs index 03ee288..6a90b8c 100644 --- a/backend/src/Host.WebApi/Extensions/ResultExtensions.cs +++ b/backend/src/Host.WebApi/Extensions/ResultExtensions.cs @@ -10,7 +10,7 @@ internal static IResult ToHttp(this Result result) return result.Error switch { null => Results.NoContent(), - _ => Results.Problem(result.Error.Description, statusCode: (int) HttpStatusCode.BadRequest) + _ => Results.Problem(result.Error.Description, statusCode: (int)HttpStatusCode.BadRequest) }; } } \ No newline at end of file diff --git a/backend/src/Host.WebApi/Host.WebApi.csproj b/backend/src/Host.WebApi/Host.WebApi.csproj index a54e847..6b6e7ef 100644 --- a/backend/src/Host.WebApi/Host.WebApi.csproj +++ b/backend/src/Host.WebApi/Host.WebApi.csproj @@ -9,15 +9,15 @@ - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/backend/src/Host.WebApi/Routes/ProductRoutes.cs b/backend/src/Host.WebApi/Routes/ProductRoutes.cs index e61b3fe..58be7b5 100644 --- a/backend/src/Host.WebApi/Routes/ProductRoutes.cs +++ b/backend/src/Host.WebApi/Routes/ProductRoutes.cs @@ -22,6 +22,14 @@ internal static void MapProductRoutes(this IEndpointRouteBuilder api) .Produces() .ProducesProblem(StatusCodes.Status500InternalServerError); + group.MapGet("{id:guid}", async ([FromServices] IQueryHandler handler, [FromRoute] Guid id) => + { + var result = await handler.HandleAsync(new(id)); + return Results.Ok(result); + }) + .Produces() + .ProducesProblem(StatusCodes.Status500InternalServerError); + group.MapPost("", async ([FromServices] ICommandHandler handler, [FromBody] AddProduct.Command command) => { var result = await handler.HandleAsync(command); diff --git a/backend/src/Host.WebApi/Routes/TagRoutes.cs b/backend/src/Host.WebApi/Routes/TagRoutes.cs index 96316e6..63f49db 100644 --- a/backend/src/Host.WebApi/Routes/TagRoutes.cs +++ b/backend/src/Host.WebApi/Routes/TagRoutes.cs @@ -14,7 +14,7 @@ internal static void MapTagRoutes(this IEndpointRouteBuilder api) var group = api.MapGroup("tags") .WithTags("Tags"); - group.MapGet("", async ([FromServices] IQueryHandler handler, [FromQuery] int pageIndex, [FromQuery] int pageSize) => + group.MapGet("", async ([FromServices] IQueryHandler handler, [FromQuery] int? pageIndex, [FromQuery] int? pageSize) => { var result = await handler.HandleAsync(new(pageIndex, pageSize)); return Results.Ok(result); diff --git a/backend/src/Infrastructure.Data/Configurations/ProductConfiguration.cs b/backend/src/Infrastructure.Data/Configurations/ProductConfiguration.cs index 497ec73..635efbd 100644 --- a/backend/src/Infrastructure.Data/Configurations/ProductConfiguration.cs +++ b/backend/src/Infrastructure.Data/Configurations/ProductConfiguration.cs @@ -24,5 +24,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Price) .IsRequired() .HasPrecision(18, 2); + + builder.HasMany(p => p.Tags) + .WithMany(); } } \ No newline at end of file diff --git a/backend/src/Infrastructure.Data/Extensions/ServiceCollectionExtensions.cs b/backend/src/Infrastructure.Data/Extensions/ServiceCollectionExtensions.cs index 244abaa..67f76d3 100644 --- a/backend/src/Infrastructure.Data/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Infrastructure.Data/Extensions/ServiceCollectionExtensions.cs @@ -17,7 +17,7 @@ public static IServiceCollection AddInfrastructureData(this IServiceCollection s services.AddDbContext(builder => builder.UseNpgsql(connectionString)); services.AddScoped(); services.AddTransient(); - + services.AddTransient(); services.AddTransient(); diff --git a/backend/src/Infrastructure.Data/Infrastructure.Data.csproj b/backend/src/Infrastructure.Data/Infrastructure.Data.csproj index f226cf7..d9c1950 100644 --- a/backend/src/Infrastructure.Data/Infrastructure.Data.csproj +++ b/backend/src/Infrastructure.Data/Infrastructure.Data.csproj @@ -7,17 +7,17 @@ - - + + - - + + - + diff --git a/backend/src/Infrastructure.Data/Migrations/20241018082551_AddProductTag.Designer.cs b/backend/src/Infrastructure.Data/Migrations/20241018082551_AddProductTag.Designer.cs new file mode 100644 index 0000000..8cdb3a3 --- /dev/null +++ b/backend/src/Infrastructure.Data/Migrations/20241018082551_AddProductTag.Designer.cs @@ -0,0 +1,113 @@ +// +using System; +using Infrastructure.Data.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20241018082551_AddProductTag")] + partial class AddProductTag + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Catalog.Domain.Products.Entities.Product", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("price"); + + b.HasKey("Id") + .HasName("pk_product"); + + b.ToTable("product", (string)null); + }); + + modelBuilder.Entity("Catalog.Domain.Tags.Entities.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_tag"); + + b.ToTable("tag", (string)null); + }); + + modelBuilder.Entity("ProductTag", b => + { + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("TagsId") + .HasColumnType("uuid") + .HasColumnName("tags_id"); + + b.HasKey("ProductId", "TagsId") + .HasName("pk_product_tag"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_product_tag_tags_id"); + + b.ToTable("product_tag", (string)null); + }); + + modelBuilder.Entity("ProductTag", b => + { + b.HasOne("Catalog.Domain.Products.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_product_tag_product_product_id"); + + b.HasOne("Catalog.Domain.Tags.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_product_tag_tag_tags_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Infrastructure.Data/Migrations/20241018082551_AddProductTag.cs b/backend/src/Infrastructure.Data/Migrations/20241018082551_AddProductTag.cs new file mode 100644 index 0000000..957ea95 --- /dev/null +++ b/backend/src/Infrastructure.Data/Migrations/20241018082551_AddProductTag.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// + public partial class AddProductTag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "product_tag", + columns: table => new + { + product_id = table.Column(type: "uuid", nullable: false), + tags_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_product_tag", x => new { x.product_id, x.tags_id }); + table.ForeignKey( + name: "fk_product_tag_product_product_id", + column: x => x.product_id, + principalTable: "product", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_product_tag_tag_tags_id", + column: x => x.tags_id, + principalTable: "tag", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_product_tag_tags_id", + table: "product_tag", + column: "tags_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "product_tag"); + } + } +} diff --git a/backend/src/Infrastructure.Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Infrastructure.Data/Migrations/AppDbContextModelSnapshot.cs index abe1333..d4ab391 100644 --- a/backend/src/Infrastructure.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Infrastructure.Data/Migrations/AppDbContextModelSnapshot.cs @@ -68,6 +68,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("tag", (string)null); }); + + modelBuilder.Entity("ProductTag", b => + { + b.Property("ProductId") + .HasColumnType("uuid") + .HasColumnName("product_id"); + + b.Property("TagsId") + .HasColumnType("uuid") + .HasColumnName("tags_id"); + + b.HasKey("ProductId", "TagsId") + .HasName("pk_product_tag"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_product_tag_tags_id"); + + b.ToTable("product_tag", (string)null); + }); + + modelBuilder.Entity("ProductTag", b => + { + b.HasOne("Catalog.Domain.Products.Entities.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_product_tag_product_product_id"); + + b.HasOne("Catalog.Domain.Tags.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_product_tag_tag_tags_id"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs b/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs index f077a88..cf9536d 100644 --- a/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs +++ b/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs @@ -24,6 +24,8 @@ public void Delete(List products) public Task GetByIdAsync(Guid id) { - return context.Set().FirstAsync(p => p.Id == id); + return context.Set() + .Include(p => p.Tags) + .FirstAsync(p => p.Id == id); } } \ No newline at end of file diff --git a/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/AddProductTests.cs b/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/AddProductTests.cs index 095a8db..fc2e851 100644 --- a/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/AddProductTests.cs +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/AddProductTests.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using Catalog.Application.Products.Commands; using Catalog.Domain.Products.Entities; +using Catalog.Domain.Tags.Entities; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Tamaplante.IntegrationTests.Common; @@ -18,7 +19,19 @@ public async Task AddProduct_Should_BeSuccessful() await integrationFixture.ResetDatabaseAsync(); using var client = integrationFixture.Factory.CreateClient(); - var command = new AddProduct.Command(Guid.NewGuid(), "Name", "Description", 10); + var tags = new List + { + new() { Id = Guid.NewGuid(), Name = "Tag 1" }, + new() { Id = Guid.NewGuid(), Name = "Tag 2" } + }; + + await using (var dbContext = integrationFixture.CreateDbContext()) + { + await dbContext.Set().AddRangeAsync(tags); + await dbContext.SaveChangesAsync(); + } + + var command = new AddProduct.Command(Guid.NewGuid(), "Name", "Description", 10, tags.Select(t => t.Id).ToList()); // Act var response = await client.PostAsJsonAsync("api/v1/products", command); @@ -26,8 +39,17 @@ public async Task AddProduct_Should_BeSuccessful() // Assert response.StatusCode.Should().Be(HttpStatusCode.NoContent); - await using var dbContext = integrationFixture.CreateDbContext(); - var product = await dbContext.Set().FirstOrDefaultAsync(x => x.Id == command.Id); - product.Should().NotBeNull(); + await using (var dbContext = integrationFixture.CreateDbContext()) + { + var expectedProduct = new Product { Id = command.Id, Name = command.Name, Description = command.Description, Price = command.Price }; + expectedProduct.Tags.AddRange(tags); + + var actualProduct = await dbContext.Set() + .Include(p => p.Tags) + .FirstOrDefaultAsync(x => x.Id == command.Id); + + actualProduct.Should().NotBeNull(); + actualProduct.Should().BeEquivalentTo(expectedProduct); + } } } \ No newline at end of file diff --git a/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/EditProductTests.cs b/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/EditProductTests.cs index eec5b09..8801989 100644 --- a/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/EditProductTests.cs +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/EditProductTests.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using Catalog.Application.Products.Commands; using Catalog.Domain.Products.Entities; +using Catalog.Domain.Tags.Entities; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Tamaplante.IntegrationTests.Common; @@ -18,23 +19,28 @@ public async Task EditProduct_Should_BeSuccessful() await integrationFixture.ResetDatabaseAsync(); using var client = integrationFixture.Factory.CreateClient(); - var productId = Guid.NewGuid(); + var tags = new List + { + new() { Id = Guid.NewGuid(), Name = "Tag 1" }, + new() { Id = Guid.NewGuid(), Name = "Tag 2" } + }; - await using (var dbContext = integrationFixture.CreateDbContext()) + var product = new Product { - var product = new Product - { - Id = productId, - Name = "Name", - Description = "Description", - Price = 10 - }; + Id = Guid.NewGuid(), + Name = "Name", + Description = "Description", + Price = 10 + }; + await using (var dbContext = integrationFixture.CreateDbContext()) + { + await dbContext.Set().AddRangeAsync(tags); await dbContext.Set().AddAsync(product); await dbContext.SaveChangesAsync(); } - var command = new EditProduct.Command(productId, "NewName", "NewDescription", 20); + var command = new EditProduct.Command(product.Id, "NewName", "NewDescription", 20, tags.Select(t => t.Id).ToList()); // Act var response = await client.PutAsJsonAsync("api/v1/products", command); @@ -45,9 +51,15 @@ public async Task EditProduct_Should_BeSuccessful() await using (var dbContext = integrationFixture.CreateDbContext()) { - var product = await dbContext.Set().FirstOrDefaultAsync(x => x.Id == productId); - product.Should().NotBeNull(); - product.Should().BeEquivalentTo(new Product { Id = productId, Name = command.Name, Description = command.Description, Price = command.Price }); + var expectedProduct = new Product { Id = command.Id, Name = command.Name, Description = command.Description, Price = command.Price }; + expectedProduct.Tags.AddRange(tags); + + var actualProduct = await dbContext.Set() + .Include(p => p.Tags) + .FirstOrDefaultAsync(x => x.Id == command.Id); + + actualProduct.Should().NotBeNull(); + actualProduct.Should().BeEquivalentTo(expectedProduct); } } } \ No newline at end of file diff --git a/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Queries/GetProductTests.cs b/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Queries/GetProductTests.cs new file mode 100644 index 0000000..7141b88 --- /dev/null +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Queries/GetProductTests.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Json; +using Catalog.Application.Products.Queries; +using Catalog.Domain.Products.Entities; +using FluentAssertions; +using Tamaplante.IntegrationTests.Common; + +namespace Tamaplante.IntegrationTests.Catalog.Products.Queries; + +[Collection("IntegrationTests")] +public sealed class GetProductTests(IntegrationFixture integrationFixture) +{ + [Fact] + public async Task GetProduct_Should_BeSuccessful() + { + // Arrange + await integrationFixture.ResetDatabaseAsync(); + using var client = integrationFixture.Factory.CreateClient(); + + var products = new List + { + new() { Id = Guid.NewGuid(), Name = "Name 1", Description = "Description 1", Price = 10, Tags = { new() { Id = Guid.NewGuid(), Name = "Name" } } }, + new() { Id = Guid.NewGuid(), Name = "Name 2", Description = "Description 2", Price = 10 } + }; + + var product = products.First(); + + await using var context = integrationFixture.CreateDbContext(); + await context.Set().AddRangeAsync(products); + await context.SaveChangesAsync(); + + // Act + var response = await client.GetFromJsonAsync($"/api/v1/products/{product.Id}"); + + // Assert + var expectedProduct = new GetProduct.ProductDto(product.Id, product.Name, product.Description, product.Price, product.Tags.Select(t => new GetProduct.TagDto(t.Id, t.Name))); + response.Should().NotBeNull(); + response.Should().BeEquivalentTo(expectedProduct); + } +} \ No newline at end of file diff --git a/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/AddTagTests.cs b/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/AddTagTests.cs index aed6f19..7a2d244 100644 --- a/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/AddTagTests.cs +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/AddTagTests.cs @@ -27,7 +27,15 @@ public async Task AddTag_Should_BeSuccessful() response.StatusCode.Should().Be(HttpStatusCode.NoContent); await using var dbContext = integrationFixture.CreateDbContext(); - var tag = await dbContext.Set().FirstOrDefaultAsync(x => x.Id == command.Id); - tag.Should().NotBeNull(); + + var expectedTag = new Tag + { + Id = command.Id, + Name = command.Name + }; + + var actualTag = await dbContext.Set().FirstOrDefaultAsync(x => x.Id == command.Id); + actualTag.Should().NotBeNull(); + actualTag.Should().BeEquivalentTo(expectedTag); } } \ No newline at end of file diff --git a/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs b/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs index eef0556..1dd5bce 100644 --- a/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs @@ -18,21 +18,19 @@ public async Task EditTag_Should_BeSuccessful() await integrationFixture.ResetDatabaseAsync(); using var client = integrationFixture.Factory.CreateClient(); - var tagId = Guid.NewGuid(); + var tag = new Tag + { + Id = Guid.NewGuid(), + Name = "Name" + }; await using (var dbContext = integrationFixture.CreateDbContext()) { - var tag = new Tag - { - Id = tagId, - Name = "Name" - }; - await dbContext.Set().AddAsync(tag); await dbContext.SaveChangesAsync(); } - var command = new EditTag.Command(tagId, "NewName"); + var command = new EditTag.Command(tag.Id, "NewName"); // Act var response = await client.PutAsJsonAsync("api/v1/tags", command); @@ -43,9 +41,15 @@ public async Task EditTag_Should_BeSuccessful() await using (var dbContext = integrationFixture.CreateDbContext()) { - var tag = await dbContext.Set().FirstOrDefaultAsync(x => x.Id == tagId); - tag.Should().NotBeNull(); - tag.Should().BeEquivalentTo(new Tag { Id = tagId, Name = command.Name }); + var expectedTag = new Tag + { + Id = command.Id, + Name = command.Name + }; + + var actualTag = await dbContext.Set().FirstOrDefaultAsync(x => x.Id == command.Id); + actualTag.Should().NotBeNull(); + actualTag.Should().BeEquivalentTo(expectedTag); } } } \ No newline at end of file diff --git a/backend/test/Tamaplante.IntegrationTests/Common/IntegrationFixture.cs b/backend/test/Tamaplante.IntegrationTests/Common/IntegrationFixture.cs index 9b5cccb..5564e29 100644 --- a/backend/test/Tamaplante.IntegrationTests/Common/IntegrationFixture.cs +++ b/backend/test/Tamaplante.IntegrationTests/Common/IntegrationFixture.cs @@ -12,7 +12,7 @@ public sealed class IntegrationFixture : IAsyncLifetime private CustomWebApplicationFactory? _factory; private Respawner? _respawner; - public CustomWebApplicationFactory Factory => _factory ??= new CustomWebApplicationFactory(_sqlContainer.GetConnectionString()); + public CustomWebApplicationFactory Factory => _factory ??= new(_sqlContainer.GetConnectionString()); public async Task InitializeAsync() { @@ -24,7 +24,7 @@ public async Task InitializeAsync() await using var connection = new NpgsqlConnection(_sqlContainer.GetConnectionString()); await connection.OpenAsync(); - _respawner = await Respawner.CreateAsync(connection, new RespawnerOptions + _respawner = await Respawner.CreateAsync(connection, new() { DbAdapter = DbAdapter.Postgres }); @@ -47,7 +47,7 @@ public async Task ResetDatabaseAsync() public AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder().UseNpgsql(_sqlContainer.GetConnectionString()).Options; - return new AppDbContext(options); + return new(options); } } diff --git a/backend/test/Tamaplante.IntegrationTests/Tamaplante.IntegrationTests.csproj b/backend/test/Tamaplante.IntegrationTests/Tamaplante.IntegrationTests.csproj index f200063..a8555ad 100644 --- a/backend/test/Tamaplante.IntegrationTests/Tamaplante.IntegrationTests.csproj +++ b/backend/test/Tamaplante.IntegrationTests/Tamaplante.IntegrationTests.csproj @@ -11,20 +11,20 @@ - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -33,11 +33,11 @@ - + - + diff --git a/backend/test/Tamaplante.Tests/Catalog/Application/Products/Commands/AddProductTests.cs b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Commands/AddProductTests.cs new file mode 100644 index 0000000..67524ae --- /dev/null +++ b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Commands/AddProductTests.cs @@ -0,0 +1,45 @@ +using Catalog.Application.Products.Commands; +using Catalog.Domain.Products.Repositories; +using Catalog.Domain.Tags.Entities; +using Catalog.Domain.Tags.Repositories; +using Common.Application.Commands; +using FluentAssertions; +using FluentValidation; +using Moq; + +namespace Tamaplante.Tests.Catalog.Application.Products.Commands; + +public class AddProductTests +{ + private readonly AddProduct.Handler _sut; + private readonly Mock _tagRepositoryMock; + private readonly Mock> _validatorMock; + + public AddProductTests() + { + var unitOfWorkMock = new Mock(); + var productRepositoryMock = new Mock(); + _tagRepositoryMock = new(); + _validatorMock = new(); + + _sut = new(unitOfWorkMock.Object, productRepositoryMock.Object, _tagRepositoryMock.Object, _validatorMock.Object); + } + + [Fact] + public async Task Handle_Should_Fail_When_TagsCountDoNotMatch() + { + // Arrange + var command = new AddProduct.Command(Guid.NewGuid(), "Name", "Description", 10, [Guid.NewGuid(), Guid.NewGuid()]); + var tags = command.TagIds.Take(1).Select(id => new Tag { Id = id, Name = "Name" }).ToList(); + + _validatorMock.Setup(validator => validator.ValidateAsync(command, It.IsAny())).ReturnsAsync(() => new()); + _tagRepositoryMock.Setup(repository => repository.GetTagsAsync(command.TagIds)).ReturnsAsync(() => tags); + + // Act + var result = await _sut.HandleAsync(command); + + // Assert + result.Error.Should().NotBeNull(); + result.Error?.Description.Should().Be("add-product-tags-count"); + } +} \ No newline at end of file diff --git a/backend/test/Tamaplante.Tests/Catalog/Application/Products/Commands/EditProductTests.cs b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Commands/EditProductTests.cs new file mode 100644 index 0000000..983f400 --- /dev/null +++ b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Commands/EditProductTests.cs @@ -0,0 +1,56 @@ +using Catalog.Application.Products.Commands; +using Catalog.Domain.Products.Entities; +using Catalog.Domain.Products.Repositories; +using Catalog.Domain.Tags.Entities; +using Catalog.Domain.Tags.Repositories; +using Common.Application.Commands; +using FluentAssertions; +using FluentValidation; +using Moq; + +namespace Tamaplante.Tests.Catalog.Application.Products.Commands; + +public class EditProductTests +{ + private readonly Mock _productRepositoryMock; + private readonly EditProduct.Handler _sut; + private readonly Mock _tagRepositoryMock; + private readonly Mock> _validatorMock; + + public EditProductTests() + { + var unitOfWorkMock = new Mock(); + _productRepositoryMock = new(); + _tagRepositoryMock = new(); + _validatorMock = new(); + + _sut = new(unitOfWorkMock.Object, _productRepositoryMock.Object, _tagRepositoryMock.Object, _validatorMock.Object); + } + + [Fact] + public async Task Handle_Should_Fail_When_TagsCountDoNotMatch() + { + // Arrange + var product = new Product + { + Id = Guid.NewGuid(), + Name = "Name", + Description = "Description", + Price = 10 + }; + + var command = new EditProduct.Command(product.Id, "Name", "Description", 10, [Guid.NewGuid(), Guid.NewGuid()]); + var tags = command.TagIds.Take(1).Select(id => new Tag { Id = id, Name = "Name" }).ToList(); + + _validatorMock.Setup(validator => validator.ValidateAsync(command, It.IsAny())).ReturnsAsync(() => new()); + _productRepositoryMock.Setup(repository => repository.GetByIdAsync(command.Id)).ReturnsAsync(() => product); + _tagRepositoryMock.Setup(repository => repository.GetTagsAsync(command.TagIds)).ReturnsAsync(() => tags); + + // Act + var result = await _sut.HandleAsync(command); + + // Assert + result.Error.Should().NotBeNull(); + result.Error?.Description.Should().Be("edit-product-tags-count"); + } +} \ No newline at end of file diff --git a/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/AddProductValidatorTests.cs b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/AddProductValidatorTests.cs index 678aa12..9844694 100644 --- a/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/AddProductValidatorTests.cs +++ b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/AddProductValidatorTests.cs @@ -16,7 +16,7 @@ public sealed class AddProductValidatorTests [InlineData("00000000-0000-0000-0000-000000000000", "name", "description", 0, false)] public async Task Should_Fail_When_Invalid_Command(string id, string name, string description, decimal price, bool valid) { - var command = new AddProduct.Command(Guid.Parse(id), name, description, price); + var command = new AddProduct.Command(Guid.Parse(id), name, description, price, []); var result = await _sut.TestValidateAsync(command); result.IsValid.Should().Be(valid); } diff --git a/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/EditProductValidatorTests.cs b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/EditProductValidatorTests.cs index 4f56cb2..f9d2c85 100644 --- a/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/EditProductValidatorTests.cs +++ b/backend/test/Tamaplante.Tests/Catalog/Application/Products/Validators/EditProductValidatorTests.cs @@ -16,7 +16,7 @@ public sealed class EditProductValidatorTests [InlineData("00000000-0000-0000-0000-000000000000", "name", "description", 0, false)] public async Task Should_Fail_When_Invalid_Command(string id, string name, string description, decimal price, bool valid) { - var command = new EditProduct.Command(Guid.Parse(id), name, description, price); + var command = new EditProduct.Command(Guid.Parse(id), name, description, price, []); var result = await _sut.TestValidateAsync(command); result.IsValid.Should().Be(valid); } diff --git a/backend/test/Tamaplante.Tests/Tamaplante.Tests.csproj b/backend/test/Tamaplante.Tests/Tamaplante.Tests.csproj index 0864f11..663d0b8 100644 --- a/backend/test/Tamaplante.Tests/Tamaplante.Tests.csproj +++ b/backend/test/Tamaplante.Tests/Tamaplante.Tests.csproj @@ -11,9 +11,9 @@ - + - + @@ -23,7 +23,7 @@ - + diff --git a/frontend/admin/src/api/types/index.ts b/frontend/admin/src/api/types/index.ts index 76c4457..ab20f22 100644 --- a/frontend/admin/src/api/types/index.ts +++ b/frontend/admin/src/api/types/index.ts @@ -20,8 +20,8 @@ import type { import { customInstance } from "../mutator/custom-instance"; import type { ErrorType, BodyType } from "../mutator/custom-instance"; export type GetApiV1TagsParams = { - pageIndex: number; - pageSize: number; + pageIndex?: number; + pageSize?: number; }; export type GetApiV1ProductsParams = { @@ -79,11 +79,25 @@ export interface CatalogProductsQueriesGetProductsResult { total: number; } +export interface CatalogProductsQueriesGetProductTagDto { + id: string; + name: string; +} + +export interface CatalogProductsQueriesGetProductProductDto { + description: string; + id: string; + name: string; + price: number; + tags: CatalogProductsQueriesGetProductTagDto[]; +} + export interface CatalogProductsEditProductCommand { description: string; id: string; name: string; price: number; + tagIds: string[]; } export interface CatalogProductsDeleteProductsCommand { @@ -95,6 +109,7 @@ export interface CatalogProductsAddProductCommand { id: string; name: string; price: number; + tagIds: string[]; } type SecondParameter any> = Parameters[1]; @@ -468,8 +483,156 @@ export const useDeleteApiV1Products = < return useMutation(mutationOptions); }; +export const getApiV1ProductsId = ( + id: string, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { url: `/api/v1/products/${id}`, method: "GET", signal }, + options, + ); +}; + +export const getGetApiV1ProductsIdQueryKey = (id: string) => { + return [`/api/v1/products/${id}`] as const; +}; + +export const getGetApiV1ProductsIdQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetApiV1ProductsIdQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => getApiV1ProductsId(id, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetApiV1ProductsIdQueryResult = NonNullable< + Awaited> +>; +export type GetApiV1ProductsIdQueryError = ErrorType; + +export function useGetApiV1ProductsId< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + TData + >, + "initialData" + >; + request?: SecondParameter; + }, +): DefinedUseQueryResult & { queryKey: QueryKey }; +export function useGetApiV1ProductsId< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + TData + >, + "initialData" + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey }; +export function useGetApiV1ProductsId< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey }; + +export function useGetApiV1ProductsId< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetApiV1ProductsIdQueryOptions(id, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + export const getApiV1Tags = ( - params: GetApiV1TagsParams, + params?: GetApiV1TagsParams, options?: SecondParameter, signal?: AbortSignal, ) => { @@ -479,7 +642,7 @@ export const getApiV1Tags = ( ); }; -export const getGetApiV1TagsQueryKey = (params: GetApiV1TagsParams) => { +export const getGetApiV1TagsQueryKey = (params?: GetApiV1TagsParams) => { return [`/api/v1/tags`, ...(params ? [params] : [])] as const; }; @@ -487,7 +650,7 @@ export const getGetApiV1TagsQueryOptions = < TData = Awaited>, TError = ErrorType, >( - params: GetApiV1TagsParams, + params?: GetApiV1TagsParams, options?: { query?: Partial< UseQueryOptions>, TError, TData> @@ -519,7 +682,7 @@ export function useGetApiV1Tags< TData = Awaited>, TError = ErrorType, >( - params: GetApiV1TagsParams, + params: undefined | GetApiV1TagsParams, options: { query: Partial< UseQueryOptions>, TError, TData> @@ -539,7 +702,7 @@ export function useGetApiV1Tags< TData = Awaited>, TError = ErrorType, >( - params: GetApiV1TagsParams, + params?: GetApiV1TagsParams, options?: { query?: Partial< UseQueryOptions>, TError, TData> @@ -559,7 +722,7 @@ export function useGetApiV1Tags< TData = Awaited>, TError = ErrorType, >( - params: GetApiV1TagsParams, + params?: GetApiV1TagsParams, options?: { query?: Partial< UseQueryOptions>, TError, TData> @@ -572,7 +735,7 @@ export function useGetApiV1Tags< TData = Awaited>, TError = ErrorType, >( - params: GetApiV1TagsParams, + params?: GetApiV1TagsParams, options?: { query?: Partial< UseQueryOptions>, TError, TData> diff --git a/frontend/admin/src/routes/products/-components/add-product.tsx b/frontend/admin/src/routes/products/-components/add-product.tsx index b5c3f8f..684be8a 100644 --- a/frontend/admin/src/routes/products/-components/add-product.tsx +++ b/frontend/admin/src/routes/products/-components/add-product.tsx @@ -1,12 +1,17 @@ import { useForm } from "@mantine/form"; import { zodResolver } from "mantine-form-zod-resolver"; -import { usePostApiV1Products } from "../../../api/types"; +import { useGetApiV1Tags, usePostApiV1Products } from "../../../api/types"; import { z } from "zod"; -import { Button, Flex, Modal, NumberInput, TextInput } from "@mantine/core"; +import { + Button, + Flex, + MultiSelect, + NumberInput, + TextInput, +} from "@mantine/core"; import { handleProblemDetailsError } from "../../../utils/error-utils.ts"; interface AddProductProps { - readonly opened: boolean; readonly onClose: () => void; readonly onSave: () => void; } @@ -15,17 +20,19 @@ const schema = z.object({ name: z.string().min(1).max(50), description: z.string().min(1).max(200), price: z.number().gte(0), + tagIds: z.array(z.string().uuid()), }); type Schema = z.infer; -const AddProduct = ({ opened, onClose, onSave }: AddProductProps) => { +const AddProduct = ({ onClose, onSave }: AddProductProps) => { const form = useForm({ mode: "uncontrolled", initialValues: { name: "", description: "", price: 0, + tagIds: [], }, validate: zodResolver(schema), }); @@ -41,6 +48,8 @@ const AddProduct = ({ opened, onClose, onSave }: AddProductProps) => { }, }); + const tagsQuery = useGetApiV1Tags(); + const handleSubmit = form.onSubmit(async (values) => { await mutateAsync({ data: { @@ -51,41 +60,53 @@ const AddProduct = ({ opened, onClose, onSave }: AddProductProps) => { }); return ( - -
- - - - - - - - + + + + + + ({ + value: t.id, + label: t.name, + })) ?? [] + } + label="Tags" + placeholder="Choose some tags" + searchable + clearable + /> + + + - -
+ + ); }; diff --git a/frontend/admin/src/routes/products/-components/edit-product.tsx b/frontend/admin/src/routes/products/-components/edit-product.tsx index 59a80ec..f0b8f5f 100644 --- a/frontend/admin/src/routes/products/-components/edit-product.tsx +++ b/frontend/admin/src/routes/products/-components/edit-product.tsx @@ -1,40 +1,47 @@ import { useForm } from "@mantine/form"; import { zodResolver } from "mantine-form-zod-resolver"; import { - CatalogProductsQueriesGetProductsDto, + useGetApiV1ProductsId, + useGetApiV1Tags, usePutApiV1Products, } from "../../../api/types"; import { z } from "zod"; -import { Button, Flex, Modal, NumberInput, TextInput } from "@mantine/core"; +import { + Button, + Flex, + MultiSelect, + NumberInput, + TextInput, +} from "@mantine/core"; import { handleProblemDetailsError } from "../../../utils/error-utils.ts"; +import { useEffect } from "react"; +import { sortAlphabetically } from "../../../utils/sort-utils.ts"; interface EditProductProps { - readonly product: CatalogProductsQueriesGetProductsDto; - readonly opened: boolean; + readonly id?: string; readonly onClose: () => void; readonly onSave: () => void; } const schema = z.object({ + id: z.string().uuid(), name: z.string().min(1).max(50), description: z.string().min(1).max(200), price: z.number().gte(0), + tagIds: z.array(z.string().uuid()), }); type Schema = z.infer; -const EditProduct = ({ - product, - opened, - onClose, - onSave, -}: EditProductProps) => { - const form = useForm({ +const EditProduct = ({ id, onClose, onSave }: EditProductProps) => { + const { setValues, ...form } = useForm({ mode: "uncontrolled", initialValues: { - name: product.name, - description: product.description, - price: product.price, + id: "", + name: "", + price: 0, + description: "", + tagIds: [], }, validate: zodResolver(schema), }); @@ -50,51 +57,82 @@ const EditProduct = ({ }, }); + const tagsQuery = useGetApiV1Tags(); + + const getProductQuery = useGetApiV1ProductsId(id ?? "", { + query: { enabled: !!id }, + }); + const handleSubmit = form.onSubmit(async (values) => { await mutateAsync({ data: { ...values, - id: product.id, }, }); }); + useEffect(() => { + if (getProductQuery.data) { + setValues({ + id: getProductQuery.data.id, + name: getProductQuery.data.name, + price: getProductQuery.data.price, + description: getProductQuery.data.description, + tagIds: getProductQuery.data.tags + .sort(sortAlphabetically((x) => x.name)) + .map((t) => t.id), + }); + } + }, [setValues, getProductQuery.data]); + return ( - -
- - - - - - - - + + + + + + ({ + value: t.id, + label: t.name, + })) ?? [] + } + label="Tags" + placeholder="Choose some tags" + searchable + clearable + /> + + + - -
+ + ); }; diff --git a/frontend/admin/src/routes/products/-components/products-list.tsx b/frontend/admin/src/routes/products/-components/products-list.tsx index b27e095..0e2531f 100644 --- a/frontend/admin/src/routes/products/-components/products-list.tsx +++ b/frontend/admin/src/routes/products/-components/products-list.tsx @@ -2,7 +2,7 @@ import { useDeleteApiV1Products, useGetApiV1Products, } from "../../../api/types"; -import { Button, Checkbox, Flex, Table } from "@mantine/core"; +import { Button, Checkbox, Flex, Modal, Table } from "@mantine/core"; import TablePagination from "../../../components/table-pagination.tsx"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useDisclosure } from "@mantine/hooks"; @@ -45,13 +45,11 @@ const ProductsList = () => { setSelectedRows([]); }, [productsQuery.data?.products]); - const selectedProduct = useMemo(() => { + const selectedEditId = useMemo(() => { if (selectedRows.length !== 1) return; - return productsQuery.data?.products.find( - (product) => product.id === selectedRows[0], - ); - }, [productsQuery.data?.products, selectedRows]); + return selectedRows[0]; + }, [selectedRows]); const rows = productsQuery.data?.products.map((p) => ( @@ -83,19 +81,29 @@ const ProductsList = () => { return ( <> - - {selectedProduct && ( + + { + await productsQuery.refetch(); + closeAdd(); + }} + onClose={closeAdd} + /> + + { + await productsQuery.refetch(); + closeEdit(); + }} onClose={closeEdit} /> - )} + - - + + + + + + - - + + ); }; diff --git a/frontend/admin/src/routes/tags/-components/edit-tag.tsx b/frontend/admin/src/routes/tags/-components/edit-tag.tsx index e3e4d2a..62f35ed 100644 --- a/frontend/admin/src/routes/tags/-components/edit-tag.tsx +++ b/frontend/admin/src/routes/tags/-components/edit-tag.tsx @@ -5,27 +5,29 @@ import { usePutApiV1Tags, } from "../../../api/types"; import { z } from "zod"; -import { Button, Flex, Modal, TextInput } from "@mantine/core"; +import { Button, Flex, TextInput } from "@mantine/core"; import { handleProblemDetailsError } from "../../../utils/error-utils.ts"; +import { useEffect } from "react"; interface EditTagProps { - readonly tag: CatalogTagsQueriesGetTagsDto; - readonly opened: boolean; + readonly tag?: CatalogTagsQueriesGetTagsDto; readonly onClose: () => void; readonly onSave: () => void; } const schema = z.object({ + id: z.string().uuid(), name: z.string().min(1).max(20), }); type Schema = z.infer; -const EditTag = ({ tag, opened, onClose, onSave }: EditTagProps) => { - const form = useForm({ +const EditTag = ({ tag, onClose, onSave }: EditTagProps) => { + const { setValues, ...form } = useForm({ mode: "uncontrolled", initialValues: { - name: tag.name, + id: "", + name: "", }, validate: zodResolver(schema), }); @@ -45,31 +47,34 @@ const EditTag = ({ tag, opened, onClose, onSave }: EditTagProps) => { await mutateAsync({ data: { ...values, - id: tag.id, }, }); }); + useEffect(() => { + if (tag) { + setValues({ ...tag }); + } + }, [setValues, tag]); + return ( - -
- - - - - - + + + + + + - -
+
+ ); }; diff --git a/frontend/admin/src/routes/tags/-components/tags-list.tsx b/frontend/admin/src/routes/tags/-components/tags-list.tsx index 7604357..07e3e1a 100644 --- a/frontend/admin/src/routes/tags/-components/tags-list.tsx +++ b/frontend/admin/src/routes/tags/-components/tags-list.tsx @@ -1,5 +1,5 @@ import { useDeleteApiV1Tags, useGetApiV1Tags } from "../../../api/types"; -import { Button, Checkbox, Flex, Table } from "@mantine/core"; +import { Button, Checkbox, Flex, Modal, Table } from "@mantine/core"; import TablePagination from "../../../components/table-pagination.tsx"; import { useCallback, useEffect, useMemo, useState } from "react"; import AddTag from "./add-tag.tsx"; @@ -76,19 +76,27 @@ const TagsList = () => { return ( <> - - {selectedTag && ( + {addOpened && ( + + { + await tagsQuery.refetch(); + closeAdd(); + }} + onClose={closeAdd} + /> + + )} + { + await tagsQuery.refetch(); + closeEdit(); + }} onClose={closeEdit} /> - )} +