From c56dea60eb00f40528eea8078be3f757e82d323a Mon Sep 17 00:00:00 2001 From: tomek Date: Fri, 6 Sep 2024 18:30:45 +0200 Subject: [PATCH 01/12] Add basic unit tests for Create and Update Workspace operations --- .../CreateWorkspaceCommandHandler.cs | 2 +- .../ValueObjects/WorkspaceOwnerId.cs | 5 ++ .../CreateProjectCommandHandlerTests.cs | 4 +- .../TestUtils/Common/ValueGenerators.cs | 16 ++++++ .../Constants/Constants.Workspace.cs | 10 ++++ .../TestUtils/Projects/ProjectExtensions.cs | 2 +- .../Workspaces/WorkspaceExtensions.cs | 21 ++++++++ .../CreateWorkspaceCommandHandlerTest.cs | 42 ++++++++++++++++ .../UpdateWorkspaceCommandHandlerTest.cs | 49 +++++++++++++++++++ .../TestUtils/CreateWorkspaceCommandUtils.cs | 14 ++++++ .../TestUtils/UpdateWorkspaceCommandUtils.cs | 33 +++++++++++++ 11 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Common/ValueGenerators.cs create mode 100644 tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Constants/Constants.Workspace.cs create mode 100644 tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Workspaces/WorkspaceExtensions.cs create mode 100644 tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandlerTest.cs create mode 100644 tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/UpdateWorkspace/UpdateWorkspaceCommandHandlerTest.cs create mode 100644 tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/CreateWorkspaceCommandUtils.cs create mode 100644 tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/UpdateWorkspaceCommandUtils.cs diff --git a/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs b/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs index f7b61b6..3636490 100644 --- a/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs +++ b/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs @@ -11,7 +11,7 @@ public class CreateWorkspaceCommandHandler : IRequestHandler ValidCreateProjectCommands() tasks: CreateProjectCommandUtils.CreateTasksCommand(tasksCount: 3) )}; yield return new[] { CreateProjectCommandUtils.CreateCommand( - tasks: CreateProjectCommandUtils.CreateTasksCommand(tasksCount: 3) + tasks: CreateProjectCommandUtils.CreateTasksCommand(tasksCount: 5) )}; } } \ No newline at end of file diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Common/ValueGenerators.cs b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Common/ValueGenerators.cs new file mode 100644 index 0000000..d569b52 --- /dev/null +++ b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Common/ValueGenerators.cs @@ -0,0 +1,16 @@ +namespace PlanIt.Application.UnitTests.TestUtils.Common; + +public static class ValueGeneratorsUtils +{ + public static string GenerateSequentialGuid(int number) + { + if (number < 0) + { + throw new ArgumentException("Number can't be negative.", nameof(number)); + } + + string guidFormat = "00000000-0000-0000-0000-{0:D12}"; + string sequentialPart = number.ToString("D12"); + return string.Format(guidFormat, sequentialPart); + } +} \ No newline at end of file diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Constants/Constants.Workspace.cs b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Constants/Constants.Workspace.cs new file mode 100644 index 0000000..1d2fa4c --- /dev/null +++ b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Constants/Constants.Workspace.cs @@ -0,0 +1,10 @@ +namespace PlanIt.Application.UnitTests.TestUtils.Constants; + +public static partial class Constants +{ + public static class Workspace{ + public const string Id = "00000000-0000-0000-0000-000000000000"; + public const string Name = "Workspace Name"; + public const string Description = "Workspace Description"; + } +} \ No newline at end of file diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Projects/ProjectExtensions.cs b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Projects/ProjectExtensions.cs index a24ad3c..d41a4db 100644 --- a/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Projects/ProjectExtensions.cs +++ b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Projects/ProjectExtensions.cs @@ -3,7 +3,7 @@ using PlanIt.Domain.ProjectAggregate; using PlanIt.Domain.ProjectAggregate.Entities; -namespace PlanIt.Application.UnitTests.TestUtils.Projects.Extensions; +namespace PlanIt.Application.UnitTests.TestUtils.Projects; public static partial class ProjectExtensions { public static void ValidateCreatedFrom(this Project project, CreateProjectCommand command) diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Workspaces/WorkspaceExtensions.cs b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Workspaces/WorkspaceExtensions.cs new file mode 100644 index 0000000..cb7372e --- /dev/null +++ b/tests/UnitTests/PlanIt.Application.UnitTests/TestUtils/Workspaces/WorkspaceExtensions.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using PlanIt.Application.Workspaces.Commands.CreateWorkspace; +using PlanIt.Application.Workspaces.Commands.UpdateWorkspace; +using PlanIt.Domain.WorkspaceAggregate; + +namespace PlanIt.Application.UnitTests.TestUtils.Workspaces; + +public static partial class WorkspaceExtensions +{ + public static void ValidateCreatedFrom(this Workspace workspace, CreateWorkspaceCommand command) + { + workspace.Name.Should().Be(command.Name); + workspace.Description.Should().Be(command.Description); + } + + public static void ValidateUpdatedFrom(this Workspace workspace, UpdateWorkspaceCommand command) + { + workspace.Name.Should().Be(command.Name); + workspace.Description.Should().Be(command.Description); + } +} \ No newline at end of file diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandlerTest.cs b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandlerTest.cs new file mode 100644 index 0000000..1a60a05 --- /dev/null +++ b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandlerTest.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using NSubstitute; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Application.UnitTests.TestUtils.Projects; +using PlanIt.Application.UnitTests.TestUtils.Workspaces; +using PlanIt.Application.UnitTests.Workspaces.TestUtils; +using PlanIt.Application.Workspaces.Commands.CreateWorkspace; +using PlanIt.Application.UnitTests.TestUtils.Constants; + +namespace PlanIt.Application.UnitTests.Workspaces.Commands.CreateWorkspace; + +public class CreateProjectCommandHandlerTest +{ + private readonly IWorkspaceRepository _mockWorkspaceRepository; + private readonly IUserContext _userContext; + private readonly CreateWorkspaceCommandHandler _handler; + + public CreateProjectCommandHandlerTest() + { + _mockWorkspaceRepository = Substitute.For(); + _userContext = Substitute.For(); + + _handler = new CreateWorkspaceCommandHandler(_userContext, _mockWorkspaceRepository); + } + + [Fact] + public async Task HandleCreateWorkspaceCommand_WhenWorkspaceIsValid_ShouldCreateAndReturnWorkspace() + { + // Arrange + _userContext.TryGetUserId().Returns(Constants.User.Id); + var createWorkspaceCommand = CreateWorkspaceCommandUtils.CreateCommand(); + + // Act + var result = await _handler.Handle(createWorkspaceCommand, default); + + // Assert + result.IsFailed.Should().BeFalse(); + result.Value.ValidateCreatedFrom(createWorkspaceCommand); + await _mockWorkspaceRepository.Received().AddAsync(result.Value); + await _mockWorkspaceRepository.Received(1).AddAsync(result.Value); + } +} \ No newline at end of file diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/UpdateWorkspace/UpdateWorkspaceCommandHandlerTest.cs b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/UpdateWorkspace/UpdateWorkspaceCommandHandlerTest.cs new file mode 100644 index 0000000..6c5ddc6 --- /dev/null +++ b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/Commands/UpdateWorkspace/UpdateWorkspaceCommandHandlerTest.cs @@ -0,0 +1,49 @@ +using NSubstitute; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Application.Workspaces.Commands.UpdateWorkspace; +using PlanIt.Application.UnitTests.Workspaces.TestUtils; +using FluentAssertions; +using PlanIt.Application.UnitTests.TestUtils.Workspaces; +using PlanIt.Application.UnitTests.TestUtils.Constants; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; + +namespace PlanIt.Application.UnitTests.Workspaces.Commands.UpdateWorkspace; + +public class UpdateWorkspaceCommandHandlerTest +{ + private readonly IWorkspaceRepository _mockWorkspaceRepository; + private readonly IUserContext _userContext; + private readonly UpdateWorkspaceCommandHandler _handler; + + public UpdateWorkspaceCommandHandlerTest() + { + _mockWorkspaceRepository = Substitute.For(); + _userContext = Substitute.For(); + + _handler = new UpdateWorkspaceCommandHandler( _userContext, _mockWorkspaceRepository); + } + + [Theory] + [MemberData(nameof(ValidUpdateWorkspaceCommands))] + public async Task HandleUpdateWorkspaceCommand_WhenWorkspaceIsValid_ShouldUpdateAndReturnWorkspace(UpdateWorkspaceCommand updateProjectCommand) + { + // Arrange + var mockedWorkspace = UpdateWorkspaceCommandUtils.GetMockedWorkspace(); + _userContext.TryGetUserId().Returns(Constants.User.Id); + _mockWorkspaceRepository.GetAsync(Arg.Any()).Returns(mockedWorkspace); + + // Act + var result = await _handler.Handle(updateProjectCommand, default); + + // Assert + result.IsFailed.Should().BeFalse(); + result.Value.ValidateUpdatedFrom(updateProjectCommand); + await _mockWorkspaceRepository.Received().SaveChangesAsync(); + await _mockWorkspaceRepository.Received(1).SaveChangesAsync(); + } + + public static IEnumerable ValidUpdateWorkspaceCommands() + { + yield return new[] { UpdateWorkspaceCommandUtils.CreateCommand() }; + } +} \ No newline at end of file diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/CreateWorkspaceCommandUtils.cs b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/CreateWorkspaceCommandUtils.cs new file mode 100644 index 0000000..128b96e --- /dev/null +++ b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/CreateWorkspaceCommandUtils.cs @@ -0,0 +1,14 @@ +using PlanIt.Application.UnitTests.TestUtils.Constants; +using PlanIt.Application.Workspaces.Commands.CreateWorkspace; + +namespace PlanIt.Application.UnitTests.Workspaces.TestUtils; + +public static class CreateWorkspaceCommandUtils +{ + public static CreateWorkspaceCommand CreateCommand() => ( + new CreateWorkspaceCommand( + Constants.Workspace.Name, + Constants.Workspace.Description + ) + ); +} \ No newline at end of file diff --git a/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/UpdateWorkspaceCommandUtils.cs b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/UpdateWorkspaceCommandUtils.cs new file mode 100644 index 0000000..eff2ed1 --- /dev/null +++ b/tests/UnitTests/PlanIt.Application.UnitTests/Workspaces/TestUtils/UpdateWorkspaceCommandUtils.cs @@ -0,0 +1,33 @@ +using PlanIt.Application.UnitTests.TestUtils.Constants; +using PlanIt.Application.Workspaces.Commands.UpdateWorkspace; +using PlanIt.Domain.ProjectAggregate.ValueObjects; +using PlanIt.Application.UnitTests.TestUtils.Common; +using PlanIt.Domain.WorkspaceAggregate; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; + +namespace PlanIt.Application.UnitTests.Workspaces.TestUtils; + +public static class UpdateWorkspaceCommandUtils +{ + public static Workspace GetMockedWorkspace() => ( + Workspace.Create( + Constants.Workspace.Name, + Constants.Workspace.Description, + WorkspaceOwnerId.FromString(Constants.User.Id) + ) + ); + + public static UpdateWorkspaceCommand CreateCommand() => ( + new UpdateWorkspaceCommand( + Constants.Workspace.Id, + Constants.Workspace.Name, + Constants.Workspace.Description + ) + ); + + public static List CreateProjectId( int projectIdsCount = 1) => ( + Enumerable.Range(0, projectIdsCount) + .Select( index => ProjectId.Create(new Guid(ValueGeneratorsUtils.GenerateSequentialGuid(index)))).ToList() + ); +} \ No newline at end of file From 373610eab5711a1e59a68e83b6f48157573901f3 Mon Sep 17 00:00:00 2001 From: tomek Date: Mon, 9 Sep 2024 10:44:27 +0200 Subject: [PATCH 02/12] Add GET operation for Workspace --- .../Queries/GetWorkspace/GetWorkspaceQuery.cs | 10 ++++++ .../GetWorkspace/GetWorkspaceQueryHandler.cs | 32 +++++++++++++++++++ .../Controllers/WorkspaceController.cs | 18 +++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQuery.cs create mode 100644 src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQueryHandler.cs diff --git a/src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQuery.cs b/src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQuery.cs new file mode 100644 index 0000000..0ccb414 --- /dev/null +++ b/src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQuery.cs @@ -0,0 +1,10 @@ +using FluentResults; +using MediatR; +using PlanIt.Domain.WorkspaceAggregate; + +namespace PlanIt.Application.Workspaces.Queries.GetWorkspace; + +public record GetWorkspaceQuery +( + string WorkspaceId +) : IRequest>; \ No newline at end of file diff --git a/src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQueryHandler.cs b/src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQueryHandler.cs new file mode 100644 index 0000000..b63ac74 --- /dev/null +++ b/src/PlanIt.Application/Workspaces/Queries/GetWorkspace/GetWorkspaceQueryHandler.cs @@ -0,0 +1,32 @@ +using FluentResults; +using MediatR; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Application.Workspaces.Queries.GetWorkspace; +using PlanIt.Domain.WorkspaceAggregate; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; + +public class GetWorkspaceQueryHandler : IRequestHandler> +{ + private readonly IWorkspaceRepository _workspaceRepository; + + public GetWorkspaceQueryHandler(IWorkspaceRepository workspaceRepository) + { + _workspaceRepository = workspaceRepository; + } + + public async Task> Handle(GetWorkspaceQuery query, CancellationToken cancellationToken) + { + var workspaceId = WorkspaceId.FromString(query.WorkspaceId); + + // Get workspace + var workspace = await _workspaceRepository.GetAsync(workspaceId); + + if (workspace is null) + { + return Result.Fail(new NotFoundError($"Couldn't find a workspace with id: ${workspaceId.Value}")); + } + + // Return it + return workspace; + } +} \ No newline at end of file diff --git a/src/PlanIt.WebApi/Controllers/WorkspaceController.cs b/src/PlanIt.WebApi/Controllers/WorkspaceController.cs index f228441..78cd07f 100644 --- a/src/PlanIt.WebApi/Controllers/WorkspaceController.cs +++ b/src/PlanIt.WebApi/Controllers/WorkspaceController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PlanIt.Application.Workspaces.Commands.AssignProjectToWorkspace; +using PlanIt.Application.Workspaces.Queries.GetWorkspace; using PlanIt.Application.Workspaces.Commands.CreateWorkspace; using PlanIt.Application.Workspaces.Commands.DeleteWorkspace; using PlanIt.Application.Workspaces.Commands.UpdateWorkspace; @@ -22,6 +23,23 @@ public WorkspaceController(ISender mediator) _mediator = mediator; } + [HttpGet] + [Route("{workspaceId}")] + public async Task GetWorkspace(string workspaceId) + { + GetWorkspaceQuery query = new(workspaceId); + + Result workspaceQueryResult = await _mediator.Send(query); + + if (workspaceQueryResult.IsFailed) + { + return Problem(workspaceQueryResult.Errors); + } + + return Ok(workspaceQueryResult.Value.MapToResponse()); + } + + [HttpPost] public async Task CreateWorkspace([FromBody] CreateWorkspaceRequest createWorkspaceRequest) { From 01ef5ce62b5d10f7536222de6cf06a4c51fce434 Mon Sep 17 00:00:00 2001 From: tomek Date: Tue, 10 Sep 2024 14:48:16 +0200 Subject: [PATCH 03/12] Add UI for Workspace operations --- Requests/Project/CreateProjectRequest.http | 3 +- Requests/User/GetUserWorkspaces.http | 5 + clients/plan-it-web/package-lock.json | 66 ++- clients/plan-it-web/package.json | 3 +- clients/plan-it-web/src/App.tsx | 33 +- .../src/components/MainWindow/MainWindow.tsx | 79 ++- .../components/MainWindow/WorkspaceMenu.tsx | 56 ++ .../src/components/Navbar/Navbar.tsx | 67 ++- .../src/components/Project/Project.tsx | 12 +- .../SortableItems/Container/Container.tsx | 4 +- .../components/SortableItems/Item/Item.tsx | 9 +- .../MultipleSortableProjects.tsx | 92 +-- clients/plan-it-web/src/hooks/reduxHooks.ts | 6 + .../src/hooks/useMultipleProjectsHook.tsx | 78 ++- clients/plan-it-web/src/main.tsx | 9 +- clients/plan-it-web/src/redux/store.ts | 10 +- .../plan-it-web/src/redux/workspacesSlice.ts | 40 ++ .../plan-it-web/src/services/planit-api.ts | 48 +- clients/plan-it-web/src/types/Project.ts | 14 + .../Persistence/IProjectRepository.cs | 5 +- .../Interfaces/Persistence/IUserRepository.cs | 2 +- .../Persistence/IWorkspaceRepository.cs | 2 + .../CreateProject/CreateProjectCommand.cs | 2 +- .../CreateProjectCommandHandler.cs | 18 +- .../Users/Queries/GetUserWorkspacesQuery.cs | 10 + .../Queries/GetUserWorkspacesQueryHandler.cs | 35 ++ .../CreateWorkspaceCommandHandler.cs | 5 +- .../GetWorkspaceProjectsQuery.cs | 9 + .../GetWorkspaceProjectsQueryHandler.cs | 32 ++ .../Projects/Requests/CreateProjectRequest.cs | 1 + .../Responses/WorkspaceProjectsResponse.cs | 9 + .../Workspace/Responses/WorkspaceResponse.cs | 1 + src/PlanIt.Domain/ProjectAggregate/Project.cs | 9 +- .../ValueObjects/TaskOwnerId.cs | 5 + .../ValueObjects/ProjectOwnerId.cs | 5 + ...Workspace FK to Projects Table.Designer.cs | 530 ++++++++++++++++++ ...2823_Add Workspace FK to Projects Table.cs | 51 ++ .../PlanItDbContextModelSnapshot.cs | 11 + .../Configurations/ProjectConfiguration.cs | 13 + .../Repositories/ProjectRepository.cs | 10 +- .../Repositories/WorkspaceRepository.cs | 6 + .../Mapping/AuthenticationMappingConfig.cs | 15 - .../Common/Mapping/ProjectMapping.cs | 15 + .../Common/Mapping/UserMapping.cs | 12 + .../Common/Mapping/WorkspaceMapping.cs | 11 + .../Controllers/ProjectController.cs | 7 +- .../Controllers/UserController.cs | 33 ++ .../Controllers/WorkspaceController.cs | 22 +- src/PlanIt.WebApi/DependencyInjection.cs | 2 + 49 files changed, 1342 insertions(+), 180 deletions(-) create mode 100644 Requests/User/GetUserWorkspaces.http create mode 100644 clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx create mode 100644 clients/plan-it-web/src/hooks/reduxHooks.ts create mode 100644 clients/plan-it-web/src/redux/workspacesSlice.ts create mode 100644 src/PlanIt.Application/Users/Queries/GetUserWorkspacesQuery.cs create mode 100644 src/PlanIt.Application/Users/Queries/GetUserWorkspacesQueryHandler.cs create mode 100644 src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQuery.cs create mode 100644 src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQueryHandler.cs create mode 100644 src/PlanIt.Contracts/Workspace/Responses/WorkspaceProjectsResponse.cs create mode 100644 src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.Designer.cs create mode 100644 src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.cs delete mode 100644 src/PlanIt.WebApi/Common/Mapping/AuthenticationMappingConfig.cs create mode 100644 src/PlanIt.WebApi/Common/Mapping/UserMapping.cs create mode 100644 src/PlanIt.WebApi/Controllers/UserController.cs diff --git a/Requests/Project/CreateProjectRequest.http b/Requests/Project/CreateProjectRequest.http index d0dfef0..9988532 100644 --- a/Requests/Project/CreateProjectRequest.http +++ b/Requests/Project/CreateProjectRequest.http @@ -2,9 +2,10 @@ POST {{host}}/api/projects Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6IjQ0NTkyMjQ0LWU0NzgtNDBjNC1hMzY5LTg2NWE5ZWExM2FmOCIsImV4cCI6MTcyNTQ0ODg3NCwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.E6iCIp1c3QGt5PAdXVsk9UZkFR9vmRSCMXU4poJl1Dw +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6ImE2ZmI3YmEwLTZiNjUtNGEyMS1hYmFkLTYxOWZjMzExOTNmYyIsImV4cCI6MTcyNTg5NTA3MiwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.pd_wuO_8FZ-AL0_GKE4QaxTxwdL4LKOR1IOi6DdvPtM { + "workspaceId": "e02023fa-6b10-46b8-9315-f53a081bf46d", "name": "Project 1", "description": "Project 1 description", "projectTasks": diff --git a/Requests/User/GetUserWorkspaces.http b/Requests/User/GetUserWorkspaces.http new file mode 100644 index 0000000..a25743a --- /dev/null +++ b/Requests/User/GetUserWorkspaces.http @@ -0,0 +1,5 @@ +@host=https://localhost:5234 +@userId=e0d91303-b5c9-4530-9914-d27c7a054415 + +GET {{host}}/api/users/{{userId}}/workspaces +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6ImUyM2UzMDU1LTBjYTUtNDRhMy05ZjUzLTNjYjkzOWE3YzJhMiIsImV4cCI6MTcyNTg3NzM3NCwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.sKvv3kdMfJ5F28vp6r_6sPNNdG8KWCsX7PFi6reuXes \ No newline at end of file diff --git a/clients/plan-it-web/package-lock.json b/clients/plan-it-web/package-lock.json index b5a3d5f..dc1b95a 100644 --- a/clients/plan-it-web/package-lock.json +++ b/clients/plan-it-web/package-lock.json @@ -18,7 +18,8 @@ "eslint-plugin-react": "^7.35.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-redux": "^9.1.2" + "react-redux": "^9.1.2", + "react-router-dom": "^6.26.1" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -724,6 +725,15 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz", + "integrity": "sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz", @@ -3684,6 +3694,38 @@ } } }, + "node_modules/react-router": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz", + "integrity": "sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.19.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.1.tgz", + "integrity": "sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.19.1", + "react-router": "6.26.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -4975,6 +5017,11 @@ "reselect": "^5.1.0" } }, + "@remix-run/router": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz", + "integrity": "sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==" + }, "@rollup/rollup-android-arm-eabi": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz", @@ -6861,6 +6908,23 @@ "tslib": "^2.0.0" } }, + "react-router": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz", + "integrity": "sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==", + "requires": { + "@remix-run/router": "1.19.1" + } + }, + "react-router-dom": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.1.tgz", + "integrity": "sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==", + "requires": { + "@remix-run/router": "1.19.1", + "react-router": "6.26.1" + } + }, "react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", diff --git a/clients/plan-it-web/package.json b/clients/plan-it-web/package.json index 2e678f5..88fa9b5 100644 --- a/clients/plan-it-web/package.json +++ b/clients/plan-it-web/package.json @@ -20,7 +20,8 @@ "eslint-plugin-react": "^7.35.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-redux": "^9.1.2" + "react-redux": "^9.1.2", + "react-router-dom": "^6.26.1" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index 4adec60..ba04ecc 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -1,21 +1,38 @@ import '@mantine/core/styles.css'; -import { MantineProvider } from '@mantine/core'; import { Navbar } from './components/Navbar/Navbar'; import './App.css'; import { MainWindow } from './components/MainWindow/MainWindow'; -import { Provider } from 'react-redux'; -import { store } from './redux/store'; +import { useGetUserWorkspacesQuery } from './services/planit-api'; + +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { useAppDispatch } from './hooks/reduxHooks'; +import { useEffect } from 'react'; +import { setWorkspaces } from './redux/workspacesSlice'; export default function App() { + const dispatch = useAppDispatch(); + const { data, isLoading, error } = useGetUserWorkspacesQuery('e0d91303-b5c9-4530-9914-d27c7a054415'); + + useEffect(() => { + if (data) { + dispatch(setWorkspaces(data)); + } + },[data, dispatch]); + + if (isLoading) return
Loading...
; + if (error) return
Error occurred while fetching workspaces
; + return ( - - + <> + - - - + + } /> + + + ); }; \ No newline at end of file diff --git a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx index ecd8e4b..8a96ffe 100644 --- a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx +++ b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx @@ -1,34 +1,77 @@ -import { Flex, Title } from "@mantine/core"; +import { Flex, Group, Title } from "@mantine/core"; import { MultipleSortableProjects } from '../SortableItems/MultipleSortableProjects'; import classes from './MainWindow.module.css'; -import { useGetProjectByIdQuery } from "../../services/planit-api"; +import { useCreateProjectMutation, useGetProjectsForWorkspaceQuery } from "../../services/planit-api"; +import { useParams } from "react-router-dom"; +import { useEffect } from "react"; +import { WorkspaceMenu } from "./WorkspaceMenu"; export function MainWindow() { - const { data, error, isLoading } = useGetProjectByIdQuery('d0b41044-c9c0-40d3-9750-b1a72d4acbf4'); + const { workspaceId } = useParams<{ workspaceId: string }>(); + const { data : projects, error, isLoading, refetch } = useGetProjectsForWorkspaceQuery(workspaceId, { + skip: !workspaceId + }); + const [ createProject, {isLoading: isUpdating }] = useCreateProjectMutation(); - let projects = {}; + useEffect(() => { + if (workspaceId) { + refetch().catch(console.error); + } + }, [workspaceId, refetch]); - console.log(data); + useEffect(() => { + console.log('Refetched projects:', projects); + }, [projects]); - if (data && typeof(data) === "object") - { - let inData = [data]; - for (let i = 0; i < inData.length; i++) - { - projects[inData[i].id] = inData[i]; + if (isLoading) { + return
Loading projects...
; + } + + if (error) { + return
Error loading projects: {error.toString()}
; + } - } - console.log(projects); + const handleAddNewProject = async () => { + const newProject = await createProject({ + workspaceId: workspaceId, + name: "New Project", + description: "New Project Description", + projectTasks: [] + }); + + refetch().catch(console.error); + + return newProject.data; } + const projectsToObjects = projects?.projects.reduce((prev, cur) => ({...prev, [cur.id]: cur}), {}); + return ( - { isLoading ?
Loading...
: + {isLoading ? ( +
Loading...
+ ) : ( <> - Workspace - - - } + + + Workspace + + + + + {projects?.projects && projects.projects.length > 0 ? ( + + ) : ( + <> +
No projects available
+ + + )} + + )}
); } \ No newline at end of file diff --git a/clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx b/clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx new file mode 100644 index 0000000..badfaae --- /dev/null +++ b/clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx @@ -0,0 +1,56 @@ +import { ActionIcon, Group, Menu } from "@mantine/core"; +import { IconSettings, IconTrash } from "@tabler/icons-react"; +import { useAppDispatch } from "../../hooks/reduxHooks"; +import { useParams } from "react-router-dom"; +import { useDeleteWorkspaceMutation } from "../../services/planit-api"; +import { deleteWorkspace } from "../../redux/workspacesSlice"; + +export function WorkspaceMenu() +{ + const { workspaceId } = useParams<{ workspaceId: string }>(); + const dispatch = useAppDispatch(); + const [ deleteWorkspace ] = useDeleteWorkspaceMutation(); + + const handleDeleteWorkspace = async () => { + if (!workspaceId) return; + + console.log("workspaceId:", workspaceId); + + if (!window.confirm('Are you sure you want to delete this workspace?')) return; + + const result = await deleteWorkspace(workspaceId); + + if (result.error) + { + console.error('Error deleting workspace:', result.error); + } + + dispatch(deleteWorkspace(workspaceId)); + } + + return ( + + + + + + + + + + Workspace Settings + + + Option 2 + + } + onClick={handleDeleteWorkspace} + > + Delete Workspace + + + + + ) +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Navbar/Navbar.tsx b/clients/plan-it-web/src/components/Navbar/Navbar.tsx index 895fdcc..02c321a 100644 --- a/clients/plan-it-web/src/components/Navbar/Navbar.tsx +++ b/clients/plan-it-web/src/components/Navbar/Navbar.tsx @@ -10,10 +10,15 @@ import { Group, ActionIcon, Tooltip, + Loader, } from '@mantine/core'; - import { IconBulb, IconUser, IconCheckbox, IconSearch, IconPlus } from '@tabler/icons-react'; + import { IconBulb, IconUser, IconCheckbox, IconSearch, IconPlus} from '@tabler/icons-react'; import { UserButton } from '../UserButton/UserButton'; import classes from './Navbar.module.css'; +import { NavLink } from 'react-router-dom'; +import { useCreateWorkspaceMutation } from '../../services/planit-api'; +import { useAppDispatch, useAppSelector } from '../../hooks/reduxHooks'; +import { addWorkspace } from '../../redux/workspacesSlice'; const links: { icon: any; label: string; notifications?: number }[] = [ @@ -22,19 +27,25 @@ import { { icon: IconUser, label: 'Contacts' }, ]; - const collections = [ - { emoji: '👍', label: 'Sales' }, - { emoji: '🚚', label: 'Deliveries' }, - { emoji: '💸', label: 'Discounts' }, - { emoji: '💰', label: 'Profits' }, - { emoji: '✨', label: 'Reports' }, - { emoji: '🛒', label: 'Orders' }, - { emoji: '📅', label: 'Events' }, - { emoji: '🙈', label: 'Debts' }, - { emoji: '💁‍♀️', label: 'Customers' }, - ]; + export function Navbar() { + const dispatch = useAppDispatch(); + const workspaces = useAppSelector( state => state.workspaces.workspaces); + const [ createWorkspace ]= useCreateWorkspaceMutation(); + + const handleClickIconPlus = async () => { + const newWorkspace = await createWorkspace({ + name: "New Workspace", + description: "", + projectIds: [] + }); + + if (!newWorkspace.data) return; + + dispatch(addWorkspace(newWorkspace.data)); + } + const mainLinks = links.map((link) => (
@@ -49,17 +60,19 @@ import { )); - const collectionLinks = collections.map((collection) => ( - event.preventDefault()} - key={collection.label} - className={classes.collectionLink} - > - {collection.emoji}{' '} - {collection.label} - - )); + const workspacesLinks = workspaces + ? workspaces.map((workspace) => ( + + `${classes.workspaceLink} ${isActive ? classes.workspaceLinkActive : ''}` + } + > + {workspace.name} + + )) + : null; return (
- + Workspaces - - + + -
{collectionLinks}
+
{!workspacesLinks ? : workspacesLinks}
); diff --git a/clients/plan-it-web/src/components/Project/Project.tsx b/clients/plan-it-web/src/components/Project/Project.tsx index a7d2753..eabefe3 100644 --- a/clients/plan-it-web/src/components/Project/Project.tsx +++ b/clients/plan-it-web/src/components/Project/Project.tsx @@ -1,6 +1,7 @@ import classes from './Project.module.css'; -import { Avatar, Group, Progress, Stack, Title, Text } from "@mantine/core"; +import { Avatar, Group, Progress, Stack, Title, Text, Loader } from "@mantine/core"; import { Handle, Remove } from "../SortableItems/Item"; +import { useDeleteProjectMutation } from '../../services/planit-api'; const avatars = [ 'https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-2.png', @@ -9,19 +10,20 @@ const avatars = [ ]; interface ProjectProps { - onRemove : (() => void) | undefined; handleProps : React.HTMLAttributes | undefined; + onRemove: () => void; name: string; description: string; } -export function Project({ onRemove, handleProps, name, description } : ProjectProps ) { +export function Project({ onRemove, handleProps, name, description, id } : ProjectProps ) { + return ( - {name} + {name + id} - {onRemove ? : undefined} + diff --git a/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx b/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx index eb9b606..9f8ddb0 100644 --- a/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx +++ b/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx @@ -72,7 +72,9 @@ export const Container = forwardRef( name={content?.name} description={content?.description} onRemove={onRemove} - handleProps={handleProps}/> + handleProps={handleProps} + id={content?.id} + /> ) : null} {placeholder ? children :
    {children}
} diff --git a/clients/plan-it-web/src/components/SortableItems/Item/Item.tsx b/clients/plan-it-web/src/components/SortableItems/Item/Item.tsx index 43210dd..ad95b62 100644 --- a/clients/plan-it-web/src/components/SortableItems/Item/Item.tsx +++ b/clients/plan-it-web/src/components/SortableItems/Item/Item.tsx @@ -11,6 +11,7 @@ import {Handle, Remove} from './components'; import styles from './Item.module.css'; import { Task } from '../../Task/Task'; import { ProjectTask } from '../../../types/Project'; +import { useDeleteProjectMutation } from '../../../services/planit-api'; export interface ItemProps { dragOverlay?: boolean; @@ -58,7 +59,6 @@ export const Item = React.memo( handleProps, index, listeners, - onRemove, sorting, style, content, @@ -69,7 +69,9 @@ export const Item = React.memo( }, ref ) => { + useEffect(() => { + if (!dragOverlay) { return; } @@ -127,10 +129,7 @@ export const Item = React.memo( tabIndex={!handle ? 0 : undefined} > - - {onRemove ? ( - - ) : null} + {handle ? : null} diff --git a/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx b/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx index 33751fb..9ef7fda 100644 --- a/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx +++ b/clients/plan-it-web/src/components/SortableItems/MultipleSortableProjects.tsx @@ -1,51 +1,59 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { CancelDrop, DndContext, DragOverlay, Modifiers } from '@dnd-kit/core'; import { SortableContext, SortingStrategy } from '@dnd-kit/sortable'; import { createPortal } from 'react-dom'; -import { Flex, Group, useMantineTheme } from '@mantine/core'; +import { Box, Button, Flex, Group, useMantineTheme } from '@mantine/core'; -import { projects, useMultipleContainers } from '../../hooks/useMultipleProjectsHook'; +import { useMultipleContainers } from '../../hooks/useMultipleProjectsHook'; import { DroppableContainer } from './Container/DroppableContainer'; import { SortableItem } from './Item/SortableItem'; import { getNextContainerId } from '../../utils/containerUtils'; import classes from './MultipleSortableProjects.module.css'; import { IconCirclePlus } from '@tabler/icons-react'; +import { Project } from '../../types/Project'; +import { useDeleteProjectMutation } from '../../services/planit-api'; const PLACEHOLDER_ID = 'placeholder'; export interface MultipleSortableProjectsProps { adjustScale?: boolean; cancelDrop?: CancelDrop; + onAddNewProject: () => Promise; columns?: number; containerStyle?: React.CSSProperties; - projects?: projects; handle?: boolean; strategy?: SortingStrategy; modifiers?: Modifiers; scrollable?: boolean; + projectsIn: Record; } - export function MultipleSortableProjects({ adjustScale = false, cancelDrop, + onAddNewProject, columns, handle = false, - projects: initialprojects, + projectsIn, containerStyle, modifiers, strategy, scrollable, }: MultipleSortableProjectsProps) { const theme = useMantineTheme(); - const [projects, setprojects] = useState(initialprojects); + const [projects, setProjects] = useState(projectsIn); + const [deleteProject] = useDeleteProjectMutation(); + + console.log(projects); + + useEffect(() => { + setProjects(structuredClone(projectsIn)); + }, [JSON.stringify(projectsIn)]) const { sensors, activeId, - containers, - setContainers, onDragStart, onDragOver, onDragEnd, @@ -53,24 +61,36 @@ export function MultipleSortableProjects({ getIndex, renderSortableItemDragOverlay, renderContainerDragOverlay, - } = useMultipleContainers(projects, setprojects); - - const handleRemove = (containerID: string) => { - setContainers((containers) => containers.filter((id) => id !== containerID)); - }; + } = useMultipleContainers(projects, setProjects); + + const containers = useMemo(() => Object.keys(projects), [projects]); - const handleAddColumn = () => { + const handleAddColumn = async () => { + const newProject = await onAddNewProject(); const newContainerId = getNextContainerId(); - setContainers((containers) => [...containers, newContainerId]); - setprojects((projects) => ({ - ...projects, + setProjects((prevProjects) => ({ + ...prevProjects, [newContainerId]: { - name: "New project", - description: "", + workspaceId: newProject.workspaceId, + id: newProject.id, + name: newProject.name, + description: newProject.description, projectTasks: [] }, - })); - }; + }))}; + + const handleRemove = useCallback(async (containerID: string) => { + const result = await deleteProject(projects[containerID].id); + if (result.error) { + console.error(result.error); + return; + } + setProjects((prevProjects) => { + const newProjects = {...prevProjects}; + delete newProjects[containerID]; + return newProjects; + }); + }, [deleteProject, projects]); return ( console.log("Add task")} - style={{ width: "45px", - height: "45px", - color:theme.colors.blue[7], - cursor: "pointer" - }} - stroke={1.3} /> - + onClick={() => console.log("Add task")} + style={{ + width: "45px", + height: "45px", + color: theme.colors.blue[7], + cursor: "pointer" + }} + stroke={1.3} /> + ))} - + + + {createPortal( @@ -142,4 +164,4 @@ export function MultipleSortableProjects({ )} ); -} \ No newline at end of file +} diff --git a/clients/plan-it-web/src/hooks/reduxHooks.ts b/clients/plan-it-web/src/hooks/reduxHooks.ts new file mode 100644 index 0000000..3d2b22f --- /dev/null +++ b/clients/plan-it-web/src/hooks/reduxHooks.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux' +import type { RootState, AppDispatch } from '../redux/store' + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() \ No newline at end of file diff --git a/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx b/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx index 32958ac..f07aab1 100644 --- a/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx +++ b/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx @@ -12,12 +12,10 @@ const PLACEHOLDER_ID = 'placeholder'; export type Items = Record; export const useMultipleContainers = (items: Items, setItems: React.Dispatch>) => { - const [containers, setContainers] = useState(Object.keys(items)); const [activeId, setActiveId] = useState(null); const [clonedItems, setClonedItems] = useState(null); const lastOverId = useRef(null); const recentlyMovedToNewContainer = useRef(false); - const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor)); const findContainer = useCallback((id: string) => { @@ -33,7 +31,7 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch task.id === id); - }, [findContainer, items]); + }, [items, findContainer]); const onDragStart = useCallback(({ active }) => { setActiveId(active.id); @@ -50,15 +48,15 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch { - const activeItems = items[activeContainer].projectTasks; - const overItems = items[overContainer].projectTasks; - const overIndex = overId in items ? overItems.length + 1 : overItems.findIndex((task) => task.id === overId); + setItems((prevItems) => { + const activeItems = prevItems[activeContainer].projectTasks; + const overItems = prevItems[overContainer].projectTasks; + const overIndex = overId in prevItems ? overItems.length + 1 : overItems.findIndex((task) => task.id === overId); const activeIndex = activeItems.findIndex((task) => task.id === active.id); let newIndex: number; - if (overId in items) { + if (overId in prevItems) { newIndex = overItems.length + 1; } else { const isBelowOverItem = over && active.rect.current.translated && @@ -70,30 +68,32 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch item.id !== active.id) + ...prevItems[activeContainer], + projectTasks: prevItems[activeContainer].projectTasks.filter((item) => item.id !== active.id) }, [overContainer]: { - ...items[overContainer], + ...prevItems[overContainer], projectTasks: [ - ...items[overContainer].projectTasks.slice(0, newIndex), - items[activeContainer].projectTasks.find((item) => item.id === active.id)!, - ...items[overContainer].projectTasks.slice(newIndex) + ...prevItems[overContainer].projectTasks.slice(0, newIndex), + prevItems[activeContainer].projectTasks.find((item) => item.id === active.id)!, + ...prevItems[overContainer].projectTasks.slice(newIndex) ], } }; }); } - }, [findContainer, items]); + }, [items, findContainer]); const onDragEnd = useCallback(({ active, over }) => { if (active.id in items && over?.id) { - setContainers((containers) => { - const activeIndex = containers.indexOf(active.id); - const overIndex = containers.indexOf(over.id); - return arrayMove(containers, activeIndex, overIndex); + setItems((prevItems) => { + const activeIndex = Object.keys(prevItems).indexOf(active.id); + const overIndex = Object.keys(prevItems).indexOf(over.id); + return Object.fromEntries( + arrayMove(Object.entries(prevItems), activeIndex, overIndex) + ); }); } @@ -113,17 +113,16 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch [...containers, newContainerId]); - setItems((items) => ({ - ...items, + setItems((prevItems) => ({ + ...prevItems, [activeContainer]: { - ...items[activeContainer], - projectTasks: items[activeContainer].projectTasks.filter((task) => task.id !== active.id) + ...prevItems[activeContainer], + projectTasks: prevItems[activeContainer].projectTasks.filter((task) => task.id !== active.id) }, [newContainerId]: { name: "New project", description: "", - projectTasks: [items[activeContainer].projectTasks.find((task) => task.id === active.id)!] + projectTasks: [prevItems[activeContainer].projectTasks.find((task) => task.id === active.id)!] }, })); setActiveId(null); @@ -137,18 +136,18 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch ({ - ...items, + setItems((prevItems) => ({ + ...prevItems, [overContainer]: { - ...items[overContainer], - projectTasks: arrayMove(items[overContainer].projectTasks, activeIndex, overIndex) + ...prevItems[overContainer], + projectTasks: arrayMove(prevItems[overContainer].projectTasks, activeIndex, overIndex) }, })); } } setActiveId(null); - }, [findContainer, getIndex, items]); + }, [items, findContainer, getIndex]); const onDragCancel = useCallback(() => { if (clonedItems) { @@ -158,9 +157,9 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch { const containerId = findContainer(id) as UniqueIdentifier; - const projectTask = items[containerId].projectTasks.find( (task) => task.id === id); + const projectTask = items[containerId].projectTasks.find((task) => task.id === id); return ( ); - } + }, [items, findContainer]); - function renderContainerDragOverlay(containerId: UniqueIdentifier) { + const renderContainerDragOverlay = useCallback((containerId: UniqueIdentifier) => { return ( - {items[containerId].projectTasks.map((item, index) => ( + {items[containerId].projectTasks.map((item) => ( ); - } + }, [items]); return { sensors, activeId, - containers, - setContainers, onDragStart, onDragOver, onDragEnd, @@ -211,5 +208,4 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch - + + + + + , ) diff --git a/clients/plan-it-web/src/redux/store.ts b/clients/plan-it-web/src/redux/store.ts index f71caf0..c493fd5 100644 --- a/clients/plan-it-web/src/redux/store.ts +++ b/clients/plan-it-web/src/redux/store.ts @@ -3,10 +3,13 @@ import { configureStore } from '@reduxjs/toolkit' import { setupListeners } from '@reduxjs/toolkit/query' import { projectApi } from '../services/planit-api' +import workspacesReducer from './workspacesSlice' + export const store = configureStore({ reducer: { // Add the generated reducer as a specific top-level slice [projectApi.reducerPath]: projectApi.reducer, + workspaces: workspacesReducer, }, // Adding the api middleware enables caching, invalidation, polling, // and other useful features of `rtk-query`. @@ -16,4 +19,9 @@ export const store = configureStore({ // optional, but required for refetchOnFocus/refetchOnReconnect behaviors // see `setupListeners` docs - takes an optional callback as the 2nd arg for customization -setupListeners(store.dispatch) \ No newline at end of file +setupListeners(store.dispatch) + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType +// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} +export type AppDispatch = typeof store.dispatch \ No newline at end of file diff --git a/clients/plan-it-web/src/redux/workspacesSlice.ts b/clients/plan-it-web/src/redux/workspacesSlice.ts new file mode 100644 index 0000000..7658d6c --- /dev/null +++ b/clients/plan-it-web/src/redux/workspacesSlice.ts @@ -0,0 +1,40 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { Workspace } from '../types/Project' + +interface WorkspacesState { + workspaces: Workspace[] +} + +const initialState: WorkspacesState = { + workspaces: [] +} + +const workspacesSlice = createSlice({ + name: 'workspaces', + initialState, + reducers: { + addWorkspace: (state, action: PayloadAction) => { + state.workspaces.push(action.payload) + }, + updateWorkspace: (state, action: PayloadAction) => { + const index = state.workspaces.findIndex(workspace => workspace.id === action.payload.id) + if (index !== -1) { + state.workspaces[index] = action.payload + } + }, + deleteWorkspace: (state, action: PayloadAction) => { + state.workspaces = state.workspaces.filter(workspace => workspace.id !== action.payload) + }, + setWorkspaces: (state, action: PayloadAction) => { + state.workspaces = action.payload + } + } +}) + +export const { + addWorkspace, + updateWorkspace, + deleteWorkspace, + setWorkspaces + } = workspacesSlice.actions +export default workspacesSlice.reducer diff --git a/clients/plan-it-web/src/services/planit-api.ts b/clients/plan-it-web/src/services/planit-api.ts index c1d3452..872d889 100644 --- a/clients/plan-it-web/src/services/planit-api.ts +++ b/clients/plan-it-web/src/services/planit-api.ts @@ -1,8 +1,10 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' -import type { Project } from '../types/Project' +import type { Project, Workspace, WorkspaceProjects } from '../types/Project' +import { deleteWorkspace } from '../redux/workspacesSlice'; const HOST = "https://localhost:5234"; +const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6IjY5Yzg5OGVmLWQ2ODctNDdhOS1hNzEzLTgwZmZmODY5MmI1MyIsImV4cCI6MTcyNTk3MTgyNiwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.zBy2yXBVdu9-3RQeFTjFJNCdb0M_6vTAF_N6LNSINEs"; // Define a service using a base URL and expected endpoints export const projectApi = createApi({ @@ -10,14 +12,54 @@ export const projectApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: `${HOST}/api`, mode: 'cors', + headers: { Authorization: `Bearer ${TOKEN}`} }), endpoints: (builder) => ({ getProjectById: builder.query({ query: (id) => `projects/${id}`, }), + getUserWorkspaces: builder.query({ + query: (userId) => `users/${userId}/workspaces/`, }), -}) + getProjectsForWorkspace: builder.query({ + query: (workspaceId) => `workspaces/${workspaceId}/projects`, + }), + createProject: builder.mutation>({ + query: (newProject) => ({ + url: `projects/`, + method: 'POST', + body: newProject, + }), + }), + deleteProject: builder.mutation({ + query: (projectId) => ({ + url: `projects/${projectId}`, + method: 'DELETE', + }), + }), + createWorkspace: builder.mutation>({ + query: (newWorkspace) => ({ + url: `workspaces/`, + method: 'POST', + body: newWorkspace, + }), + }), + deleteWorkspace: builder.mutation({ + query: (workspaceId) => ({ + url: `workspaces/${workspaceId}`, + method: 'DELETE', + }), + }) +})}); // Export hooks for usage in functional components, which are // auto-generated based on the defined endpoints -export const { useGetProjectByIdQuery } = projectApi; \ No newline at end of file +export const { + useGetProjectByIdQuery, + useGetUserWorkspacesQuery, + useGetProjectsForWorkspaceQuery, + useCreateProjectMutation, + useDeleteProjectMutation, + useCreateWorkspaceMutation, + useDeleteWorkspaceMutation + } = projectApi; \ No newline at end of file diff --git a/clients/plan-it-web/src/types/Project.ts b/clients/plan-it-web/src/types/Project.ts index 83ebbaa..35bdc31 100644 --- a/clients/plan-it-web/src/types/Project.ts +++ b/clients/plan-it-web/src/types/Project.ts @@ -1,11 +1,25 @@ export interface Project { + workspaceId: string; id: string; name: string; description: string; + projectTasks: ProjectTask[]; } export interface ProjectTask { id: string, name: string, description: string +} + +export interface Workspace { + id: string, + name: string, + description: string, + projectIds: string[] +} + +export interface WorkspaceProjects { + id: string, + projects: Project[] } \ No newline at end of file diff --git a/src/PlanIt.Application/Common/Interfaces/Persistence/IProjectRepository.cs b/src/PlanIt.Application/Common/Interfaces/Persistence/IProjectRepository.cs index 4651674..f67cacd 100644 --- a/src/PlanIt.Application/Common/Interfaces/Persistence/IProjectRepository.cs +++ b/src/PlanIt.Application/Common/Interfaces/Persistence/IProjectRepository.cs @@ -1,14 +1,17 @@ using PlanIt.Domain.ProjectAggregate; using PlanIt.Domain.ProjectAggregate.ValueObjects; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; namespace PlanIt.Application.Common.Interfaces.Persistence; public interface IProjectRepository { - void Add(Project project); + Task AddAsync(Project project); public Task GetAsync(ProjectId projectId); + public Task> GetProjectsForWorkspaceAsync(WorkspaceId workspaceId); + public Task UpdateAsync(); public Task DeleteAsync(Project project); diff --git a/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs b/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs index 05992e0..e0d12c3 100644 --- a/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs +++ b/src/PlanIt.Application/Common/Interfaces/Persistence/IUserRepository.cs @@ -5,8 +5,8 @@ namespace PlanIt.Application.Common.Interfaces.Persistence { public interface IUserRepository { - public Task GetUserByEmail(string email); public Task> AddAsync(User user, string email, string password); + public Task GetUserByEmail(string email); public Task SaveChangesAsync(); } } \ No newline at end of file diff --git a/src/PlanIt.Application/Common/Interfaces/Persistence/IWorkspaceRepository.cs b/src/PlanIt.Application/Common/Interfaces/Persistence/IWorkspaceRepository.cs index 3987f79..b3f06e2 100644 --- a/src/PlanIt.Application/Common/Interfaces/Persistence/IWorkspaceRepository.cs +++ b/src/PlanIt.Application/Common/Interfaces/Persistence/IWorkspaceRepository.cs @@ -1,3 +1,4 @@ +using PlanIt.Domain.UserAggregate.ValueObjects; using PlanIt.Domain.WorkspaceAggregate; using PlanIt.Domain.WorkspaceAggregate.ValueObjects; @@ -8,5 +9,6 @@ public interface IWorkspaceRepository public Task AddAsync(Workspace workspace); public Task DeleteAsync(Workspace workspace); public Task GetAsync(WorkspaceId workspaceId); + public Task> GetUserWorkspacesAsync(WorkspaceOwnerId userId); public Task SaveChangesAsync(); } \ No newline at end of file diff --git a/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommand.cs b/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommand.cs index 7fdd77a..2ae2917 100644 --- a/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommand.cs +++ b/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommand.cs @@ -5,7 +5,7 @@ namespace PlanIt.Application.Projects.Commands.CreateProject; public record CreateProjectCommand( - string ProjectOwnerId, + string WorkspaceId, string Name, string Description, List ProjectTasks diff --git a/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommandHandler.cs b/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommandHandler.cs index b1f753c..f2585fd 100644 --- a/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommandHandler.cs +++ b/src/PlanIt.Application/Projects/Commands/CreateProject/CreateProjectCommandHandler.cs @@ -5,36 +5,44 @@ using PlanIt.Domain.ProjectAggregate; using PlanIt.Domain.ProjectAggregate.Entities; using PlanIt.Domain.ProjectAggregate.ValueObjects; +using PlanIt.Domain.UserAggregate.ValueObjects; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; namespace PlanIt.Application.Projects.Commands.CreateProject; public class CreateProjectCommandHandler : IRequestHandler> { public readonly IProjectRepository _projectRepository; + public readonly IUserContext _userContext; - public CreateProjectCommandHandler(IProjectRepository projectRepository) + public CreateProjectCommandHandler(IProjectRepository projectRepository, IUserContext userContext) { _projectRepository = projectRepository; + _userContext = userContext; } public async Task> Handle(CreateProjectCommand request, CancellationToken cancellationToken) { - await Task.CompletedTask; + var loggedInUserId = _userContext.TryGetUserId(); + + var projectOwnerId = ProjectOwnerId.FromString(loggedInUserId); + var taskOwnerId = TaskOwnerId.FromString(loggedInUserId); // Create Project var project = Project.Create( name: request.Name, description: request.Description, - projectOwnerId: ProjectOwnerId.Create(new Guid(request.ProjectOwnerId)), + workspaceId: WorkspaceId.FromString(request.WorkspaceId), + projectOwnerId: projectOwnerId, projectTasks: request.ProjectTasks.ConvertAll(projectTask => ProjectTask.Create( - taskOwnerId: TaskOwnerId.Create(new Guid(request.ProjectOwnerId)), + taskOwnerId: taskOwnerId, name: projectTask.Name, description: projectTask.Description )) ); // Persist Project - _projectRepository.Add(project); + await _projectRepository.AddAsync(project); return project; } diff --git a/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQuery.cs b/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQuery.cs new file mode 100644 index 0000000..4c4ce7a --- /dev/null +++ b/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQuery.cs @@ -0,0 +1,10 @@ +using FluentResults; +using MediatR; +using PlanIt.Domain.WorkspaceAggregate; + +namespace PlanIt.Application.Users.Queries; + +public record GetUserWorkspacesQuery +( + string UserId +) : IRequest>>; \ No newline at end of file diff --git a/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQueryHandler.cs b/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQueryHandler.cs new file mode 100644 index 0000000..97740a8 --- /dev/null +++ b/src/PlanIt.Application/Users/Queries/GetUserWorkspacesQueryHandler.cs @@ -0,0 +1,35 @@ +using FluentResults; +using MediatR; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Domain.UserAggregate.ValueObjects; +using PlanIt.Domain.WorkspaceAggregate; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; + +namespace PlanIt.Application.Users.Queries; +public class GetUserWorkspacesQueryHandler : IRequestHandler>> +{ + private readonly IWorkspaceRepository _workspaceRepository; + private readonly IUserRepository _userRepository; + + public GetUserWorkspacesQueryHandler(IWorkspaceRepository workspaceRepository, IUserRepository userRepository) + { + _workspaceRepository = workspaceRepository; + _userRepository = userRepository; + } + + public async Task>> Handle(GetUserWorkspacesQuery query, CancellationToken cancellationToken) + { + var userId = WorkspaceOwnerId.FromString(query.UserId); + + // Get workspaces + var workspaces = await _workspaceRepository.GetUserWorkspacesAsync(userId); + + if (workspaces is null) + { + return Result.Fail>(new NotFoundError($"Couldn't find workspaces for User with id: ${userId.Value}")); + } + + // Return it + return workspaces; + } +} \ No newline at end of file diff --git a/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs b/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs index 3636490..734c619 100644 --- a/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs +++ b/src/PlanIt.Application/Workspaces/Commands/CreateWorkspace/CreateWorkspaceCommandHandler.cs @@ -21,7 +21,10 @@ public async Task> Handle(CreateWorkspaceCommand command, Canc { var userId = _userContext.TryGetUserId(); - var workspace = Workspace.Create(command.Name, command.Description, WorkspaceOwnerId.Create(new Guid(userId))); + var workspace = Workspace.Create( + name: command.Name, + description: command.Description, + workspaceOwnerId: WorkspaceOwnerId.Create(new Guid(userId))); await _workspaceRepository.AddAsync(workspace); diff --git a/src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQuery.cs b/src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQuery.cs new file mode 100644 index 0000000..9b3c263 --- /dev/null +++ b/src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQuery.cs @@ -0,0 +1,9 @@ +using FluentResults; +using MediatR; +using PlanIt.Domain.ProjectAggregate; + +namespace PlanIt.Application.Workspaces.Queries.GetWorkspaceProjects; + +public record GetWorkspaceProjectsQuery( + string WorkspaceId +) : IRequest>>; \ No newline at end of file diff --git a/src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQueryHandler.cs b/src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQueryHandler.cs new file mode 100644 index 0000000..228132f --- /dev/null +++ b/src/PlanIt.Application/Workspaces/Queries/GetWorkspaceProjects/GetWorkspaceProjectsQueryHandler.cs @@ -0,0 +1,32 @@ +using FluentResults; +using MediatR; +using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Domain.ProjectAggregate; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; + +namespace PlanIt.Application.Workspaces.Queries.GetWorkspaceProjects; + +public class GetWorkspaceProjectsQueryHandler : IRequestHandler>> +{ + private readonly IProjectRepository _projectRepository; + + public GetWorkspaceProjectsQueryHandler(IProjectRepository projectRepository) + { + _projectRepository = projectRepository; + } + + public async Task>> Handle(GetWorkspaceProjectsQuery query, CancellationToken cancellationToken) + { + var workspaceId = WorkspaceId.FromString(query.WorkspaceId); + + // Get project associated with the workspace + var projects = await _projectRepository.GetProjectsForWorkspaceAsync(workspaceId); + + if (projects is null) + { + return Result.Fail>(new NotFoundError($"Couldn't find project for a workspace with id: ${workspaceId.Value}")); + } + + return projects; + } +} \ No newline at end of file diff --git a/src/PlanIt.Contracts/Projects/Requests/CreateProjectRequest.cs b/src/PlanIt.Contracts/Projects/Requests/CreateProjectRequest.cs index e039d61..067c803 100644 --- a/src/PlanIt.Contracts/Projects/Requests/CreateProjectRequest.cs +++ b/src/PlanIt.Contracts/Projects/Requests/CreateProjectRequest.cs @@ -1,6 +1,7 @@ namespace PlanIt.Contracts.Projects.Requests; public record CreateProjectRequest( + string WorkspaceId, string Name, string Description, List ProjectTasks diff --git a/src/PlanIt.Contracts/Workspace/Responses/WorkspaceProjectsResponse.cs b/src/PlanIt.Contracts/Workspace/Responses/WorkspaceProjectsResponse.cs new file mode 100644 index 0000000..cd61c26 --- /dev/null +++ b/src/PlanIt.Contracts/Workspace/Responses/WorkspaceProjectsResponse.cs @@ -0,0 +1,9 @@ +using PlanIt.Contracts.Projects.Responses; + +namespace PlanIt.Contracts.Workspace.Responses; + +public record WorkspaceProjectsResponse +( + string WorkspaceId, + List Projects +); \ No newline at end of file diff --git a/src/PlanIt.Contracts/Workspace/Responses/WorkspaceResponse.cs b/src/PlanIt.Contracts/Workspace/Responses/WorkspaceResponse.cs index 37d5659..fbd1ef1 100644 --- a/src/PlanIt.Contracts/Workspace/Responses/WorkspaceResponse.cs +++ b/src/PlanIt.Contracts/Workspace/Responses/WorkspaceResponse.cs @@ -1,6 +1,7 @@ namespace PlanIt.Contracts.Workspace.Responses; public record WorkspaceResponse( + string Id, string Name, string Description ); \ No newline at end of file diff --git a/src/PlanIt.Domain/ProjectAggregate/Project.cs b/src/PlanIt.Domain/ProjectAggregate/Project.cs index 73270d8..fe6d965 100644 --- a/src/PlanIt.Domain/ProjectAggregate/Project.cs +++ b/src/PlanIt.Domain/ProjectAggregate/Project.cs @@ -3,6 +3,7 @@ using PlanIt.Domain.ProjectAggregate.Entities; using PlanIt.Domain.ProjectAggregate.Events; using PlanIt.Domain.ProjectAggregate.ValueObjects; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; namespace PlanIt.Domain.ProjectAggregate; @@ -10,6 +11,7 @@ public sealed class Project : AggregateRoot { private Project( ProjectId id, + WorkspaceId workspaceId, string name, string description, ProjectOwnerId projectOwnerId, @@ -20,6 +22,7 @@ private Project( Name = name; Description = description; _projectTasks = projectTasks; + WorkspaceId = workspaceId; ProjectOwnerId = projectOwnerId; CreatedDateTime = createdDateTime; UpdatedDateTime = updatedDateTime; @@ -35,6 +38,7 @@ private Project() private readonly List _projectTasks; public string Name { get; private set; } public string Description { get; private set; } + public WorkspaceId WorkspaceId {get; private set; } public ProjectOwnerId ProjectOwnerId { get; private set; } public DateTime CreatedDateTime { get; private set; } public DateTime UpdatedDateTime { get; private set; } @@ -72,13 +76,16 @@ public void ChangeDescription(string newDescription) return task; } - public static Project Create(string name, + public static Project Create( + string name, string description, + WorkspaceId workspaceId, ProjectOwnerId projectOwnerId, List projectTasks) { var project = new Project( ProjectId.CreateUnique(), + workspaceId, name, description, projectOwnerId, diff --git a/src/PlanIt.Domain/ProjectAggregate/ValueObjects/TaskOwnerId.cs b/src/PlanIt.Domain/ProjectAggregate/ValueObjects/TaskOwnerId.cs index ea70cdc..b96805e 100644 --- a/src/PlanIt.Domain/ProjectAggregate/ValueObjects/TaskOwnerId.cs +++ b/src/PlanIt.Domain/ProjectAggregate/ValueObjects/TaskOwnerId.cs @@ -21,6 +21,11 @@ public static TaskOwnerId Create(Guid id) return new TaskOwnerId(id); } + public static TaskOwnerId FromString(string id) + { + return new TaskOwnerId(new Guid(id)); + } + public override IEnumerable GetEqualityComponents() { yield return Value; diff --git a/src/PlanIt.Domain/ProjectOwner/ValueObjects/ProjectOwnerId.cs b/src/PlanIt.Domain/ProjectOwner/ValueObjects/ProjectOwnerId.cs index cd866e1..2e2f7bc 100644 --- a/src/PlanIt.Domain/ProjectOwner/ValueObjects/ProjectOwnerId.cs +++ b/src/PlanIt.Domain/ProjectOwner/ValueObjects/ProjectOwnerId.cs @@ -21,6 +21,11 @@ public static ProjectOwnerId Create(Guid projectOwnerId) return new ProjectOwnerId(projectOwnerId); } + public static ProjectOwnerId FromString(string id) + { + return new ProjectOwnerId(new Guid(id)); + } + public override IEnumerable GetEqualityComponents() { yield return Value; diff --git a/src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.Designer.cs b/src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.Designer.cs new file mode 100644 index 0000000..6891773 --- /dev/null +++ b/src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.Designer.cs @@ -0,0 +1,530 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PlanIt.Infrastructure.Persistence; + +#nullable disable + +namespace PlanIt.Infrastructure.Migrations +{ + [DbContext(typeof(PlanItDbContext))] + [Migration("20240909122823_Add Workspace FK to Projects Table")] + partial class AddWorkspaceFKtoProjectsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PlanIt.Domain.ProjectAggregate.Project", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedDateTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProjectOwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedDateTime") + .HasColumnType("datetime2"); + + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("PlanIt.Domain.UserAggregate.User", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("FirstName"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("LastName"); + + b.HasKey("Id"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("PlanIt.Domain.WorkspaceAggregate.Workspace", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedDateTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedDateTime") + .HasColumnType("datetime2"); + + b.Property("WorkspaceOwnerId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("PlanIt.Infrastructure.Authentication.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PlanIt.Domain.ProjectAggregate.Project", b => + { + b.HasOne("PlanIt.Domain.WorkspaceAggregate.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("PlanIt.Domain.ProjectAggregate.Entities.ProjectTask", "ProjectTasks", b1 => + { + b1.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("ProjectTaskId"); + + b1.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("TaskOwnerId") + .HasColumnType("uniqueidentifier"); + + b1.HasKey("Id", "ProjectId"); + + b1.HasIndex("ProjectId"); + + b1.ToTable("ProjectTasks", (string)null); + + b1.WithOwner() + .HasForeignKey("ProjectId"); + + b1.OwnsMany("PlanIt.Domain.ProjectAggregate.Entities.TaskComment", "TaskComments", b2 => + { + b2.Property("ProjectTaskId") + .HasColumnType("uniqueidentifier"); + + b2.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b2.Property("Id") + .HasColumnType("uniqueidentifier") + .HasColumnName("TaskCommentId"); + + b2.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b2.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b2.HasKey("ProjectTaskId", "ProjectId", "Id"); + + b2.ToTable("TaskComment", (string)null); + + b2.WithOwner() + .HasForeignKey("ProjectTaskId", "ProjectId"); + }); + + b1.OwnsMany("PlanIt.Domain.TaskWorker.ValueObjects.TaskWorkerId", "TaskWorkerIds", b2 => + { + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b2.Property("Id")); + + b2.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b2.Property("ProjectTaskId") + .HasColumnType("uniqueidentifier"); + + b2.Property("Value") + .HasColumnType("uniqueidentifier") + .HasColumnName("TaskWorkerId"); + + b2.HasKey("Id"); + + b2.HasIndex("ProjectTaskId", "ProjectId"); + + b2.ToTable("TaskWorker", (string)null); + + b2.WithOwner() + .HasForeignKey("ProjectTaskId", "ProjectId"); + }); + + b1.Navigation("TaskComments"); + + b1.Navigation("TaskWorkerIds"); + }); + + b.Navigation("ProjectTasks"); + }); + + modelBuilder.Entity("PlanIt.Domain.UserAggregate.User", b => + { + b.HasOne("PlanIt.Infrastructure.Authentication.ApplicationUser", null) + .WithMany() + .HasForeignKey("Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PlanIt.Domain.WorkspaceAggregate.Workspace", b => + { + b.OwnsMany("PlanIt.Domain.ProjectAggregate.ValueObjects.ProjectId", "ProjectIds", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Value") + .HasColumnType("uniqueidentifier") + .HasColumnName("ProjectId"); + + b1.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b1.HasKey("Id"); + + b1.HasIndex("WorkspaceId"); + + b1.ToTable("WorkspaceProject", (string)null); + + b1.WithOwner() + .HasForeignKey("WorkspaceId"); + }); + + b.OwnsMany("PlanIt.Domain.UserAggregate.ValueObjects.UserId", "UserIds", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("Value") + .HasColumnType("uniqueidentifier") + .HasColumnName("UserId"); + + b1.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + + b1.HasKey("Id"); + + b1.HasIndex("WorkspaceId"); + + b1.ToTable("WorkspaceUser", (string)null); + + b1.WithOwner() + .HasForeignKey("WorkspaceId"); + }); + + b.Navigation("ProjectIds"); + + b.Navigation("UserIds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.cs b/src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.cs new file mode 100644 index 0000000..949d28a --- /dev/null +++ b/src/PlanIt.Infrastructure/Migrations/20240909122823_Add Workspace FK to Projects Table.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PlanIt.Infrastructure.Migrations +{ + /// + public partial class AddWorkspaceFKtoProjectsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "WorkspaceId", + table: "Projects", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "IX_Projects_WorkspaceId", + table: "Projects", + column: "WorkspaceId"); + + migrationBuilder.AddForeignKey( + name: "FK_Projects_Workspaces_WorkspaceId", + table: "Projects", + column: "WorkspaceId", + principalTable: "Workspaces", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Projects_Workspaces_WorkspaceId", + table: "Projects"); + + migrationBuilder.DropIndex( + name: "IX_Projects_WorkspaceId", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "WorkspaceId", + table: "Projects"); + } + } +} diff --git a/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs b/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs index 02350e0..127bd44 100644 --- a/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs +++ b/src/PlanIt.Infrastructure/Migrations/PlanItDbContextModelSnapshot.cs @@ -177,8 +177,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedDateTime") .HasColumnType("datetime2"); + b.Property("WorkspaceId") + .HasColumnType("uniqueidentifier"); + b.HasKey("Id"); + b.HasIndex("WorkspaceId"); + b.ToTable("Projects", (string)null); }); @@ -350,6 +355,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("PlanIt.Domain.ProjectAggregate.Project", b => { + b.HasOne("PlanIt.Domain.WorkspaceAggregate.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.OwnsMany("PlanIt.Domain.ProjectAggregate.Entities.ProjectTask", "ProjectTasks", b1 => { b1.Property("Id") diff --git a/src/PlanIt.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs b/src/PlanIt.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs index 6c2545f..228b2e2 100644 --- a/src/PlanIt.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs +++ b/src/PlanIt.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs @@ -7,6 +7,8 @@ using PlanIt.Domain.ProjectAggregate.Entities; using PlanIt.Domain.ProjectAggregate.ValueObjects; using PlanIt.Domain.TaskComment.ValueObjects; +using PlanIt.Domain.WorkspaceAggregate; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; namespace PlanIt.Infrastructure.Persistence.Configurations; @@ -104,6 +106,17 @@ private void ConfigureProjectsTable(EntityTypeBuilder builder) .HasConversion( id => id.Value, // In conversion value => ProjectId.Create(value)); // Out conversion + + builder.Property( m => m.WorkspaceId) + .ValueGeneratedNever() + .HasConversion( + id => id.Value, + value => WorkspaceId.Create(value) + ); + + builder.HasOne() + .WithMany() + .HasForeignKey( p => p.WorkspaceId ); builder.Property(m => m.Name) .HasMaxLength(100); diff --git a/src/PlanIt.Infrastructure/Persistence/Repositories/ProjectRepository.cs b/src/PlanIt.Infrastructure/Persistence/Repositories/ProjectRepository.cs index 4e341c7..d0e0958 100644 --- a/src/PlanIt.Infrastructure/Persistence/Repositories/ProjectRepository.cs +++ b/src/PlanIt.Infrastructure/Persistence/Repositories/ProjectRepository.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.EntityFrameworkCore; using PlanIt.Domain.ProjectAggregate.ValueObjects; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; namespace PlanIt.Infrastructure.Persistence.Repositories; @@ -15,10 +16,10 @@ public ProjectRepository(PlanItDbContext dbContext) _dbContext = dbContext; } - public void Add(Project project) + public async Task AddAsync(Project project) { _dbContext.Add(project); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); } public async Task GetAsync(ProjectId projectId) @@ -26,6 +27,11 @@ public void Add(Project project) return await _dbContext.Projects.FirstOrDefaultAsync( p => p.Id == projectId ); } + public async Task> GetProjectsForWorkspaceAsync(WorkspaceId workspaceId) + { + return await _dbContext.Projects.Where( p => p.WorkspaceId == workspaceId).ToListAsync(); + } + public async Task UpdateAsync() { await _dbContext.SaveChangesAsync(); diff --git a/src/PlanIt.Infrastructure/Persistence/Repositories/WorkspaceRepository.cs b/src/PlanIt.Infrastructure/Persistence/Repositories/WorkspaceRepository.cs index a45d1e6..2097644 100644 --- a/src/PlanIt.Infrastructure/Persistence/Repositories/WorkspaceRepository.cs +++ b/src/PlanIt.Infrastructure/Persistence/Repositories/WorkspaceRepository.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; using PlanIt.Application.Common.Interfaces.Persistence; +using PlanIt.Domain.UserAggregate.ValueObjects; using PlanIt.Domain.WorkspaceAggregate; using PlanIt.Domain.WorkspaceAggregate.ValueObjects; @@ -34,6 +35,11 @@ public async Task DeleteAsync(Workspace workspace) return await _dbContext.Workspaces.FirstOrDefaultAsync( w => w.Id == workspaceId); } + public async Task> GetUserWorkspacesAsync(WorkspaceOwnerId userId) + { + return await _dbContext.Workspaces.Where( w => w.WorkspaceOwnerId == userId ).ToListAsync(); + } + public async Task SaveChangesAsync() { await _dbContext.SaveChangesAsync(); diff --git a/src/PlanIt.WebApi/Common/Mapping/AuthenticationMappingConfig.cs b/src/PlanIt.WebApi/Common/Mapping/AuthenticationMappingConfig.cs deleted file mode 100644 index a40e875..0000000 --- a/src/PlanIt.WebApi/Common/Mapping/AuthenticationMappingConfig.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Mapster; -using PlanIt.Application.Projects.Commands.CreateProject; -using PlanIt.Contracts.Projects.Requests; - -namespace PlanIt.WebApi.Common.Mapping; - -public class AuthenthicationMappingConfig : IRegister -{ - public void Register(TypeAdapterConfig config) - { - config.NewConfig<(CreateProjectRequest Request, string ProjectOwnerId), CreateProjectCommand>() - .Map( dest => dest.ProjectOwnerId, src => src.ProjectOwnerId) - .Map( dest => dest, src => src.Request ); - } -} \ No newline at end of file diff --git a/src/PlanIt.WebApi/Common/Mapping/ProjectMapping.cs b/src/PlanIt.WebApi/Common/Mapping/ProjectMapping.cs index 8bea27f..df53413 100644 --- a/src/PlanIt.WebApi/Common/Mapping/ProjectMapping.cs +++ b/src/PlanIt.WebApi/Common/Mapping/ProjectMapping.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; +using PlanIt.Application.Projects.Commands.CreateProject; using PlanIt.Contracts.Projects.Requests; using PlanIt.Contracts.Projects.Responses; using PlanIt.Contracts.Tasks.Responses; @@ -26,6 +28,19 @@ public static ProjectResponse MapToResponse(this Project project) => ( ) ); + public static CreateProjectCommand MapToCommand(this CreateProjectRequest request) => + ( + new CreateProjectCommand( + WorkspaceId: request.WorkspaceId, + Name: request.Name, + Description: request.Description, + ProjectTasks: request.ProjectTasks.Select( pt => new CreateProjectTaskCommand( + Name: pt.Name, + Description: pt.Description + )).ToList() + ) + ); + public static UpdateProjectCommand MapToCommand(this UpdateProjectRequest request, string projectId, string userId) => new UpdateProjectCommand( ProjectId: projectId, diff --git a/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs new file mode 100644 index 0000000..fb8ba39 --- /dev/null +++ b/src/PlanIt.WebApi/Common/Mapping/UserMapping.cs @@ -0,0 +1,12 @@ +using PlanIt.Contracts.Workspace.Responses; +using PlanIt.Domain.WorkspaceAggregate; + +namespace PlanIt.WebApi.Common.Mapping; + +public static class UserMapping +{ + public static List MapToResponse(this List workspaces) + { + return workspaces.Select(w => w.MapToResponse()).ToList(); + } +} \ No newline at end of file diff --git a/src/PlanIt.WebApi/Common/Mapping/WorkspaceMapping.cs b/src/PlanIt.WebApi/Common/Mapping/WorkspaceMapping.cs index df1aa6a..5250852 100644 --- a/src/PlanIt.WebApi/Common/Mapping/WorkspaceMapping.cs +++ b/src/PlanIt.WebApi/Common/Mapping/WorkspaceMapping.cs @@ -3,7 +3,10 @@ using PlanIt.Application.Workspaces.Commands.UpdateWorkspace; using PlanIt.Contracts.Workspace.Requests; using PlanIt.Contracts.Workspace.Responses; +using PlanIt.Domain.ProjectAggregate; using PlanIt.Domain.WorkspaceAggregate; +using PlanIt.Domain.WorkspaceAggregate.ValueObjects; +using PlanIt.WebApi.Common.Mapping; public static class WorkspaceMapping { @@ -31,8 +34,16 @@ public static AssignProjectToWorkspaceCommand MapToCommand(this AssignProjectToW public static WorkspaceResponse MapToResponse(this Workspace workspace) => ( new WorkspaceResponse( + Id: workspace.Id.Value.ToString(), Name: workspace.Name, Description: workspace.Description ) ); + + public static WorkspaceProjectsResponse MapToResponse(this List projects, string workspaceId) => ( + new WorkspaceProjectsResponse( + WorkspaceId: workspaceId, + Projects: projects.Select( p => p.MapToResponse() ).ToList() + ) + ); } \ No newline at end of file diff --git a/src/PlanIt.WebApi/Controllers/ProjectController.cs b/src/PlanIt.WebApi/Controllers/ProjectController.cs index 70c40a3..4fc646b 100644 --- a/src/PlanIt.WebApi/Controllers/ProjectController.cs +++ b/src/PlanIt.WebApi/Controllers/ProjectController.cs @@ -23,10 +23,9 @@ public ProjectController(IMapper mapper, ISender mediator) } [HttpPost] - public async Task CreateProject([FromBody]CreateProjectRequest request) + public async Task CreateProject([FromBody]CreateProjectRequest createProjectRequest) { - var userId = GetUserId(); - CreateProjectCommand command = _mapper.Map((request, userId)); + CreateProjectCommand command = createProjectRequest.MapToCommand(); var createProjectResult = await _mediator.Send(command); @@ -35,7 +34,7 @@ public async Task CreateProject([FromBody]CreateProjectRequest re return Problem(createProjectResult.Errors); } - return Ok(_mapper.Map(createProjectResult.Value)); + return Ok(createProjectResult.Value.MapToResponse()); } [HttpGet("{projectId}")] diff --git a/src/PlanIt.WebApi/Controllers/UserController.cs b/src/PlanIt.WebApi/Controllers/UserController.cs new file mode 100644 index 0000000..0bfbc76 --- /dev/null +++ b/src/PlanIt.WebApi/Controllers/UserController.cs @@ -0,0 +1,33 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using PlanIt.Application.Users.Queries; +using PlanIt.WebApi.Common.Mapping; + +namespace PlanIt.WebApi.Controllers; + +[Route("api/users/{userId}")] +public class UserController : ApiController +{ + private readonly ISender _mediator; + + public UserController(ISender mediator) + { + _mediator = mediator; + } + + [HttpGet] + [Route("workspaces")] + public async Task GetUserWorkspaces(string userId) + { + GetUserWorkspacesQuery query = new(userId); + + var getUserWorkspacesResult = await _mediator.Send(query); + + if (getUserWorkspacesResult.IsFailed) + { + return Problem(getUserWorkspacesResult.Errors); + } + + return Ok(getUserWorkspacesResult.Value.MapToResponse()); + } +} \ No newline at end of file diff --git a/src/PlanIt.WebApi/Controllers/WorkspaceController.cs b/src/PlanIt.WebApi/Controllers/WorkspaceController.cs index 78cd07f..c914fb4 100644 --- a/src/PlanIt.WebApi/Controllers/WorkspaceController.cs +++ b/src/PlanIt.WebApi/Controllers/WorkspaceController.cs @@ -9,10 +9,14 @@ using PlanIt.Application.Workspaces.Commands.UpdateWorkspace; using PlanIt.Contracts.Workspace.Requests; using PlanIt.Domain.WorkspaceAggregate; +using PlanIt.Contracts.Projects.Responses; +using PlanIt.Application.Workspaces.Queries.GetWorkspaceProjects; +using PlanIt.Contracts.Workspace.Responses; +using PlanIt.Domain.ProjectAggregate; namespace PlanIt.WebApi.Controllers; -[Authorize] + [Route("/api/workspaces")] public class WorkspaceController : ApiController { @@ -39,6 +43,22 @@ public async Task GetWorkspace(string workspaceId) return Ok(workspaceQueryResult.Value.MapToResponse()); } + + [HttpGet] + [Route("{workspaceId}/projects")] + public async Task GetWorkspaceProjects(string workspaceId) + { + GetWorkspaceProjectsQuery query = new(workspaceId); + + Result> workspaceProjectsQueryResult = await _mediator.Send(query); + + if (workspaceProjectsQueryResult.IsFailed) + { + return Problem(workspaceProjectsQueryResult.Errors); + } + + return Ok(workspaceProjectsQueryResult.Value.MapToResponse(workspaceId)); + } [HttpPost] public async Task CreateWorkspace([FromBody] CreateWorkspaceRequest createWorkspaceRequest) diff --git a/src/PlanIt.WebApi/DependencyInjection.cs b/src/PlanIt.WebApi/DependencyInjection.cs index 3aeddb7..0698cf0 100644 --- a/src/PlanIt.WebApi/DependencyInjection.cs +++ b/src/PlanIt.WebApi/DependencyInjection.cs @@ -13,6 +13,8 @@ public static IServiceCollection AddPresentation(this IServiceCollection service builder => { // Allow any part on localhost for dev purposes builder.SetIsOriginAllowed(origin => new Uri(origin).Host == "localhost"); + builder.AllowAnyMethod(); + builder.AllowAnyHeader(); } ); }); From 4bfa695279320681cb53d922c842f5940108e447 Mon Sep 17 00:00:00 2001 From: tomek Date: Tue, 10 Sep 2024 14:53:36 +0200 Subject: [PATCH 04/12] Add global exception handler middleware --- .../Middleware/ExcpetionHandlingMiddleware.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/PlanIt.WebApi/Middleware/ExcpetionHandlingMiddleware.cs diff --git a/src/PlanIt.WebApi/Middleware/ExcpetionHandlingMiddleware.cs b/src/PlanIt.WebApi/Middleware/ExcpetionHandlingMiddleware.cs new file mode 100644 index 0000000..543a353 --- /dev/null +++ b/src/PlanIt.WebApi/Middleware/ExcpetionHandlingMiddleware.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; + +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try { + await _next(context); + } + catch(Exception exception) { + _logger.LogError(exception, "Excpetion occurred: {Message}", exception.Message); + + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Server Error" + }; + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + + await context.Response.WriteAsJsonAsync(problemDetails); + } + } +} \ No newline at end of file From 446de3a8bdb81ab0da50bff57fa6a4ac5c1a6a85 Mon Sep 17 00:00:00 2001 From: tomek Date: Tue, 10 Sep 2024 15:00:01 +0200 Subject: [PATCH 05/12] Add the global exception handler middleware to the Program.cs --- src/PlanIt.WebApi/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PlanIt.WebApi/Program.cs b/src/PlanIt.WebApi/Program.cs index 434401f..b92c9ca 100644 --- a/src/PlanIt.WebApi/Program.cs +++ b/src/PlanIt.WebApi/Program.cs @@ -12,6 +12,7 @@ var app = builder.Build(); { + app.UseMiddleware(); app.UseExceptionHandler("/error"); app.UseHttpsRedirection(); app.UseCors(); From 5744485f33a3415e1344c47e75b2ffb6176b2986 Mon Sep 17 00:00:00 2001 From: tomek Date: Wed, 11 Sep 2024 16:22:46 +0200 Subject: [PATCH 06/12] Add new React UI components --- .../Workspace/UpdateWorkspaceRequest.http | 4 +- clients/plan-it-web/eslint.config.js | 2 + clients/plan-it-web/package-lock.json | 93 ++++++++++++++- clients/plan-it-web/package.json | 1 + clients/plan-it-web/src/App.tsx | 2 + .../src/components/Common/ExtendedModal.tsx | 31 +++++ .../src/components/MainWindow/MainWindow.tsx | 34 +++--- .../components/MainWindow/WorkspaceMenu.tsx | 17 ++- .../src/components/Project/Project.tsx | 25 +++- .../Project/ProjectSettings.module.css | 3 + .../components/Project/ProjectSettings.tsx | 93 +++++++++++++++ .../SortableItems/Container/Container.tsx | 2 + .../components/SortableItems/Item/Item.tsx | 17 +-- .../SortableItems/Item/SortableItem.tsx | 9 ++ .../MultipleSortableProjects.tsx | 109 +++++++++++++++--- .../plan-it-web/src/components/Task/Task.tsx | 16 +-- .../src/components/Task/TaskCard.tsx | 73 ++++++++++-- .../components/Task/TaskSettings.module.css | 3 + .../src/components/Task/TaskSettings.tsx | 81 +++++++++++++ .../WorkspaceProjectsTable.module.css | 5 + .../WorkspaceProjectsTable.tsx | 108 +++++++++++++++++ .../WorkspaceSettings.module.css | 7 ++ .../WorkspaceSettings/WorkspaceSettings.tsx | 84 ++++++++++++++ .../src/hooks/useMultipleProjectsHook.tsx | 6 +- clients/plan-it-web/src/main.tsx | 5 +- .../plan-it-web/src/redux/workspacesSlice.ts | 8 +- .../plan-it-web/src/services/planit-api.ts | 58 ++++++++-- clients/plan-it-web/src/types/Task.ts | 5 + 28 files changed, 813 insertions(+), 88 deletions(-) create mode 100644 clients/plan-it-web/src/components/Common/ExtendedModal.tsx create mode 100644 clients/plan-it-web/src/components/Project/ProjectSettings.module.css create mode 100644 clients/plan-it-web/src/components/Project/ProjectSettings.tsx create mode 100644 clients/plan-it-web/src/components/Task/TaskSettings.module.css create mode 100644 clients/plan-it-web/src/components/Task/TaskSettings.tsx create mode 100644 clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.module.css create mode 100644 clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.tsx create mode 100644 clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.module.css create mode 100644 clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.tsx create mode 100644 clients/plan-it-web/src/types/Task.ts diff --git a/Requests/Workspace/UpdateWorkspaceRequest.http b/Requests/Workspace/UpdateWorkspaceRequest.http index 84bf11c..89a46f9 100644 --- a/Requests/Workspace/UpdateWorkspaceRequest.http +++ b/Requests/Workspace/UpdateWorkspaceRequest.http @@ -1,9 +1,9 @@ @host=https://localhost:5234 -@workspaceId=e02023fa-6b10-46b8-9315-f53a081bf46d +@workspaceId=3458798c-c713-4a59-8b6f-99a48cfd05f4 PUT {{host}}/api/workspaces/{{workspaceId}} Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6ImIyMGExOTU5LTllYWYtNDU4ZS04NjI3LWQ3NDAzMzNkZmZjNSIsImV4cCI6MTcyNTYzMjgzMCwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.ZN8-8TdKJoS2KyxB0tM8yJcweCrdLuu9ucy9Dby45OM +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6ImViOWIwYTE0LWU4NmItNGMwMi1iNzczLTYxZGYyMTgzYWZmOSIsImV4cCI6MTcyNTk4NTIyMCwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.Q61IfAqjyHhT9gL8IDHlM4Of_oDPXaB-l7Gwox-6Mw4 { "name": "Workspace 1 Edited", diff --git a/clients/plan-it-web/eslint.config.js b/clients/plan-it-web/eslint.config.js index b9d0f36..a01bb47 100644 --- a/clients/plan-it-web/eslint.config.js +++ b/clients/plan-it-web/eslint.config.js @@ -32,6 +32,8 @@ export default tseslint.config( { allowConstantExport: true }, ], "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-misused-promises" : "warn", + "@typescript-eslint/no-unsafe-call" : "warn", ...react.configs.recommended.rules, ...react.configs['jsx-runtime'].rules, }, diff --git a/clients/plan-it-web/package-lock.json b/clients/plan-it-web/package-lock.json index dc1b95a..a182df5 100644 --- a/clients/plan-it-web/package-lock.json +++ b/clients/plan-it-web/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/sortable": "^8.0.0", "@mantine/core": "^7.12.2", "@mantine/hooks": "^7.12.2", + "@mantine/notifications": "^7.12.2", "@reduxjs/toolkit": "^2.2.7", "@tabler/icons-react": "^3.14.0", "classnames": "^2.5.1", @@ -669,6 +670,31 @@ "react": "^18.2.0" } }, + "node_modules/@mantine/notifications": { + "version": "7.12.2", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.12.2.tgz", + "integrity": "sha512-gTvLHkoAZ42v5bZxibP9A50djp5ndEwumVhHSa7mxQ8oSS23tt3It/6hOqH7M+9kHY0a8s+viMiflUzTByA9qg==", + "license": "MIT", + "dependencies": { + "@mantine/store": "7.12.2", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "7.12.2", + "@mantine/hooks": "7.12.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@mantine/store": { + "version": "7.12.2", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.12.2.tgz", + "integrity": "sha512-NqL31sO/KcAETEWP/CiXrQOQNoE4168vZsxyXacQHGBueVMJa64WIDQtKLHrCnFRMws3vsXF02/OO4bH4XGcMQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1788,8 +1814,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/data-view-buffer": { "version": "1.0.1", @@ -1909,6 +1934,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/es-abstract": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", @@ -3766,6 +3801,22 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -4983,6 +5034,21 @@ "integrity": "sha512-dVMw8jpM0hAzc8e7/GNvzkk9N0RN/m+PKycETB3H6lJGuXJJSRR4wzzgQKpEhHwPccktDpvb4rkukKDq2jA8Fg==", "requires": {} }, + "@mantine/notifications": { + "version": "7.12.2", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.12.2.tgz", + "integrity": "sha512-gTvLHkoAZ42v5bZxibP9A50djp5ndEwumVhHSa7mxQ8oSS23tt3It/6hOqH7M+9kHY0a8s+viMiflUzTByA9qg==", + "requires": { + "@mantine/store": "7.12.2", + "react-transition-group": "4.4.5" + } + }, + "@mantine/store": { + "version": "7.12.2", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.12.2.tgz", + "integrity": "sha512-NqL31sO/KcAETEWP/CiXrQOQNoE4168vZsxyXacQHGBueVMJa64WIDQtKLHrCnFRMws3vsXF02/OO4bH4XGcMQ==", + "requires": {} + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5655,8 +5721,7 @@ "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "data-view-buffer": { "version": "1.0.1", @@ -5734,6 +5799,15 @@ "esutils": "^2.0.2" } }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "es-abstract": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", @@ -6945,6 +7019,17 @@ "use-latest": "^1.2.1" } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", diff --git a/clients/plan-it-web/package.json b/clients/plan-it-web/package.json index 88fa9b5..2f4ec4c 100644 --- a/clients/plan-it-web/package.json +++ b/clients/plan-it-web/package.json @@ -14,6 +14,7 @@ "@dnd-kit/sortable": "^8.0.0", "@mantine/core": "^7.12.2", "@mantine/hooks": "^7.12.2", + "@mantine/notifications": "^7.12.2", "@reduxjs/toolkit": "^2.2.7", "@tabler/icons-react": "^3.14.0", "classnames": "^2.5.1", diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index ba04ecc..be2763d 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -11,6 +11,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { useAppDispatch } from './hooks/reduxHooks'; import { useEffect } from 'react'; import { setWorkspaces } from './redux/workspacesSlice'; +import { WorkspaceSettings } from './components/WorkspaceSettings/WorkspaceSettings'; export default function App() { const dispatch = useAppDispatch(); @@ -30,6 +31,7 @@ export default function App() { + } /> } /> diff --git a/clients/plan-it-web/src/components/Common/ExtendedModal.tsx b/clients/plan-it-web/src/components/Common/ExtendedModal.tsx new file mode 100644 index 0000000..726ac28 --- /dev/null +++ b/clients/plan-it-web/src/components/Common/ExtendedModal.tsx @@ -0,0 +1,31 @@ +import { Modal, Title } from "@mantine/core"; + +interface ExtendedModalProps +{ + title: string; + opened: boolean; + onClose: () => void; + children: React.ReactNode; +} + +export function ExtendedModal({ + title, + children, + opened, + onClose} : ExtendedModalProps) +{ + return ( + <> + e.stopPropagation() } opened={opened} onClose={onClose} closeOnClickOutside={true}> + + + + {title} + + + {children} + + + + ) +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx index 8a96ffe..1963007 100644 --- a/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx +++ b/clients/plan-it-web/src/components/MainWindow/MainWindow.tsx @@ -1,17 +1,20 @@ import { Flex, Group, Title } from "@mantine/core"; import { MultipleSortableProjects } from '../SortableItems/MultipleSortableProjects'; import classes from './MainWindow.module.css'; -import { useCreateProjectMutation, useGetProjectsForWorkspaceQuery } from "../../services/planit-api"; +import { useCreateProjectMutation, useGetProjectsForWorkspaceQuery, useGetWorkspaceQuery } from "../../services/planit-api"; import { useParams } from "react-router-dom"; import { useEffect } from "react"; import { WorkspaceMenu } from "./WorkspaceMenu"; +import { Project } from "../../types/Project"; export function MainWindow() { const { workspaceId } = useParams<{ workspaceId: string }>(); - const { data : projects, error, isLoading, refetch } = useGetProjectsForWorkspaceQuery(workspaceId, { + + const { data: workspace, error: workspaceFetchError, isLoading: isLoadingWorkspace } = useGetWorkspaceQuery(workspaceId ?? ""); + const { data : projects, error: workspaceProjectsFetchError, isLoading, refetch } = useGetProjectsForWorkspaceQuery(workspaceId ?? "", { skip: !workspaceId }); - const [ createProject, {isLoading: isUpdating }] = useCreateProjectMutation(); + const [ createProject ] = useCreateProjectMutation(); useEffect(() => { if (workspaceId) { @@ -22,13 +25,14 @@ export function MainWindow() { useEffect(() => { console.log('Refetched projects:', projects); }, [projects]); - - if (isLoading) { - return
Loading projects...
; - } - if (error) { - return
Error loading projects: {error.toString()}
; + if (workspaceFetchError) { + return
Incorrect workspace
; + } + + if (workspaceProjectsFetchError) + { + return
Could not retrieve projects for this workspace
; } const handleAddNewProject = async () => { @@ -38,30 +42,30 @@ export function MainWindow() { description: "New Project Description", projectTasks: [] }); - + refetch().catch(console.error); - - return newProject.data; + + return newProject; } const projectsToObjects = projects?.projects.reduce((prev, cur) => ({...prev, [cur.id]: cur}), {}); return ( - {isLoading ? ( + {isLoading || isLoadingWorkspace ? (
Loading...
) : ( <> - Workspace + {workspace!.name} {projects?.projects && projects.projects.length > 0 ? ( ) : ( diff --git a/clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx b/clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx index badfaae..23f240c 100644 --- a/clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx +++ b/clients/plan-it-web/src/components/MainWindow/WorkspaceMenu.tsx @@ -1,21 +1,21 @@ import { ActionIcon, Group, Menu } from "@mantine/core"; import { IconSettings, IconTrash } from "@tabler/icons-react"; import { useAppDispatch } from "../../hooks/reduxHooks"; -import { useParams } from "react-router-dom"; +import { Navigate, useNavigate, useParams } from "react-router-dom"; import { useDeleteWorkspaceMutation } from "../../services/planit-api"; -import { deleteWorkspace } from "../../redux/workspacesSlice"; +import { deleteWorkspaceLocal } from "../../redux/workspacesSlice"; export function WorkspaceMenu() { const { workspaceId } = useParams<{ workspaceId: string }>(); + const navigate = useNavigate(); const dispatch = useAppDispatch(); const [ deleteWorkspace ] = useDeleteWorkspaceMutation(); const handleDeleteWorkspace = async () => { + console.log("calling!"); if (!workspaceId) return; - console.log("workspaceId:", workspaceId); - if (!window.confirm('Are you sure you want to delete this workspace?')) return; const result = await deleteWorkspace(workspaceId); @@ -25,7 +25,12 @@ export function WorkspaceMenu() console.error('Error deleting workspace:', result.error); } - dispatch(deleteWorkspace(workspaceId)); + dispatch(deleteWorkspaceLocal(workspaceId)); + navigate('/'); + } + + const handleWorkspaceSettings = () => { + navigate(`/workspaces/${workspaceId}/settings`); } return ( @@ -37,7 +42,7 @@ export function WorkspaceMenu() - + Workspace Settings diff --git a/clients/plan-it-web/src/components/Project/Project.tsx b/clients/plan-it-web/src/components/Project/Project.tsx index eabefe3..93cf6ee 100644 --- a/clients/plan-it-web/src/components/Project/Project.tsx +++ b/clients/plan-it-web/src/components/Project/Project.tsx @@ -1,7 +1,10 @@ import classes from './Project.module.css'; -import { Avatar, Group, Progress, Stack, Title, Text, Loader } from "@mantine/core"; +import { Avatar, Group, Progress, Stack, Title, Text, Loader, Modal, ActionIcon } from "@mantine/core"; import { Handle, Remove } from "../SortableItems/Item"; -import { useDeleteProjectMutation } from '../../services/planit-api'; +import { useDisclosure } from '@mantine/hooks'; +import { IconAdjustments } from '@tabler/icons-react'; +import { ProjectSettings } from './ProjectSettings'; +import { ExtendedModal } from '../Common/ExtendedModal'; const avatars = [ 'https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-2.png', @@ -11,19 +14,28 @@ const avatars = [ interface ProjectProps { handleProps : React.HTMLAttributes | undefined; + onUpdate: () => void; onRemove: () => void; name: string; + id: string; description: string; } -export function Project({ onRemove, handleProps, name, description, id } : ProjectProps ) { +export function Project({ onUpdate, onRemove, handleProps, name, description, id } : ProjectProps ) { + const [modalOpened, { open, close }] = useDisclosure(false); return ( - + <> + + + + - {name + id} + {name} - + + + @@ -43,5 +55,6 @@ export function Project({ onRemove, handleProps, name, description, id } : Proje + ); } \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Project/ProjectSettings.module.css b/clients/plan-it-web/src/components/Project/ProjectSettings.module.css new file mode 100644 index 0000000..cc1237d --- /dev/null +++ b/clients/plan-it-web/src/components/Project/ProjectSettings.module.css @@ -0,0 +1,3 @@ +.container { + +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Project/ProjectSettings.tsx b/clients/plan-it-web/src/components/Project/ProjectSettings.tsx new file mode 100644 index 0000000..aac10b9 --- /dev/null +++ b/clients/plan-it-web/src/components/Project/ProjectSettings.tsx @@ -0,0 +1,93 @@ +import { Button, Flex, Group, Loader, Stack, TextInput, Title } from "@mantine/core"; +import classes from "./projectSettings.module.css" +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { notifications } from '@mantine/notifications'; +import { useGetProjectQuery, useUpdateProjectMutation } from "../../services/planit-api"; +import { Project } from "../../types/Project"; + +interface ProjectSettingsProps { + onUpdate: (project: Project) => void; + onRemove: () => void; + projectId: string; +} + +export function ProjectSettings({ + onUpdate, + onRemove, + projectId} : ProjectSettingsProps) +{ + const navigate = useNavigate(); + // Get the project ID from the URL + + if (!projectId) + { + console.error('No project ID found'); + + notifications.show({ + title: 'Erorr accessing project', + message: 'project was not found, please try again!', + color: 'red' + }) + + navigate('/'); + + return; + } + + // Get details about the project and project projects + const { data: project, error : projectError , isLoading : projectLoading } = useGetProjectQuery(projectId); + + // Local state for the project settings + const [ projectName, setProjectName ] = useState(project?.name); + const [ projectDescription, setProjectDescription ] =useState(project?.description); + + const [ updateproject, { isLoading : projectUpdating } ] = useUpdateProjectMutation(); + + + const handleSaveproject = async () => { + // Save the project settings + const updatedProject = { + name: projectName, + description: projectDescription + }; + + const result = await updateproject({updatedProject, projectId: projectId}); + + if (result.error) + { + console.error('Error updating project:', result.error); + notifications.show({ + title: 'Erorr updating project', + message: `${result.error.data.title}`, + color: 'red' + }) + return; + } + + onUpdate(result.data); + + notifications.show({ + title: 'Success', + message: 'Project settings saved successfuly', + color: 'green' + }); + } + + return ( + + {(projectLoading) && } + + + setProjectName(e.currentTarget.value) } /> + setProjectDescription(e.currentTarget.value) }/> + + + + + + + + + ) +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx b/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx index 9f8ddb0..7f98e8c 100644 --- a/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx +++ b/clients/plan-it-web/src/components/SortableItems/Container/Container.tsx @@ -30,6 +30,7 @@ export const Container = forwardRef( horizontal, hover, onClick, + onUpdate, onRemove, label, content, @@ -71,6 +72,7 @@ export const Container = forwardRef( void; dragOverlay?: boolean; color?: string; disabled?: boolean; @@ -21,6 +19,8 @@ export interface ItemProps { handle?: boolean; handleProps?: any; height?: number; + projectId: string; + taskId: string; index?: number; fadeIn?: boolean; content: ProjectTask; @@ -33,6 +33,8 @@ export interface ItemProps { value: React.ReactNode; onRemove?(): void; renderItem?(args: { + onUpdate: () => void; + onDelete: () => void; dragOverlay: boolean; dragging: boolean; sorting: boolean; @@ -51,12 +53,15 @@ export const Item = React.memo( React.forwardRef( ( { + onUpdate, + onDelete, dragOverlay, dragging, disabled, fadeIn, + projectId, + taskId, handle, - handleProps, index, listeners, sorting, @@ -119,7 +124,6 @@ export const Item = React.memo( className={classNames( styles.Item, dragging && styles.dragging, - handle && styles.withHandle, dragOverlay && styles.dragOverlay, disabled && styles.disabled, )} @@ -128,9 +132,8 @@ export const Item = React.memo( {...(!handle ? listeners : undefined)} tabIndex={!handle ? 0 : undefined} > - + - {handle ? : null} diff --git a/clients/plan-it-web/src/components/SortableItems/Item/SortableItem.tsx b/clients/plan-it-web/src/components/SortableItems/Item/SortableItem.tsx index dd5f8e2..d8d3ebb 100644 --- a/clients/plan-it-web/src/components/SortableItems/Item/SortableItem.tsx +++ b/clients/plan-it-web/src/components/SortableItems/Item/SortableItem.tsx @@ -3,6 +3,8 @@ import { Item } from './Item'; import { ProjectTask } from '../../../types/Project'; interface SortableItemProps { + onDeleteTask: () => void; + onUpdateTask: () => void; containerId: string; id: string; index: number; @@ -13,7 +15,10 @@ interface SortableItemProps { } export function SortableItem({ + onUpdateTask, + onDeleteTask, disabled, + projectId, id, index, handle, @@ -33,13 +38,17 @@ export function SortableItem({ return ( Promise; + onAddNewProject: any; columns?: number; containerStyle?: React.CSSProperties; handle?: boolean; strategy?: SortingStrategy; modifiers?: Modifiers; scrollable?: boolean; - projectsIn: Record; + workspaceProjects: Items; } export function MultipleSortableProjects({ @@ -35,21 +35,20 @@ export function MultipleSortableProjects({ onAddNewProject, columns, handle = false, - projectsIn, + workspaceProjects, containerStyle, modifiers, strategy, scrollable, }: MultipleSortableProjectsProps) { const theme = useMantineTheme(); - const [projects, setProjects] = useState(projectsIn); + const [projects, setProjects] = useState(workspaceProjects); const [deleteProject] = useDeleteProjectMutation(); - - console.log(projects); + const [createProjectTask] = useCreateProjectTaskMutation(); useEffect(() => { - setProjects(structuredClone(projectsIn)); - }, [JSON.stringify(projectsIn)]) + setProjects(structuredClone(workspaceProjects)); + }, [JSON.stringify(workspaceProjects)]) const { sensors, @@ -65,12 +64,57 @@ export function MultipleSortableProjects({ const containers = useMemo(() => Object.keys(projects), [projects]); + const handleAddTask = async ( projectId : string ) => { + const result = await createProjectTask({ + projectId: projectId, + task: { + name: "New Task", + description: "New Task" + } + }); + + if (result.error) { + console.error('Error adding project task:', result.error); + notifications.show({ + title: 'Error adding project task', + message: 'Could not add project task, please try again!', + color: 'red' + }); + return; + } + + if (projects[projectId].projectTasks.find((task) => task.id === result.data.id) != undefined) return; + + setProjects((prevProjects : object) => { + if (prevProjects[projectId].projectTasks.find((task) => task.id === result.data.id) != undefined) return prevProjects; + + const newProjects = {...prevProjects}; + newProjects[projectId].projectTasks.push(result.data); + return newProjects; + }); + } + const handleAddColumn = async () => { - const newProject = await onAddNewProject(); - const newContainerId = getNextContainerId(); - setProjects((prevProjects) => ({ + const result = await onAddNewProject(); + + if (result.error) + { + console.error('Error adding new project :', result.error); + + notifications.show({ + title: 'Error adding new project', + message: 'Could not add new project, please try again!', + color: 'red' + }); + + return; + } + + const newProject: Project = result.data; + + setProjects((prevProjects : object) => ({ ...prevProjects, - [newContainerId]: { + [newProject.id]: { workspaceId: newProject.workspaceId, id: newProject.id, name: newProject.name, @@ -79,6 +123,13 @@ export function MultipleSortableProjects({ }, }))}; + const handleUpdate = (updatedProject : Project) => { + setProjects((prevProjects : Project) => ({ + ...prevProjects, + [updatedProject.id]: {...updatedProject} + })); + } + const handleRemove = useCallback(async (containerID: string) => { const result = await deleteProject(projects[containerID].id); if (result.error) { @@ -92,6 +143,27 @@ export function MultipleSortableProjects({ }); }, [deleteProject, projects]); + const onDeleteTask = (projectId, taskId) => { + setProjects((prevProjects) => { + const newProjects = {...prevProjects}; + newProjects[projectId].projectTasks = newProjects[projectId].projectTasks.filter((task) => task.id !== taskId); + return newProjects; + }); + } + + const onUpdateTask = (projectId, taskId, updatedTask) => { + setProjects((prevProjects) => { + const newProjects = {...prevProjects}; + newProjects[projectId].projectTasks = newProjects[projectId].projectTasks.map((task) => { + if (task.id === taskId) { + return updatedTask; + } + return task; + }); + + return newProjects; + })}; + return ( task.id)} scrollable={scrollable} style={containerStyle} + onUpdate = {(updatedProject) => handleUpdate(updatedProject) } onRemove={() => handleRemove(containerId)} > task.id)} strategy={strategy}> {projects[containerId].projectTasks.map((task) => ( onDeleteTask(projects[containerId].id, task.id) } index={task.id} key={task.id} id={task.id} + projectId={projects[containerId].id} content={task} handle={handle} containerId={containerId} @@ -129,7 +205,7 @@ export function MultipleSortableProjects({ ))} console.log("Add task")} + onClick={() => handleAddTask(projects[containerId].id) } style={{ width: "45px", height: "45px", @@ -146,7 +222,6 @@ export function MultipleSortableProjects({ diff --git a/clients/plan-it-web/src/components/Task/Task.tsx b/clients/plan-it-web/src/components/Task/Task.tsx index 682876e..9092618 100644 --- a/clients/plan-it-web/src/components/Task/Task.tsx +++ b/clients/plan-it-web/src/components/Task/Task.tsx @@ -1,20 +1,20 @@ -import {useSortable} from '@dnd-kit/sortable'; -import {CSS} from '@dnd-kit/utilities'; import { TaskCard } from './TaskCard'; import classes from './Task.module.css'; interface SortableItemProps { - id: number; - key: number; + id: string; + projectId: string; name: string; description: string; + onDelete: () => void; + onUpdate: () => void; } -export function Task( {id, name, description, key} : SortableItemProps) { - +export function Task( {id, projectId, name, description, onDelete, onUpdate} : SortableItemProps) { return ( -
- +
+ +
); } \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Task/TaskCard.tsx b/clients/plan-it-web/src/components/Task/TaskCard.tsx index 23fdd86..eb2e865 100644 --- a/clients/plan-it-web/src/components/Task/TaskCard.tsx +++ b/clients/plan-it-web/src/components/Task/TaskCard.tsx @@ -1,23 +1,79 @@ -import { Card, Avatar, Text, Progress, Badge, Group, ActionIcon, useMantineTheme, Stack } from '@mantine/core'; -import { IconUpload } from '@tabler/icons-react'; +import { Card, Avatar, Text, Progress, Badge, Group, ActionIcon, useMantineTheme, Stack, Title, Modal, Flex } from '@mantine/core'; import classes from './TaskCard.module.css'; +import { IconSettings, IconX } from '@tabler/icons-react'; +import { useDeleteProjectTaskMutation } from '../../services/planit-api'; +import { notifications } from '@mantine/notifications'; +import { useDisclosure } from '@mantine/hooks'; +import { ExtendedModal } from '../Common/ExtendedModal'; +import { TaskSettings } from './TaskSettings'; interface TaskCardProps { - id: number; + projectId: string; + taskId: string; name: string; description: string; + onDelete: () => void; }; -export function TaskCard( {id, name, description} : TaskCardProps) { +export function TaskCard( { + projectId, + taskId, + name, + description, + onUpdate, + onDelete +} : TaskCardProps) { + const [deleteProjectTask] = useDeleteProjectTaskMutation(); + const [modalOpened, { open, close }] = useDisclosure(false); + const theme = useMantineTheme(); + const handleDelete = async () => { + console.log('Deleting project task:', taskId); + const result = await deleteProjectTask({projectId, taskId}); + + if (result.error) + { + console.error('Error deleting project task:', result.error); + notifications.show({ + title: 'Error deleting project task', + message: 'Could not delete project task, please try again!', + color: 'red' + }); + return; + } + + // Callback from top component + onDelete(); + notifications.show({ + title: 'Project task deleted', + message: 'Project task was successfully deleted', + color: 'green' + }); + + } + return ( + <> + + + - - - {name} - + + + + {name} + + + + + + + + + + {description} @@ -29,5 +85,6 @@ export function TaskCard( {id, name, description} : TaskCardProps) { + ); } \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Task/TaskSettings.module.css b/clients/plan-it-web/src/components/Task/TaskSettings.module.css new file mode 100644 index 0000000..cc1237d --- /dev/null +++ b/clients/plan-it-web/src/components/Task/TaskSettings.module.css @@ -0,0 +1,3 @@ +.container { + +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Task/TaskSettings.tsx b/clients/plan-it-web/src/components/Task/TaskSettings.tsx new file mode 100644 index 0000000..f76f3aa --- /dev/null +++ b/clients/plan-it-web/src/components/Task/TaskSettings.tsx @@ -0,0 +1,81 @@ +import { Button, Flex, Group, Loader, Stack, TextInput, Title } from "@mantine/core"; +import classes from "./TaskSettings.module.css" +import { useEffect, useState } from "react"; +import { notifications } from '@mantine/notifications'; +import { useGetProjectQuery, useGetProjectTasksQuery, useUpdateProjectTaskMutation, } from "../../services/planit-api"; +import { Task } from "../../types/Task"; + +interface TaskSettingsProps { + onUpdate: (projectId: string, taskId: string, updatedTask: Task) => void; + projectId: string; + taskId: string; +} + +export function TaskSettings({ + onUpdate, + projectId, + taskId} : TaskSettingsProps) +{ + // Get details about the Task and Task Tasks + const { data: project, error : taskError , isLoading : projectLoading } = useGetProjectQuery(projectId); + + // Local state for the Task settings + const [ taskName, setTaskName ] = useState(""); + const [ taskDescription, setTaskDescription ] =useState(""); + const [ updateProjectTask, { isLoading : TaskUpdating } ] = useUpdateProjectTaskMutation(); + + let task; + + if (!projectLoading) + { + if (project) + { + task = project.projectTasks.find(t => t.id === taskId); + } + } + + + const handleSaveTask = async () => { + // Save the Task settings + const updatedTask = { + name: taskName, + description: taskDescription + }; + + const result = await updateProjectTask({projectId, taskId, updatedTask}); + + if (result.error) + { + console.error('Error updating Task:', result.error); + notifications.show({ + title: 'Erorr updating Task', + message: `${result.error.data.title}`, + color: 'red' + }) + return; + } + + onUpdate(projectId, taskId, result.data); + + notifications.show({ + title: 'Success', + message: 'Task settings saved successfuly', + color: 'green' + }); + } + + return ( + + { projectLoading && } + + + setTaskName(e.currentTarget.value) } /> + setTaskDescription(e.currentTarget.value) }/> + + + + + + + ) +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.module.css b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.module.css new file mode 100644 index 0000000..817b6eb --- /dev/null +++ b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.module.css @@ -0,0 +1,5 @@ +.progressSection { + &:not(:first-of-type) { + border-left: rem(3px) solid light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + } + } \ No newline at end of file diff --git a/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.tsx b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.tsx new file mode 100644 index 0000000..bb4eaba --- /dev/null +++ b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceProjectsTable.tsx @@ -0,0 +1,108 @@ +import { Table, Progress, Anchor, Text, Group, ActionIcon, Avatar } from '@mantine/core'; +import classes from './WorkspaceProjectsTable.module.css'; +import { IconTrash } from '@tabler/icons-react'; +import { Project } from '../../types/Project'; + +const data = [ + { + title: 'Foundation', + author: 'Isaac Asimov', + year: 1951, + reviews: { positive: 2223, negative: 259 }, + }, + { + title: 'Frankenstein', + author: 'Mary Shelley', + year: 1818, + reviews: { positive: 5677, negative: 1265 }, + }, + { + title: 'Solaris', + author: 'Stanislaw Lem', + year: 1961, + reviews: { positive: 3487, negative: 1845 }, + }, + { + title: 'Dune', + author: 'Frank Herbert', + year: 1965, + reviews: { positive: 8576, negative: 663 }, + }, + { + title: 'The Left Hand of Darkness', + author: 'Ursula K. Le Guin', + year: 1969, + reviews: { positive: 6631, negative: 993 }, + }, + { + title: 'A Scanner Darkly', + author: 'Philip K Dick', + year: 1977, + reviews: { positive: 8124, negative: 1847 }, + }, +]; + +interface WorkspaceProjectsTableProps { + projects: Project[]; +} + +export function WorkspaceProjectsTable({projects} : WorkspaceProjectsTableProps ) { + const rows = projects.map((row) => { + + return ( + + + + {row.name} + + + {row.description} + + + + + + + + + + + + + + + + + + + ); + }); + + return ( + + + + + Title + Description + Assigned users + Reviews distribution + Delete + + + {rows} +
+
+ ); +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.module.css b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.module.css new file mode 100644 index 0000000..ee7d415 --- /dev/null +++ b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.module.css @@ -0,0 +1,7 @@ +.container { + width: 100%; + flex-direction: row; + flex-wrap: wrap; + margin-left: 15px; + margin-top: 15px; +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.tsx b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.tsx new file mode 100644 index 0000000..00f927d --- /dev/null +++ b/clients/plan-it-web/src/components/WorkspaceSettings/WorkspaceSettings.tsx @@ -0,0 +1,84 @@ +import { Button, Flex, Paper, Stack, Text, TextInput, Title } from "@mantine/core"; +import classes from "./WorkspaceSettings.module.css" +import { useNavigate, useParams } from "react-router-dom"; +import { useGetProjectsForWorkspaceQuery, useGetWorkspaceQuery, useUpdateWorkspaceMutation } from "../../services/planit-api"; +import { WorkspaceProjectsTable } from "./WorkspaceProjectsTable"; +import { useState } from "react"; +import { updateWorkspaceLocal } from "../../redux/workspacesSlice"; +import { useAppDispatch } from "../../hooks/reduxHooks"; +import { notifications } from '@mantine/notifications'; + +export function WorkspaceSettings() +{ + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + // Get the workspace ID from the URL + const { workspaceId } = useParams<{ workspaceId: string }>(); + + if (!workspaceId) + { + console.error('No workspace ID found'); + + notifications.show({ + title: 'Erorr accessing workspace', + message: 'Workspace was not found, please try again!', + color: 'red' + }) + + navigate('/'); + + return; + } + + // Get details about the workspace and workspace projects + const { data: workspace, error : workspaceError , isLoading : workspaceLoading } = useGetWorkspaceQuery(workspaceId); + const { data : projects, error : projectsError , isLoading : projectsLoading, refetch } = useGetProjectsForWorkspaceQuery(workspaceId); + + // Local state for the workspace settings + const [ workspaceName, setWorkspaceName ] = useState(workspace?.name); + const [ workspaceDescription, setWorkspaceDescription ] =useState(workspace?.description); + + const [ updateWorkspace, { isLoading : workspaceUpdating } ] = useUpdateWorkspaceMutation(); + + + const handleSaveWorkspace = async () => { + // Save the workspace settings + const updatedWorkspace = { + name: workspaceName, + description: workspaceDescription + }; + + const result = await updateWorkspace({updatedWorkspace, workspaceId: workspaceId}); + + if (result.error) + { + console.error('Error updating workspace:', result.error); + notifications.show({ + title: 'Erorr updating workspace', + message: 'Could not update workspace, please try again!', + color: 'red' + }) + return; + } + + dispatch(updateWorkspaceLocal(result.data)); + } + + return ( + + {(workspaceLoading || projectsLoading) && Loading...} + + + + Workspace Settings + setWorkspaceName(e.currentTarget.value) } /> + setWorkspaceDescription(e.currentTarget.value) }/> + + Projects + + + + + + ) +} \ No newline at end of file diff --git a/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx b/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx index f07aab1..2017474 100644 --- a/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx +++ b/clients/plan-it-web/src/hooks/useMultipleProjectsHook.tsx @@ -84,7 +84,7 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch { if (active.id in items && over?.id) { @@ -147,7 +147,7 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch { if (clonedItems) { @@ -155,7 +155,7 @@ export const useMultipleContainers = (items: Items, setItems: React.Dispatch { const containerId = findContainer(id) as UniqueIdentifier; diff --git a/clients/plan-it-web/src/main.tsx b/clients/plan-it-web/src/main.tsx index 28b1285..5e7265f 100644 --- a/clients/plan-it-web/src/main.tsx +++ b/clients/plan-it-web/src/main.tsx @@ -3,13 +3,16 @@ import { createRoot } from 'react-dom/client' import App from './App.tsx' import { Provider } from 'react-redux' import { MantineProvider } from '@mantine/core' +import { Notifications } from '@mantine/notifications'; +import '@mantine/notifications/styles.css'; import { store } from './redux/store.ts' createRoot(document.getElementById('root')!).render( - + + , diff --git a/clients/plan-it-web/src/redux/workspacesSlice.ts b/clients/plan-it-web/src/redux/workspacesSlice.ts index 7658d6c..564e479 100644 --- a/clients/plan-it-web/src/redux/workspacesSlice.ts +++ b/clients/plan-it-web/src/redux/workspacesSlice.ts @@ -16,13 +16,13 @@ const workspacesSlice = createSlice({ addWorkspace: (state, action: PayloadAction) => { state.workspaces.push(action.payload) }, - updateWorkspace: (state, action: PayloadAction) => { + updateWorkspaceLocal: (state, action: PayloadAction) => { const index = state.workspaces.findIndex(workspace => workspace.id === action.payload.id) if (index !== -1) { state.workspaces[index] = action.payload } }, - deleteWorkspace: (state, action: PayloadAction) => { + deleteWorkspaceLocal: (state, action: PayloadAction) => { state.workspaces = state.workspaces.filter(workspace => workspace.id !== action.payload) }, setWorkspaces: (state, action: PayloadAction) => { @@ -33,8 +33,8 @@ const workspacesSlice = createSlice({ export const { addWorkspace, - updateWorkspace, - deleteWorkspace, + updateWorkspaceLocal, + deleteWorkspaceLocal, setWorkspaces } = workspacesSlice.actions export default workspacesSlice.reducer diff --git a/clients/plan-it-web/src/services/planit-api.ts b/clients/plan-it-web/src/services/planit-api.ts index 872d889..7412a21 100644 --- a/clients/plan-it-web/src/services/planit-api.ts +++ b/clients/plan-it-web/src/services/planit-api.ts @@ -1,10 +1,10 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' -import type { Project, Workspace, WorkspaceProjects } from '../types/Project' -import { deleteWorkspace } from '../redux/workspacesSlice'; +import type { Project, ProjectTask, Workspace, WorkspaceProjects } from '../types/Project' +import { deleteWorkspace, updateWorkspace } from '../redux/workspacesSlice'; const HOST = "https://localhost:5234"; -const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6IjY5Yzg5OGVmLWQ2ODctNDdhOS1hNzEzLTgwZmZmODY5MmI1MyIsImV4cCI6MTcyNTk3MTgyNiwiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.zBy2yXBVdu9-3RQeFTjFJNCdb0M_6vTAF_N6LNSINEs"; +const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6IjMzMjFlYTgyLTU5ZGUtNDczMS05MDMzLTllM2M4YTAwNDhkMSIsImV4cCI6MTcyNjA2NTEzNywiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.l5Q00XDMDhCw4EQcSHMoUj-Pat7QAMhDFuy9lHWiU0g"; // Define a service using a base URL and expected endpoints export const projectApi = createApi({ @@ -15,7 +15,7 @@ export const projectApi = createApi({ headers: { Authorization: `Bearer ${TOKEN}`} }), endpoints: (builder) => ({ - getProjectById: builder.query({ + getProject: builder.query({ query: (id) => `projects/${id}`, }), getUserWorkspaces: builder.query({ @@ -31,12 +31,21 @@ export const projectApi = createApi({ body: newProject, }), }), + updateProject: builder.mutation, projectId: string }>({ + query: ({ updatedProject, projectId }) => ({ + url: `projects/${projectId}`, + method: 'PUT', + body: updatedProject, + })}), deleteProject: builder.mutation({ query: (projectId) => ({ url: `projects/${projectId}`, method: 'DELETE', }), }), + getWorkspace: builder.query({ + query: (workspaceId) => `workspaces/${workspaceId}`, + }), createWorkspace: builder.mutation>({ query: (newWorkspace) => ({ url: `workspaces/`, @@ -44,22 +53,57 @@ export const projectApi = createApi({ body: newWorkspace, }), }), + updateWorkspace: builder.mutation, workspaceId: string }>({ + query: ({ updatedWorkspace, workspaceId }) => ({ + url: `workspaces/${workspaceId}`, + method: 'PUT', + body: updatedWorkspace, + })}), deleteWorkspace: builder.mutation({ query: (workspaceId) => ({ url: `workspaces/${workspaceId}`, method: 'DELETE', }), - }) + }), + // Project tasks + getProjectTasks: builder.query({ + query: (projectId) => `projects/${projectId}`, + }), + createProjectTask: builder.mutation }>({ + query: ({ projectId, task }) => ({ + url: `projects/${projectId}/tasks`, + method: 'POST', + body: task, + }) + }), + updateProjectTask: builder.mutation }>({ + query: ({ projectId, taskId, updatedTask }) => ({ + url: `projects/${projectId}/tasks/${taskId}`, + method: 'PUT', + body: updatedTask, + })}), + deleteProjectTask: builder.mutation({ + query: ({ projectId, taskId }) => ({ + url: `projects/${projectId}/tasks/${taskId}`, + method: 'DELETE', + })}) })}); // Export hooks for usage in functional components, which are // auto-generated based on the defined endpoints export const { - useGetProjectByIdQuery, + useGetProjectQuery, useGetUserWorkspacesQuery, useGetProjectsForWorkspaceQuery, useCreateProjectMutation, + useUpdateProjectMutation, useDeleteProjectMutation, + useGetWorkspaceQuery, useCreateWorkspaceMutation, - useDeleteWorkspaceMutation + useUpdateWorkspaceMutation, + useDeleteWorkspaceMutation, + useCreateProjectTaskMutation, + useDeleteProjectTaskMutation, + useUpdateProjectTaskMutation, + useGetProjectTasksQuery } = projectApi; \ No newline at end of file diff --git a/clients/plan-it-web/src/types/Task.ts b/clients/plan-it-web/src/types/Task.ts new file mode 100644 index 0000000..4086b84 --- /dev/null +++ b/clients/plan-it-web/src/types/Task.ts @@ -0,0 +1,5 @@ +export interface Task { + name: string; + description: string; + id: string; +} \ No newline at end of file From a2abba327ddf1346dc9136b28af1fa78ad9182d9 Mon Sep 17 00:00:00 2001 From: tomek Date: Wed, 11 Sep 2024 16:42:54 +0200 Subject: [PATCH 07/12] Add Login page --- clients/plan-it-web/src/App.tsx | 5 ++- .../src/components/Login/Login.module.css | 6 +++ .../src/components/Login/Login.tsx | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 clients/plan-it-web/src/components/Login/Login.module.css create mode 100644 clients/plan-it-web/src/components/Login/Login.tsx diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index be2763d..697b826 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -12,9 +12,11 @@ import { useAppDispatch } from './hooks/reduxHooks'; import { useEffect } from 'react'; import { setWorkspaces } from './redux/workspacesSlice'; import { WorkspaceSettings } from './components/WorkspaceSettings/WorkspaceSettings'; +import { Login } from './components/Login/Login'; export default function App() { const dispatch = useAppDispatch(); + const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated); const { data, isLoading, error } = useGetUserWorkspacesQuery('e0d91303-b5c9-4530-9914-d27c7a054415'); useEffect(() => { @@ -29,8 +31,9 @@ export default function App() { return ( <> - + {isAuthenticated && } + } /> } /> } /> diff --git a/clients/plan-it-web/src/components/Login/Login.module.css b/clients/plan-it-web/src/components/Login/Login.module.css new file mode 100644 index 0000000..e6d17eb --- /dev/null +++ b/clients/plan-it-web/src/components/Login/Login.module.css @@ -0,0 +1,6 @@ +.title { + font-family: + Greycliff CF, + var(--mantine-font-family); + font-weight: 900; + } \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Login/Login.tsx b/clients/plan-it-web/src/components/Login/Login.tsx new file mode 100644 index 0000000..b09661d --- /dev/null +++ b/clients/plan-it-web/src/components/Login/Login.tsx @@ -0,0 +1,43 @@ +import { + TextInput, + PasswordInput, + Checkbox, + Anchor, + Paper, + Title, + Text, + Container, + Group, + Button, + } from '@mantine/core'; + import classes from './Login.module.css'; + + export function Login() { + return ( + + + Welcome back! + + + Do not have an account yet?{' '} + + Create account + + + + + + + + + + Forgot password? + + + + + + ); + } \ No newline at end of file From 6750198e18f74b88cfa8c1bbd3630c4d38c54474 Mon Sep 17 00:00:00 2001 From: tomek Date: Wed, 11 Sep 2024 17:52:12 +0200 Subject: [PATCH 08/12] Add Login front-end operations --- clients/plan-it-web/package-lock.json | 15 +++++++ clients/plan-it-web/package.json | 1 + clients/plan-it-web/src/App.tsx | 11 ++++- .../src/components/Login/Login.tsx | 30 ++++++++++++-- clients/plan-it-web/src/hooks/useJwtAuth.ts | 40 +++++++++++++++++++ clients/plan-it-web/src/redux/authSlice.ts | 38 ++++++++++++++++++ clients/plan-it-web/src/redux/store.ts | 6 ++- clients/plan-it-web/src/services/auth-api.ts | 38 ++++++++++++++++++ .../plan-it-web/src/services/planit-api.ts | 18 ++++++--- clients/plan-it-web/src/types/Auth.ts | 23 +++++++++++ clients/plan-it-web/src/types/User.ts | 6 +++ .../Controllers/UserController.cs | 2 + 12 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 clients/plan-it-web/src/hooks/useJwtAuth.ts create mode 100644 clients/plan-it-web/src/redux/authSlice.ts create mode 100644 clients/plan-it-web/src/services/auth-api.ts create mode 100644 clients/plan-it-web/src/types/Auth.ts create mode 100644 clients/plan-it-web/src/types/User.ts diff --git a/clients/plan-it-web/package-lock.json b/clients/plan-it-web/package-lock.json index a182df5..f9b36b7 100644 --- a/clients/plan-it-web/package-lock.json +++ b/clients/plan-it-web/package-lock.json @@ -17,6 +17,7 @@ "@tabler/icons-react": "^3.14.0", "classnames": "^2.5.1", "eslint-plugin-react": "^7.35.0", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.1.2", @@ -3128,6 +3129,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6627,6 +6637,11 @@ "object.values": "^1.1.6" } }, + "jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==" + }, "keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/clients/plan-it-web/package.json b/clients/plan-it-web/package.json index 2f4ec4c..62d5c87 100644 --- a/clients/plan-it-web/package.json +++ b/clients/plan-it-web/package.json @@ -19,6 +19,7 @@ "@tabler/icons-react": "^3.14.0", "classnames": "^2.5.1", "eslint-plugin-react": "^7.35.0", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.1.2", diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index 697b826..23e98b5 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -8,15 +8,22 @@ import { MainWindow } from './components/MainWindow/MainWindow'; import { useGetUserWorkspacesQuery } from './services/planit-api'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; -import { useAppDispatch } from './hooks/reduxHooks'; +import { useAppDispatch, useAppSelector } from './hooks/reduxHooks'; import { useEffect } from 'react'; import { setWorkspaces } from './redux/workspacesSlice'; import { WorkspaceSettings } from './components/WorkspaceSettings/WorkspaceSettings'; import { Login } from './components/Login/Login'; +import { jwtDecode } from 'jwt-decode'; +import { JwtInformation } from './types/Auth'; +import { logOut, setCredentials } from './redux/authSlice'; +import { useJwtAuth } from './hooks/useJwtAuth'; export default function App() { const dispatch = useAppDispatch(); - const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated); + const isAuthenticated = useJwtAuth(); + + console.log(isAuthenticated); + const { data, isLoading, error } = useGetUserWorkspacesQuery('e0d91303-b5c9-4530-9914-d27c7a054415'); useEffect(() => { diff --git a/clients/plan-it-web/src/components/Login/Login.tsx b/clients/plan-it-web/src/components/Login/Login.tsx index b09661d..5abc81d 100644 --- a/clients/plan-it-web/src/components/Login/Login.tsx +++ b/clients/plan-it-web/src/components/Login/Login.tsx @@ -11,8 +11,32 @@ import { Button, } from '@mantine/core'; import classes from './Login.module.css'; + + import { useState, FormEvent } from 'react'; + import { useLoginMutation } from '../../services/auth-api'; + import { setCredentials } from '../../redux/authSlice'; + import { useAppDispatch } from '../../hooks/reduxHooks'; + import { AuthResponse} from '../../types/Auth'; export function Login() { + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [login, { isLoading }] = useLoginMutation(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + try { + const userData: AuthResponse = await login({ email, password }).unwrap(); + dispatch(setCredentials({user: userData.user, token: userData.token})); + } catch (err) { + + console.error(err); + } + }; + + return ( @@ -26,15 +50,15 @@ import { </Text> <Paper withBorder shadow="md" p={30} mt={30} radius="md"> - <TextInput label="Email" placeholder="you@mantine.dev" required /> - <PasswordInput label="Password" placeholder="Your password" required mt="md" /> + <TextInput label="Email" placeholder="you@mantine.dev" value={email} onChange={(e) => setEmail(e.target.value) } required /> + <PasswordInput label="Password" placeholder="Your password" onChange={(e) => setPassword(e.target.value)} required mt="md" /> <Group justify="space-between" mt="lg"> <Checkbox label="Remember me" /> <Anchor component="button" size="sm"> Forgot password? </Anchor> </Group> - <Button fullWidth mt="xl"> + <Button fullWidth mt="xl" onClick={handleSubmit} loading={isLoading}> Sign in </Button> </Paper> diff --git a/clients/plan-it-web/src/hooks/useJwtAuth.ts b/clients/plan-it-web/src/hooks/useJwtAuth.ts new file mode 100644 index 0000000..15520dc --- /dev/null +++ b/clients/plan-it-web/src/hooks/useJwtAuth.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '../hooks/reduxHooks'; +import { setCredentials, logOut } from '../redux/authSlice'; +import { JwtInformation } from '../types/Auth'; +import { jwtDecode } from 'jwt-decode'; + +// Hook that checks if the user is authenticated based on JWT token received from the server +export const useJwtAuth = () => { + const dispatch = useAppDispatch(); + const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated); + + useEffect(() => { + const token = localStorage.getItem('token'); + + if (token) { + try { + const decodedToken: JwtInformation = jwtDecode(token); + + const userFromToken = { + id: decodedToken.jti, + firstName: decodedToken.given_name, + lastName: decodedToken.family_name, + }; + + // Sprawdzenie, czy token jest nadal ważny + if (decodedToken.exp && decodedToken.exp * 1000 > Date.now()) { + dispatch(setCredentials({ token, user: userFromToken })); + } else { + // Token wygasł + dispatch(logOut()); + } + } catch (err) { + console.error('Invalid token:', err); + dispatch(logOut()); + } + } + }, [dispatch]); + + return isAuthenticated; +}; diff --git a/clients/plan-it-web/src/redux/authSlice.ts b/clients/plan-it-web/src/redux/authSlice.ts new file mode 100644 index 0000000..94f0fd0 --- /dev/null +++ b/clients/plan-it-web/src/redux/authSlice.ts @@ -0,0 +1,38 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { User } from '../types/User'; + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; +} + +const initialState: AuthState = { + user: null, + token: localStorage.getItem('token'), + isAuthenticated: false, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setCredentials: (state, action: PayloadAction<{ user: User; token: string }>) => { + const { user, token } = action.payload; + state.user = user; + state.token = token; + state.isAuthenticated = true; + localStorage.setItem('token', token); + }, + logOut: (state) => { + state.user = null; + state.token = null; + state.isAuthenticated = false; + localStorage.removeItem('token'); + } + } +}); + +export const { setCredentials, logOut } = authSlice.actions; + +export default authSlice.reducer; diff --git a/clients/plan-it-web/src/redux/store.ts b/clients/plan-it-web/src/redux/store.ts index c493fd5..617075f 100644 --- a/clients/plan-it-web/src/redux/store.ts +++ b/clients/plan-it-web/src/redux/store.ts @@ -2,19 +2,23 @@ import { configureStore } from '@reduxjs/toolkit' // Or from '@reduxjs/toolkit/query/react' import { setupListeners } from '@reduxjs/toolkit/query' import { projectApi } from '../services/planit-api' +import authReducer from './authSlice'; import workspacesReducer from './workspacesSlice' +import { authApi } from '../services/auth-api' export const store = configureStore({ reducer: { // Add the generated reducer as a specific top-level slice + auth: authReducer, + [authApi.reducerPath]: authApi.reducer, [projectApi.reducerPath]: projectApi.reducer, workspaces: workspacesReducer, }, // Adding the api middleware enables caching, invalidation, polling, // and other useful features of `rtk-query`. middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(projectApi.middleware), + getDefaultMiddleware().concat(projectApi.middleware, authApi.middleware), }) // optional, but required for refetchOnFocus/refetchOnReconnect behaviors diff --git a/clients/plan-it-web/src/services/auth-api.ts b/clients/plan-it-web/src/services/auth-api.ts new file mode 100644 index 0000000..00eb5a5 --- /dev/null +++ b/clients/plan-it-web/src/services/auth-api.ts @@ -0,0 +1,38 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { RootState } from '../redux/store'; +import { LoginCredentials, RegisterData, AuthResponse } from '../types/Auth'; +import { User } from '../types/User'; + +const HOST = "https://localhost:5234"; + +export const authApi = createApi({ + reducerPath: 'authApi', + baseQuery: fetchBaseQuery({ + baseUrl: HOST, + prepareHeaders: (headers, { getState }) => { + const token = (getState() as RootState).auth.token; + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + return headers; + }, + }), + endpoints: (builder) => ({ + login: builder.mutation<AuthResponse, LoginCredentials>({ + query: (credentials) => ({ + url: '/auth/login', + method: 'POST', + body: credentials, + }), + }), + register: builder.mutation<AuthResponse, RegisterData>({ + query: (userData) => ({ + url: '/auth/register', + method: 'POST', + body: userData, + }), + }), + }), +}); + +export const { useLoginMutation, useRegisterMutation } = authApi; diff --git a/clients/plan-it-web/src/services/planit-api.ts b/clients/plan-it-web/src/services/planit-api.ts index 7412a21..839b7ae 100644 --- a/clients/plan-it-web/src/services/planit-api.ts +++ b/clients/plan-it-web/src/services/planit-api.ts @@ -1,10 +1,8 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' - import type { Project, ProjectTask, Workspace, WorkspaceProjects } from '../types/Project' -import { deleteWorkspace, updateWorkspace } from '../redux/workspacesSlice'; +import { User } from '../types/User'; const HOST = "https://localhost:5234"; -const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJlMGQ5MTMwMy1iNWM5LTQ1MzAtOTkxNC1kMjdjN2EwNTQ0MTUiLCJnaXZlbl9uYW1lIjoiSmFjdcWbIiwiZmFtaWx5X25hbWUiOiJCb3NhayIsImp0aSI6IjMzMjFlYTgyLTU5ZGUtNDczMS05MDMzLTllM2M4YTAwNDhkMSIsImV4cCI6MTcyNjA2NTEzNywiaXNzIjoiUGxhbkl0IiwiYXVkIjoiUGxhbkl0In0.l5Q00XDMDhCw4EQcSHMoUj-Pat7QAMhDFuy9lHWiU0g"; // Define a service using a base URL and expected endpoints export const projectApi = createApi({ @@ -12,7 +10,13 @@ export const projectApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: `${HOST}/api`, mode: 'cors', - headers: { Authorization: `Bearer ${TOKEN}`} + prepareHeaders: (headers, { getState }) => { + const token = (getState() as RootState).auth.token; + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + return headers; + }, }), endpoints: (builder) => ({ getProject: builder.query<Project, string>({ @@ -86,7 +90,11 @@ export const projectApi = createApi({ query: ({ projectId, taskId }) => ({ url: `projects/${projectId}/tasks/${taskId}`, method: 'DELETE', - })}) + })}), + // User + getUserQuery: builder.query<User, string>({ + query: (userId) => `users/${userId}`, + }), })}); // Export hooks for usage in functional components, which are diff --git a/clients/plan-it-web/src/types/Auth.ts b/clients/plan-it-web/src/types/Auth.ts new file mode 100644 index 0000000..47ec532 --- /dev/null +++ b/clients/plan-it-web/src/types/Auth.ts @@ -0,0 +1,23 @@ +import { JwtPayload } from "jwt-decode"; +import { User } from "./User"; + +export interface LoginCredentials { + email: string; + password: string; + } + + export interface RegisterData { + username: string; + email: string; + password: string; + } + + export interface AuthResponse { + user: User; + token: string; + } + + export interface JwtInformation extends JwtPayload { + given_name: string; + family_name: string; + } \ No newline at end of file diff --git a/clients/plan-it-web/src/types/User.ts b/clients/plan-it-web/src/types/User.ts new file mode 100644 index 0000000..a9b45f8 --- /dev/null +++ b/clients/plan-it-web/src/types/User.ts @@ -0,0 +1,6 @@ +export interface User { + id: string; + email: string; + firstName: string; + lastName: string; +} \ No newline at end of file diff --git a/src/PlanIt.WebApi/Controllers/UserController.cs b/src/PlanIt.WebApi/Controllers/UserController.cs index 0bfbc76..2a1a7fc 100644 --- a/src/PlanIt.WebApi/Controllers/UserController.cs +++ b/src/PlanIt.WebApi/Controllers/UserController.cs @@ -15,6 +15,8 @@ public UserController(ISender mediator) _mediator = mediator; } + // Add Get User endpoint + [HttpGet] [Route("workspaces")] public async Task<IActionResult> GetUserWorkspaces(string userId) From b4c091f0f656b69c1ba3183bb63925f9f0d7537c Mon Sep 17 00:00:00 2001 From: tomek <tomgasper01@gmail.com> Date: Wed, 11 Sep 2024 18:27:05 +0200 Subject: [PATCH 09/12] Add Register page --- clients/plan-it-web/eslint.config.js | 2 +- clients/plan-it-web/src/App.tsx | 7 +- .../src/components/Login/Login.tsx | 4 +- .../components/Register/Register.module.css | 3 + .../src/components/Register/Register.tsx | 144 ++++++++++++++++++ clients/plan-it-web/src/services/auth-api.ts | 1 - clients/plan-it-web/src/types/Auth.ts | 3 +- 7 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 clients/plan-it-web/src/components/Register/Register.module.css create mode 100644 clients/plan-it-web/src/components/Register/Register.tsx diff --git a/clients/plan-it-web/eslint.config.js b/clients/plan-it-web/eslint.config.js index a01bb47..db823ee 100644 --- a/clients/plan-it-web/eslint.config.js +++ b/clients/plan-it-web/eslint.config.js @@ -32,7 +32,7 @@ export default tseslint.config( { allowConstantExport: true }, ], "@typescript-eslint/no-unsafe-assignment": "warn", - "@typescript-eslint/no-misused-promises" : "warn", + "@typescript-eslint/no-misused-promises" : "off", "@typescript-eslint/no-unsafe-call" : "warn", ...react.configs.recommended.rules, ...react.configs['jsx-runtime'].rules, diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index 23e98b5..17a62f0 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -8,15 +8,13 @@ import { MainWindow } from './components/MainWindow/MainWindow'; import { useGetUserWorkspacesQuery } from './services/planit-api'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; -import { useAppDispatch, useAppSelector } from './hooks/reduxHooks'; +import { useAppDispatch} from './hooks/reduxHooks'; import { useEffect } from 'react'; import { setWorkspaces } from './redux/workspacesSlice'; import { WorkspaceSettings } from './components/WorkspaceSettings/WorkspaceSettings'; import { Login } from './components/Login/Login'; -import { jwtDecode } from 'jwt-decode'; -import { JwtInformation } from './types/Auth'; -import { logOut, setCredentials } from './redux/authSlice'; import { useJwtAuth } from './hooks/useJwtAuth'; +import { Register } from './components/Register/Register'; export default function App() { const dispatch = useAppDispatch(); @@ -41,6 +39,7 @@ export default function App() { {isAuthenticated && <Navbar />} <Routes> <Route path="/login" element={<Login />} /> + <Route path="/register" element={<Register />} /> <Route path="/workspaces/:workspaceId/settings" element={<WorkspaceSettings />} /> <Route path="/workspaces/:workspaceId" element={<MainWindow />} /> </Routes> diff --git a/clients/plan-it-web/src/components/Login/Login.tsx b/clients/plan-it-web/src/components/Login/Login.tsx index 5abc81d..c3068d9 100644 --- a/clients/plan-it-web/src/components/Login/Login.tsx +++ b/clients/plan-it-web/src/components/Login/Login.tsx @@ -19,7 +19,6 @@ import { import { AuthResponse} from '../../types/Auth'; export function Login() { - const [email, setEmail] = useState<string>(''); const [password, setPassword] = useState<string>(''); const [login, { isLoading }] = useLoginMutation(); @@ -36,7 +35,6 @@ import { } }; - return ( <Container style={{minWidth:'20%'}} size={600} my={'5%'}> <Title ta="center" className={classes.title}> @@ -58,7 +56,7 @@ import { Forgot password? </Anchor> </Group> - <Button fullWidth mt="xl" onClick={handleSubmit} loading={isLoading}> + <Button fullWidth mt="xl" onClick={ handleSubmit } loading={isLoading}> Sign in </Button> </Paper> diff --git a/clients/plan-it-web/src/components/Register/Register.module.css b/clients/plan-it-web/src/components/Register/Register.module.css new file mode 100644 index 0000000..cc1237d --- /dev/null +++ b/clients/plan-it-web/src/components/Register/Register.module.css @@ -0,0 +1,3 @@ +.container { + +} \ No newline at end of file diff --git a/clients/plan-it-web/src/components/Register/Register.tsx b/clients/plan-it-web/src/components/Register/Register.tsx new file mode 100644 index 0000000..da71195 --- /dev/null +++ b/clients/plan-it-web/src/components/Register/Register.tsx @@ -0,0 +1,144 @@ +import { + TextInput, + PasswordInput, + Checkbox, + Anchor, + Paper, + Title, + Text, + Container, + Group, + Button, + } from '@mantine/core'; + import classes from './Register.module.css'; + import { useState, FormEvent } from 'react'; + import { useRegisterMutation } from '../../services/auth-api'; + import { setCredentials } from '../../redux/authSlice'; + import { useAppDispatch } from '../../hooks/reduxHooks'; + import { AuthResponse } from '../../types/Auth'; + import { notifications } from '@mantine/notifications'; +import { useNavigate } from 'react-router-dom'; + + export function Register() { + const [email, setEmail] = useState<string>(''); + const [password, setPassword] = useState<string>(''); + const [confirmPassword, setConfirmPassword] = useState<string>(''); + const [firstName, setFirstName] = useState<string>(''); + const [lastName, setLastName] = useState<string>(''); + const [register, { isLoading }] = useRegisterMutation(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + + notifications.show({ + title: 'Error registering new account', + message: 'Passwords do not match', + color: 'red' + }); + return; + } + + try { + const userData: AuthResponse = await register({ + email, + password, + firstName, + lastName + }).unwrap(); + dispatch(setCredentials({user: userData.user, token: userData.token})); + + notifications.show({ + title: 'Account registered', + message: 'Your account has been successfully registered', + color: 'green' + }); + navigate('/workspace'); + } catch (error) { + const err = error as { data?: { title?: string; errors?: Record<string, string[]> } }; + + // Display more personalized error message including server validation errors + if (err.data?.title) + { + console.log(err.data) + let errorMessage = err.data.title; + if (err.data.errors) + { + for (const [value] of Object.entries(err.data.errors)) { + errorMessage += '\n' + value; + } + } + + notifications.show({ + title: 'Error registering new account', + message: errorMessage, + color: 'red' + }); + } + } + }; + + return ( + <Container style={{width:'450px'}} size={600} my={'5%'}> + <Title ta="center" className={classes.title}> + Create an account + + + Already have an account?{' '} + + Login + + + + + setFirstName(e.target.value)} + required + /> + setLastName(e.target.value)} + required + mt="md" + /> + setEmail(e.target.value)} + required + mt="md" + /> + setPassword(e.target.value)} + required + mt="md" + /> + setConfirmPassword(e.target.value)} + required + mt="md" + /> + + + + + + + ); + } \ No newline at end of file diff --git a/clients/plan-it-web/src/services/auth-api.ts b/clients/plan-it-web/src/services/auth-api.ts index 00eb5a5..3545e86 100644 --- a/clients/plan-it-web/src/services/auth-api.ts +++ b/clients/plan-it-web/src/services/auth-api.ts @@ -1,7 +1,6 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { RootState } from '../redux/store'; import { LoginCredentials, RegisterData, AuthResponse } from '../types/Auth'; -import { User } from '../types/User'; const HOST = "https://localhost:5234"; diff --git a/clients/plan-it-web/src/types/Auth.ts b/clients/plan-it-web/src/types/Auth.ts index 47ec532..b66be90 100644 --- a/clients/plan-it-web/src/types/Auth.ts +++ b/clients/plan-it-web/src/types/Auth.ts @@ -7,7 +7,8 @@ export interface LoginCredentials { } export interface RegisterData { - username: string; + firstName: string; + lastName: string; email: string; password: string; } From a99ed1baaaccdbf69894f4ffab3cc9831b3c4a9d Mon Sep 17 00:00:00 2001 From: tomek Date: Wed, 11 Sep 2024 18:33:59 +0200 Subject: [PATCH 10/12] Add Login notifications --- .../src/components/Login/Login.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/clients/plan-it-web/src/components/Login/Login.tsx b/clients/plan-it-web/src/components/Login/Login.tsx index c3068d9..0d64486 100644 --- a/clients/plan-it-web/src/components/Login/Login.tsx +++ b/clients/plan-it-web/src/components/Login/Login.tsx @@ -17,21 +17,49 @@ import { import { setCredentials } from '../../redux/authSlice'; import { useAppDispatch } from '../../hooks/reduxHooks'; import { AuthResponse} from '../../types/Auth'; +import { useNavigate } from 'react-router-dom'; +import { notifications } from '@mantine/notifications'; export function Login() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [login, { isLoading }] = useLoginMutation(); const dispatch = useAppDispatch(); + const navigate = useNavigate(); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); try { const userData: AuthResponse = await login({ email, password }).unwrap(); dispatch(setCredentials({user: userData.user, token: userData.token})); - } catch (err) { - - console.error(err); + + notifications.show({ + title: 'Login successful', + message: 'You have been successfully logged in', + color: 'green' + }); + navigate('/workspace'); + } catch (error) { + const err = error as { data?: { title?: string; errors?: Record } }; + + // Display more personalized error message including server validation errors + if (err.data?.title) + { + console.log(err.data) + let errorMessage = err.data.title; + if (err.data.errors) + { + for (const [_,value] of Object.entries(err.data.errors)) { + errorMessage += '\n' + value; + } + } + + notifications.show({ + title: 'Error logging in', + message: errorMessage, + color: 'red' + }); + } } }; From 91aad04053ee9af9cdd5c51220abea826f5ca2ae Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 09:00:14 +0200 Subject: [PATCH 11/12] Add router protected routes that automatically check if user can access the page --- clients/plan-it-web/eslint.config.js | 2 +- clients/plan-it-web/package-lock.json | 37 +++++ clients/plan-it-web/package.json | 1 + clients/plan-it-web/src/App.tsx | 9 +- .../src/components/Profile/ProfilePage.tsx | 153 ++++++++++++++++++ .../plan-it-web/src/router/ProtectedRoute.tsx | 33 ++++ .../plan-it-web/src/services/planit-api.ts | 24 ++- 7 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 clients/plan-it-web/src/components/Profile/ProfilePage.tsx create mode 100644 clients/plan-it-web/src/router/ProtectedRoute.tsx diff --git a/clients/plan-it-web/eslint.config.js b/clients/plan-it-web/eslint.config.js index db823ee..598e63a 100644 --- a/clients/plan-it-web/eslint.config.js +++ b/clients/plan-it-web/eslint.config.js @@ -31,7 +31,7 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], - "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-misused-promises" : "off", "@typescript-eslint/no-unsafe-call" : "warn", ...react.configs.recommended.rules, diff --git a/clients/plan-it-web/package-lock.json b/clients/plan-it-web/package-lock.json index f9b36b7..f52fe88 100644 --- a/clients/plan-it-web/package-lock.json +++ b/clients/plan-it-web/package-lock.json @@ -11,6 +11,7 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@mantine/core": "^7.12.2", + "@mantine/form": "^7.12.2", "@mantine/hooks": "^7.12.2", "@mantine/notifications": "^7.12.2", "@reduxjs/toolkit": "^2.2.7", @@ -662,6 +663,19 @@ "react-dom": "^18.2.0" } }, + "node_modules/@mantine/form": { + "version": "7.12.2", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.12.2.tgz", + "integrity": "sha512-MknzDN5F7u/V24wVrL5VIXNvE7/6NMt40K6w3p7wbKFZiLhdh/tDWdMcRN7PkkWF1j2+eoVCBAOCL74U3BzNag==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/@mantine/hooks": { "version": "7.12.2", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.12.2.tgz", @@ -3146,6 +3160,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5038,6 +5061,15 @@ "type-fest": "^4.12.0" } }, + "@mantine/form": { + "version": "7.12.2", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.12.2.tgz", + "integrity": "sha512-MknzDN5F7u/V24wVrL5VIXNvE7/6NMt40K6w3p7wbKFZiLhdh/tDWdMcRN7PkkWF1j2+eoVCBAOCL74U3BzNag==", + "requires": { + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" + } + }, "@mantine/hooks": { "version": "7.12.2", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.12.2.tgz", @@ -6650,6 +6682,11 @@ "json-buffer": "3.0.1" } }, + "klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==" + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/clients/plan-it-web/package.json b/clients/plan-it-web/package.json index 62d5c87..7ed7f0b 100644 --- a/clients/plan-it-web/package.json +++ b/clients/plan-it-web/package.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@mantine/core": "^7.12.2", + "@mantine/form": "^7.12.2", "@mantine/hooks": "^7.12.2", "@mantine/notifications": "^7.12.2", "@reduxjs/toolkit": "^2.2.7", diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index 17a62f0..a6fb113 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -15,13 +15,13 @@ import { WorkspaceSettings } from './components/WorkspaceSettings/WorkspaceSetti import { Login } from './components/Login/Login'; import { useJwtAuth } from './hooks/useJwtAuth'; import { Register } from './components/Register/Register'; +import { ProfilePage } from './components/Profile/ProfilePage'; +import { ProtectedRoute } from './router/ProtectedRoute'; export default function App() { const dispatch = useAppDispatch(); const isAuthenticated = useJwtAuth(); - console.log(isAuthenticated); - const { data, isLoading, error } = useGetUserWorkspacesQuery('e0d91303-b5c9-4530-9914-d27c7a054415'); useEffect(() => { @@ -40,8 +40,9 @@ export default function App() { } /> } /> - } /> - } /> + } /> + } /> + } /> diff --git a/clients/plan-it-web/src/components/Profile/ProfilePage.tsx b/clients/plan-it-web/src/components/Profile/ProfilePage.tsx new file mode 100644 index 0000000..455814d --- /dev/null +++ b/clients/plan-it-web/src/components/Profile/ProfilePage.tsx @@ -0,0 +1,153 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, + Title, + TextInput, + PasswordInput, + Button, + Group, + Box, + Avatar, + FileInput, +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useGetUserQuery, useUpdateUserMutation, useUploadAvatarMutation } from '../../services/planit-api'; +import { showNotification } from '@mantine/notifications'; +import { useAppSelector } from '../../hooks/reduxHooks'; +import { useNavigate } from 'react-router-dom'; + +interface ProfileFormValues { + firstName: string; + lastName: string; + password: string; + confirmPassword: string; +} + +export function ProfilePage() { + const userFromToken = useAppSelector(state => state.auth.user); + const { data: currentUser } = useGetUserQuery(userFromToken?.id ?? ""); + const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation(); + const [uploadAvatar, { isLoading: isUploading }] = useUploadAvatarMutation(); + const [avatarFile, setAvatarFile] = useState(null); + + const form = useForm({ + initialValues: { + firstName: currentUser?.firstName ?? '', + lastName: currentUser?.lastName ?? '', + password: '', + confirmPassword: '', + }, + validate: { + firstName: (value : string) => (value.length < 2 ? 'First name must have at least 2 characters' : null), + lastName: (value: string) => (value.length < 2 ? 'Last name must have at least 2 characters' : null), + password: (value : string) => ( + value.length > 0 && value.length < 8 + ? 'Password must be at least 8 characters long' + : null + ), + confirmPassword: (value: string, values: ProfileFormValues) => + value !== values.password ? 'Passwords do not match' : null, + }, + }); + + useEffect(() => { + if (currentUser) { + form.setValues({ + firstName: currentUser.firstName || '', + lastName: currentUser.lastName || '', + }); + } + }, [currentUser, form]); + + const handleSubmit = async (values: ProfileFormValues) => { + if (!currentUser) { + showNotification({ + title: 'Error', + message: 'User not found', + color: 'red', + }); + return; + } + + try { + await updateUser({ + userId: currentUser.id, + firstName: values.firstName, + lastName: values.lastName, + ...(values.password ? { password: values.password } : {}), + }).unwrap(); + + if (avatarFile) { + const formData = new FormData(); + formData.append('avatar', avatarFile); + await uploadAvatar({ userId: currentUser.id, avatar: formData }).unwrap(); + } + + showNotification({ + title: 'Success', + message: 'Profile updated successfully', + color: 'green', + }); + } catch (error) { + showNotification({ + title: 'Error', + message: 'Failed to update profile', + color: 'red', + }); + } + }; + + return ( + + Edit Profile + + + + + + + + +
+ + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/clients/plan-it-web/src/router/ProtectedRoute.tsx b/clients/plan-it-web/src/router/ProtectedRoute.tsx new file mode 100644 index 0000000..69eec19 --- /dev/null +++ b/clients/plan-it-web/src/router/ProtectedRoute.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAppSelector } from '../hooks/reduxHooks'; +import { showNotification } from '@mantine/notifications'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export const ProtectedRoute: React.FC = ({ children }) => { + const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated); + const location = useLocation(); + + React.useEffect(() => { + if (!isAuthenticated) { + showNotification({ + title: 'Error', + message: 'You must be logged in to view this page', + color: 'red', + }); + } + }, [isAuthenticated]); + + if (!isAuthenticated) { + // Redirect them to the /login page, but save the current location they were + // trying to go to when they were redirected. This allows us to send them + // along to that page after they login, which is a nicer user experience + // than dropping them off on the home page. + return ; + } + + return <>{children}; +}; diff --git a/clients/plan-it-web/src/services/planit-api.ts b/clients/plan-it-web/src/services/planit-api.ts index 839b7ae..7687ce2 100644 --- a/clients/plan-it-web/src/services/planit-api.ts +++ b/clients/plan-it-web/src/services/planit-api.ts @@ -92,10 +92,25 @@ export const projectApi = createApi({ method: 'DELETE', })}), // User - getUserQuery: builder.query({ + getUser: builder.query({ query: (userId) => `users/${userId}`, }), -})}); + updateUser: builder.mutation & { userId: string }>({ + query: ({ userId, ...patch }) => ({ + url: `/users/${userId}`, + method: 'PATCH', + body: patch, + }), + }), + uploadAvatar: builder.mutation<{ avatarUrl: string }, { userId: string, avatar: FormData }>({ + query: ({ userId, avatar }) => ({ + url: `/users/${userId}/avatar`, + method: 'POST', + body: avatar, + }), + }), + }), +}); // Export hooks for usage in functional components, which are // auto-generated based on the defined endpoints @@ -113,5 +128,8 @@ export const { useCreateProjectTaskMutation, useDeleteProjectTaskMutation, useUpdateProjectTaskMutation, - useGetProjectTasksQuery + useGetProjectTasksQuery, + useGetUserQuery, + useUpdateUserMutation, + useUploadAvatarMutation } = projectApi; \ No newline at end of file From ef9c2957e085c0804481275fe958d43f1a8da9ab Mon Sep 17 00:00:00 2001 From: tomek Date: Thu, 12 Sep 2024 10:09:44 +0200 Subject: [PATCH 12/12] Stylize Profile Page UI and small cleanup for auth components --- clients/plan-it-web/src/App.tsx | 7 ++++++- .../plan-it-web/src/components/Login/Login.tsx | 2 +- .../plan-it-web/src/components/Navbar/Navbar.tsx | 9 +++++++-- .../src/components/Profile/ProfilePage.tsx | 16 +++++++++------- .../src/components/UserButton/UserButton.tsx | 8 ++++++-- clients/plan-it-web/src/types/User.ts | 1 + 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/clients/plan-it-web/src/App.tsx b/clients/plan-it-web/src/App.tsx index a6fb113..f88d228 100644 --- a/clients/plan-it-web/src/App.tsx +++ b/clients/plan-it-web/src/App.tsx @@ -17,6 +17,7 @@ import { useJwtAuth } from './hooks/useJwtAuth'; import { Register } from './components/Register/Register'; import { ProfilePage } from './components/Profile/ProfilePage'; import { ProtectedRoute } from './router/ProtectedRoute'; +import { Flex, Loader } from '@mantine/core'; export default function App() { const dispatch = useAppDispatch(); @@ -30,7 +31,11 @@ export default function App() { } },[data, dispatch]); - if (isLoading) return
Loading...
; + if (isLoading) + { + return + } + if (error) return
Error occurred while fetching workspaces
; return ( diff --git a/clients/plan-it-web/src/components/Login/Login.tsx b/clients/plan-it-web/src/components/Login/Login.tsx index 0d64486..ad8c320 100644 --- a/clients/plan-it-web/src/components/Login/Login.tsx +++ b/clients/plan-it-web/src/components/Login/Login.tsx @@ -76,7 +76,7 @@ import { notifications } from '@mantine/notifications'; - setEmail(e.target.value) } required /> + setEmail(e.target.value) } required /> setPassword(e.target.value)} required mt="md" /> diff --git a/clients/plan-it-web/src/components/Navbar/Navbar.tsx b/clients/plan-it-web/src/components/Navbar/Navbar.tsx index 02c321a..e023072 100644 --- a/clients/plan-it-web/src/components/Navbar/Navbar.tsx +++ b/clients/plan-it-web/src/components/Navbar/Navbar.tsx @@ -15,7 +15,7 @@ import { import { IconBulb, IconUser, IconCheckbox, IconSearch, IconPlus} from '@tabler/icons-react'; import { UserButton } from '../UserButton/UserButton'; import classes from './Navbar.module.css'; -import { NavLink } from 'react-router-dom'; +import { NavLink, useNavigate } from 'react-router-dom'; import { useCreateWorkspaceMutation } from '../../services/planit-api'; import { useAppDispatch, useAppSelector } from '../../hooks/reduxHooks'; import { addWorkspace } from '../../redux/workspacesSlice'; @@ -31,6 +31,7 @@ import { addWorkspace } from '../../redux/workspacesSlice'; export function Navbar() { const dispatch = useAppDispatch(); + const navigate = useNavigate(); const workspaces = useAppSelector( state => state.workspaces.workspaces); const [ createWorkspace ]= useCreateWorkspaceMutation(); @@ -46,6 +47,10 @@ import { addWorkspace } from '../../redux/workspacesSlice'; dispatch(addWorkspace(newWorkspace.data)); } + const handleUserButtonClick = () => { + navigate('/profile'); + } + const mainLinks = links.map((link) => (
@@ -77,7 +82,7 @@ import { addWorkspace } from '../../redux/workspacesSlice'; return (