From c439a5fdde63cbd245bdb100d11e4d21d0335f7a Mon Sep 17 00:00:00 2001 From: BusschaertTanguy Date: Fri, 18 Oct 2024 09:55:06 +0200 Subject: [PATCH] #19 Catalog - Edit Tag --- .../Tags/Commands/EditTag.cs | 43 ++++++++++ .../src/Catalog.Domain/Tags/Entities/Tag.cs | 2 +- .../Tags/Repositories/ITagRepository.cs | 1 + backend/src/Host.WebApi/Routes/TagRoutes.cs | 9 +++ .../Repositories/EfTagRepository.cs | 5 ++ .../Products/Commands/EditProductTests.cs | 2 +- .../Catalog/Tags/Commands/EditTagTests.cs | 51 ++++++++++++ .../Tags/Validators/EditTagValidatorTests.cs | 20 +++++ frontend/admin/src/api/types/index.ts | 79 +++++++++++++++++++ .../src/routes/tags/-components/edit-tag.tsx | 76 ++++++++++++++++++ .../src/routes/tags/-components/tags-list.tsx | 26 +++++- 11 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 backend/src/Catalog.Application/Tags/Commands/EditTag.cs create mode 100644 backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs create mode 100644 backend/test/Tamaplante.Tests/Catalog/Application/Tags/Validators/EditTagValidatorTests.cs create mode 100644 frontend/admin/src/routes/tags/-components/edit-tag.tsx diff --git a/backend/src/Catalog.Application/Tags/Commands/EditTag.cs b/backend/src/Catalog.Application/Tags/Commands/EditTag.cs new file mode 100644 index 0000000..becf540 --- /dev/null +++ b/backend/src/Catalog.Application/Tags/Commands/EditTag.cs @@ -0,0 +1,43 @@ +using Catalog.Domain.Tags.Repositories; +using Common.Application.Commands; +using Common.Application.Models; +using FluentValidation; + +namespace Catalog.Application.Tags.Commands; + +public static class EditTag +{ + 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("edit-tag-validation"); + + var tag = await tagRepository.GetByIdAsync(id); + + tag.Name = name; + + 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/Entities/Tag.cs b/backend/src/Catalog.Domain/Tags/Entities/Tag.cs index 8ddcc8c..cd97f42 100644 --- a/backend/src/Catalog.Domain/Tags/Entities/Tag.cs +++ b/backend/src/Catalog.Domain/Tags/Entities/Tag.cs @@ -3,5 +3,5 @@ public sealed class Tag { public required Guid Id { get; init; } - public required string Name { get; init; } + public required string Name { get; set; } } \ 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 index c78e4c1..129478a 100644 --- a/backend/src/Catalog.Domain/Tags/Repositories/ITagRepository.cs +++ b/backend/src/Catalog.Domain/Tags/Repositories/ITagRepository.cs @@ -7,4 +7,5 @@ public interface ITagRepository Task> GetTagsAsync(List ids); Task AddAsync(Tag tag); void Delete(List tags); + Task GetByIdAsync(Guid id); } \ 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 27e1515..96316e6 100644 --- a/backend/src/Host.WebApi/Routes/TagRoutes.cs +++ b/backend/src/Host.WebApi/Routes/TagRoutes.cs @@ -31,6 +31,15 @@ internal static void MapTagRoutes(this IEndpointRouteBuilder api) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status500InternalServerError); + group.MapPut("", async ([FromServices] ICommandHandler handler, [FromBody] EditTag.Command command) => + { + var result = await handler.HandleAsync(command); + return result.ToHttp(); + }) + .Produces(StatusCodes.Status204NoContent) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError); + group.MapDelete("", async ([FromServices] ICommandHandler handler, [FromBody] DeleteTags.Command command) => { var result = await handler.HandleAsync(command); diff --git a/backend/src/Infrastructure.Data/Repositories/EfTagRepository.cs b/backend/src/Infrastructure.Data/Repositories/EfTagRepository.cs index 991b183..1cd115e 100644 --- a/backend/src/Infrastructure.Data/Repositories/EfTagRepository.cs +++ b/backend/src/Infrastructure.Data/Repositories/EfTagRepository.cs @@ -23,4 +23,9 @@ public void Delete(List tags) { context.Set().RemoveRange(tags); } + + public Task GetByIdAsync(Guid id) + { + return context.Set().FirstAsync(t => t.Id == id); + } } \ 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 d8fc93d..eec5b09 100644 --- a/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/EditProductTests.cs +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Products/Commands/EditProductTests.cs @@ -30,7 +30,7 @@ public async Task EditProduct_Should_BeSuccessful() Price = 10 }; - await dbContext.AddAsync(product); + await dbContext.Set().AddAsync(product); await dbContext.SaveChangesAsync(); } diff --git a/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs b/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs new file mode 100644 index 0000000..eef0556 --- /dev/null +++ b/backend/test/Tamaplante.IntegrationTests/Catalog/Tags/Commands/EditTagTests.cs @@ -0,0 +1,51 @@ +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 EditTagTest(IntegrationFixture integrationFixture) +{ + [Fact] + public async Task EditTag_Should_BeSuccessful() + { + // Arrange + await integrationFixture.ResetDatabaseAsync(); + using var client = integrationFixture.Factory.CreateClient(); + + var tagId = Guid.NewGuid(); + + 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"); + + // Act + var response = await client.PutAsJsonAsync("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 == tagId); + tag.Should().NotBeNull(); + tag.Should().BeEquivalentTo(new Tag { Id = tagId, Name = command.Name }); + } + } +} \ No newline at end of file diff --git a/backend/test/Tamaplante.Tests/Catalog/Application/Tags/Validators/EditTagValidatorTests.cs b/backend/test/Tamaplante.Tests/Catalog/Application/Tags/Validators/EditTagValidatorTests.cs new file mode 100644 index 0000000..21aa367 --- /dev/null +++ b/backend/test/Tamaplante.Tests/Catalog/Application/Tags/Validators/EditTagValidatorTests.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 EditTagValidatorTests +{ + private readonly EditTag.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 EditTag.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 2a8124d..76c4457 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 CatalogTagsEditTagCommand { + id: string; + name: string; +} + export interface CatalogTagsDeleteTagsCommand { tagIds: string[]; } @@ -660,6 +665,80 @@ export const usePostApiV1Tags = < return useMutation(mutationOptions); }; +export const putApiV1Tags = ( + catalogTagsEditTagCommand: BodyType, + options?: SecondParameter, +) => { + return customInstance( + { + url: `/api/v1/tags`, + method: "PUT", + headers: { "Content-Type": "application/json" }, + data: catalogTagsEditTagCommand, + }, + options, + ); +}; + +export const getPutApiV1TagsMutationOptions = < + 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 putApiV1Tags(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type PutApiV1TagsMutationResult = NonNullable< + Awaited> +>; +export type PutApiV1TagsMutationBody = BodyType; +export type PutApiV1TagsMutationError = ErrorType; + +export const usePutApiV1Tags = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationOptions = getPutApiV1TagsMutationOptions(options); + + return useMutation(mutationOptions); +}; + export const deleteApiV1Tags = ( catalogTagsDeleteTagsCommand: BodyType, options?: SecondParameter, diff --git a/frontend/admin/src/routes/tags/-components/edit-tag.tsx b/frontend/admin/src/routes/tags/-components/edit-tag.tsx new file mode 100644 index 0000000..e3e4d2a --- /dev/null +++ b/frontend/admin/src/routes/tags/-components/edit-tag.tsx @@ -0,0 +1,76 @@ +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { + CatalogTagsQueriesGetTagsDto, + usePutApiV1Tags, +} from "../../../api/types"; +import { z } from "zod"; +import { Button, Flex, Modal, TextInput } from "@mantine/core"; +import { handleProblemDetailsError } from "../../../utils/error-utils.ts"; + +interface EditTagProps { + readonly tag: CatalogTagsQueriesGetTagsDto; + 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 EditTag = ({ tag, opened, onClose, onSave }: EditTagProps) => { + const form = useForm({ + mode: "uncontrolled", + initialValues: { + name: tag.name, + }, + validate: zodResolver(schema), + }); + + const { mutateAsync } = usePutApiV1Tags({ + mutation: { + onError: handleProblemDetailsError, + onSuccess: () => { + form.reset(); + onSave(); + onClose(); + }, + }, + }); + + const handleSubmit = form.onSubmit(async (values) => { + await mutateAsync({ + data: { + ...values, + id: tag.id, + }, + }); + }); + + return ( + +
+ + + + + + + +
+
+ ); +}; + +export default EditTag; diff --git a/frontend/admin/src/routes/tags/-components/tags-list.tsx b/frontend/admin/src/routes/tags/-components/tags-list.tsx index c492f60..7604357 100644 --- a/frontend/admin/src/routes/tags/-components/tags-list.tsx +++ b/frontend/admin/src/routes/tags/-components/tags-list.tsx @@ -1,15 +1,18 @@ import { useDeleteApiV1Tags, useGetApiV1Tags } from "../../../api/types"; import { Button, Checkbox, Flex, Table } from "@mantine/core"; import TablePagination from "../../../components/table-pagination.tsx"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import AddTag from "./add-tag.tsx"; import { useDisclosure } from "@mantine/hooks"; +import EditTag from "./edit-tag.tsx"; 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 [editOpened, { open: openEdit, close: closeEdit }] = + useDisclosure(false); const tagsQuery = useGetApiV1Tags({ pageIndex, @@ -39,6 +42,12 @@ const TagsList = () => { setSelectedRows([]); }, [tagsQuery.data?.tags]); + const selectedTag = useMemo(() => { + if (selectedRows.length !== 1) return; + + return tagsQuery.data?.tags.find((tag) => tag.id === selectedRows[0]); + }, [tagsQuery.data?.tags, selectedRows]); + const rows = tagsQuery.data?.tags.map((t) => ( { onSave={tagsQuery.refetch} onClose={closeAdd} /> + {selectedTag && ( + + )} +