diff --git a/backend/src/Catalog.Application/Tags/Commands/AddTag.cs b/backend/src/Catalog.Application/Tags/Commands/AddTag.cs new file mode 100644 index 0000000..247007f --- /dev/null +++ b/backend/src/Catalog.Application/Tags/Commands/AddTag.cs @@ -0,0 +1,47 @@ +using Catalog.Domain.Tags.Entities; +using Catalog.Domain.Tags.Repositories; +using Common.Application.Commands; +using Common.Application.Models; +using FluentValidation; + +namespace Catalog.Application.Tags.Commands; + +public static class AddTag +{ + public sealed record Command(Guid Id, string Name) : ICommand; + + public sealed class Handler(IUnitOfWork unitOfWork, ITagRepository tagRepository, IValidator validator) : ICommandHandler + { + public async Task HandleAsync(Command command) + { + var (id, name) = command; + + var validationResult = await validator.ValidateAsync(command); + if (!validationResult.IsValid) return Result.Fail("add-tag-validation"); + + var tag = new Tag + { + Id = id, + Name = name + }; + + await tagRepository.AddAsync(tag); + await unitOfWork.CommitAsync(); + + return Result.Ok(); + } + } + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Id) + .NotEmpty(); + + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(20); + } + } +} \ No newline at end of file diff --git a/backend/src/Catalog.Domain/Tags/Repositories/ITagRepository.cs b/backend/src/Catalog.Domain/Tags/Repositories/ITagRepository.cs new file mode 100644 index 0000000..286003a --- /dev/null +++ b/backend/src/Catalog.Domain/Tags/Repositories/ITagRepository.cs @@ -0,0 +1,8 @@ +using Catalog.Domain.Tags.Entities; + +namespace Catalog.Domain.Tags.Repositories; + +public interface ITagRepository +{ + Task AddAsync(Tag tag); +} \ No newline at end of file diff --git a/backend/src/Host.WebApi/Routes/TagRoutes.cs b/backend/src/Host.WebApi/Routes/TagRoutes.cs index db96aa4..f8d3fe1 100644 --- a/backend/src/Host.WebApi/Routes/TagRoutes.cs +++ b/backend/src/Host.WebApi/Routes/TagRoutes.cs @@ -1,5 +1,8 @@ -using Catalog.Application.Tags.Queries; +using Catalog.Application.Tags.Commands; +using Catalog.Application.Tags.Queries; +using Common.Application.Commands; using Common.Application.Queries; +using Host.WebApi.Extensions; using Microsoft.AspNetCore.Mvc; namespace Host.WebApi.Routes; @@ -18,5 +21,14 @@ internal static void MapTagRoutes(this IEndpointRouteBuilder api) }) .Produces() .ProducesProblem(StatusCodes.Status500InternalServerError); + + group.MapPost("", async ([FromServices] ICommandHandler handler, [FromBody] AddTag.Command command) => + { + var result = await handler.HandleAsync(command); + return result.ToHttp(); + }) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError); } } \ 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 c1ccbbc..244abaa 100644 --- a/backend/src/Infrastructure.Data/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Infrastructure.Data/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Catalog.Domain.Products.Repositories; +using Catalog.Domain.Tags.Repositories; using Common.Application.Commands; using Common.Application.Queries; using Infrastructure.Data.Common; @@ -18,6 +19,7 @@ public static IServiceCollection AddInfrastructureData(this IServiceCollection s services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services; } diff --git a/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs b/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs index c4a14d6..f077a88 100644 --- a/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs +++ b/backend/src/Infrastructure.Data/Repositories/EfProductRepository.cs @@ -14,7 +14,7 @@ public Task> GetProductsAsync(List ids) public Task AddAsync(Product product) { - return context.Set().AddRangeAsync(product); + return context.Set().AddAsync(product).AsTask(); } public void Delete(List products) diff --git a/backend/src/Infrastructure.Data/Repositories/EfTagRepository.cs b/backend/src/Infrastructure.Data/Repositories/EfTagRepository.cs new file mode 100644 index 0000000..6c1798e --- /dev/null +++ b/backend/src/Infrastructure.Data/Repositories/EfTagRepository.cs @@ -0,0 +1,13 @@ +using Catalog.Domain.Tags.Entities; +using Catalog.Domain.Tags.Repositories; +using Infrastructure.Data.Contexts; + +namespace Infrastructure.Data.Repositories; + +internal sealed class EfTagRepository(AppDbContext context) : ITagRepository +{ + public Task AddAsync(Tag tag) + { + return context.Set().AddAsync(tag).AsTask(); + } +} \ 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 new file mode 100644 index 0000000..aed6f19 --- /dev/null +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/AddTagTests.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Net.Http.Json; +using Catalog.Application.Tags.Commands; +using Catalog.Domain.Tags.Entities; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Tamaplante.IntegrationTests.Common; + +namespace Tamaplante.IntegrationTests.Catalog.Tags.Commands; + +[Collection("IntegrationTests")] +public sealed class AddTagTest(IntegrationFixture integrationFixture) +{ + [Fact] + public async Task AddTag_Should_BeSuccessful() + { + // Arrange + await integrationFixture.ResetDatabaseAsync(); + using var client = integrationFixture.Factory.CreateClient(); + + var command = new AddTag.Command(Guid.NewGuid(), "Name"); + + // Act + var response = await client.PostAsJsonAsync("api/v1/tags", command); + + // Assert + 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(); + } +} \ No newline at end of file diff --git a/backend/test/Tamaplante.Tests/Catalog/Application/Tags/Validators/AddTagValidatorTests.cs b/backend/test/Tamaplante.Tests/Catalog/Application/Tags/Validators/AddTagValidatorTests.cs new file mode 100644 index 0000000..3ef99fd --- /dev/null +++ b/backend/test/Tamaplante.Tests/Catalog/Application/Tags/Validators/AddTagValidatorTests.cs @@ -0,0 +1,20 @@ +using Catalog.Application.Tags.Commands; +using FluentAssertions; +using FluentValidation.TestHelper; + +namespace Tamaplante.Tests.Catalog.Application.Tags.Validators; + +public sealed class AddTagValidatorTests +{ + private readonly AddTag.Validator _sut = new(); + + [Theory] + [InlineData("2BE2D805-294B-4EA5-96E0-087431636A0B", "name", true)] + [InlineData("2BE2D805-294B-4EA5-96E0-087431636A0B", "", false)] + public async Task Should_Fail_When_Invalid_Command(string id, string name, bool valid) + { + var command = new AddTag.Command(Guid.Parse(id), name); + var result = await _sut.TestValidateAsync(command); + result.IsValid.Should().Be(valid); + } +} \ No newline at end of file diff --git a/frontend/admin/src/api/types/index.ts b/frontend/admin/src/api/types/index.ts index 920261d..4af60fc 100644 --- a/frontend/admin/src/api/types/index.ts +++ b/frontend/admin/src/api/types/index.ts @@ -53,6 +53,11 @@ export interface CatalogTagsQueriesGetTagsResult { total: number; } +export interface CatalogTagsAddTagCommand { + id: string; + name: string; +} + export interface CatalogProductsQueriesGetProductsDto { description: string; id: string; @@ -576,3 +581,77 @@ export function useGetApiV1Tags< return query; } + +export const postApiV1Tags = ( + catalogTagsAddTagCommand: BodyType, + options?: SecondParameter, +) => { + return customInstance( + { + url: `/api/v1/tags`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: catalogTagsAddTagCommand, + }, + options, + ); +}; + +export const getPostApiV1TagsMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const { mutation: mutationOptions, request: requestOptions } = options ?? {}; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return postApiV1Tags(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type PostApiV1TagsMutationResult = NonNullable< + Awaited> +>; +export type PostApiV1TagsMutationBody = BodyType; +export type PostApiV1TagsMutationError = ErrorType; + +export const usePostApiV1Tags = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationOptions = getPostApiV1TagsMutationOptions(options); + + return useMutation(mutationOptions); +}; diff --git a/frontend/admin/src/routes/products/-components/products-list.tsx b/frontend/admin/src/routes/products/-components/products-list.tsx index 7234b41..b27e095 100644 --- a/frontend/admin/src/routes/products/-components/products-list.tsx +++ b/frontend/admin/src/routes/products/-components/products-list.tsx @@ -55,7 +55,14 @@ const ProductsList = () => { const rows = productsQuery.data?.products.map((p) => ( - + { - + Name Description Price diff --git a/frontend/admin/src/routes/tags/-components/add-tag.tsx b/frontend/admin/src/routes/tags/-components/add-tag.tsx new file mode 100644 index 0000000..cb4c14f --- /dev/null +++ b/frontend/admin/src/routes/tags/-components/add-tag.tsx @@ -0,0 +1,72 @@ +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { usePostApiV1Tags } from "../../../api/types"; +import { z } from "zod"; +import { Button, Flex, Modal, TextInput } from "@mantine/core"; +import { handleProblemDetailsError } from "../../../utils/error-utils.ts"; + +interface AddTagProps { + readonly opened: boolean; + readonly onClose: () => void; + readonly onSave: () => void; +} + +const schema = z.object({ + name: z.string().min(1).max(20), +}); + +type Schema = z.infer; + +const AddTag = ({ opened, onClose, onSave }: AddTagProps) => { + const form = useForm({ + mode: "uncontrolled", + initialValues: { + name: "", + }, + validate: zodResolver(schema), + }); + + const { mutateAsync } = usePostApiV1Tags({ + mutation: { + onError: handleProblemDetailsError, + onSuccess: () => { + form.reset(); + onSave(); + onClose(); + }, + }, + }); + + const handleSubmit = form.onSubmit(async (values) => { + await mutateAsync({ + data: { + ...values, + id: crypto.randomUUID(), + }, + }); + }); + + return ( + +
+ + + + + + + + +
+ ); +}; + +export default AddTag; diff --git a/frontend/admin/src/routes/tags/-components/tags-list.tsx b/frontend/admin/src/routes/tags/-components/tags-list.tsx index f8e264f..fd2672f 100644 --- a/frontend/admin/src/routes/tags/-components/tags-list.tsx +++ b/frontend/admin/src/routes/tags/-components/tags-list.tsx @@ -1,12 +1,15 @@ import { useGetApiV1Tags } from "../../../api/types"; -import { Checkbox, Flex, Table } from "@mantine/core"; +import { Button, Checkbox, Flex, Table } from "@mantine/core"; import TablePagination from "../../../components/table-pagination.tsx"; import { useEffect, useState } from "react"; +import AddTag from "./add-tag.tsx"; +import { useDisclosure } from "@mantine/hooks"; const TagsList = () => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); const [selectedRows, setSelectedRows] = useState([]); + const [addOpened, { open: openAdd, close: closeAdd }] = useDisclosure(false); const tagsQuery = useGetApiV1Tags({ pageIndex, @@ -18,7 +21,14 @@ const TagsList = () => { const rows = tagsQuery.data?.tags.map((t) => ( - + { )) ?? []; return ( - -
- - - - Name - - - {rows} -
- + - + + + + + + + + + Name + + + {rows} +
+ +
+ ); };