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 (
-
-
+
+
);
};
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 (
-
-
+
+
);
};
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 (
-
-
-
-
-
- Save
-
- Cancel
-
-
+
+
+
+
+
+ Cancel
+
+ Save
-
-
+
+
);
};
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}
/>
- )}
+
diff --git a/frontend/admin/src/routes/tags/index.lazy.tsx b/frontend/admin/src/routes/tags/index.lazy.tsx
index 07b5b03..c3d7c3e 100644
--- a/frontend/admin/src/routes/tags/index.lazy.tsx
+++ b/frontend/admin/src/routes/tags/index.lazy.tsx
@@ -1,5 +1,5 @@
-import { createLazyFileRoute } from "@tanstack/react-router";
-import { Paper } from "@mantine/core";
+import { createLazyFileRoute, Link, useRouter } from "@tanstack/react-router";
+import { Anchor, Breadcrumbs, Flex, Paper } from "@mantine/core";
import TagsList from "./-components/tags-list.tsx";
export const Route = createLazyFileRoute("/tags/")({
@@ -7,9 +7,21 @@ export const Route = createLazyFileRoute("/tags/")({
});
function Tags() {
+ const { routesByPath } = useRouter();
+
return (
-
-
-
+
+
+
+
+ Tags
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/admin/src/utils/sort-utils.ts b/frontend/admin/src/utils/sort-utils.ts
new file mode 100644
index 0000000..c8f0698
--- /dev/null
+++ b/frontend/admin/src/utils/sort-utils.ts
@@ -0,0 +1,3 @@
+export const sortAlphabetically = (mapFn: (t: T) => string) => {
+ return (a: T, b: T) => (a === b ? 0 : mapFn(a) > mapFn(b) ? 1 : -1);
+};