diff --git a/src/backend/server/src/Todo.Api/Controllers/ApiController.cs b/src/backend/server/src/Todo.Api/Controllers/ApiController.cs index 4e63c08..e43a97d 100644 --- a/src/backend/server/src/Todo.Api/Controllers/ApiController.cs +++ b/src/backend/server/src/Todo.Api/Controllers/ApiController.cs @@ -7,5 +7,5 @@ namespace Todo.Api.Controllers; [ApiController] public class ApiController : ControllerBase { - public IMediator Mediator => HttpContext.RequestServices.GetRequiredService(); + protected IMediator Mediator => HttpContext.RequestServices.GetRequiredService(); } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Api/Controllers/TodosController.cs b/src/backend/server/src/Todo.Api/Controllers/TodosController.cs index 546ee8e..ef083cc 100644 --- a/src/backend/server/src/Todo.Api/Controllers/TodosController.cs +++ b/src/backend/server/src/Todo.Api/Controllers/TodosController.cs @@ -47,8 +47,33 @@ public class TodosController : ApiController public async Task>> GetTodos() => Ok(await _todoRepository.GetTodosAsync()); - [HttpGet("not-done")] - public async Task>> - GetNotDoneTodos() - => Ok(await _todoRepository.GetNotDoneTodos()); + [HttpPut("{todoId}")] + public async Task ReplaceTodo([FromRoute] string todoId, [FromBody] ReplaceTodoRequest request) + { + await Mediator.Send(request.To(todoId)); + return NoContent(); + } + + public record ReplaceTodoRequest + { + [Required] + [JsonPropertyName("title")] + public string Title { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("project")] + public string? Project { get; init; } + + [JsonPropertyName("status")] + public bool Status { get; init; } + + internal ReplaceTodoCommand To(string id) => new( + id, + Title, + Project, + Description, + Status); + } } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs b/src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs index 09a3b17..8b3241b 100644 --- a/src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs +++ b/src/backend/server/src/Todo.Api/Publishers/TodoPublisher.cs @@ -27,8 +27,9 @@ public class TodoPublisher : ITodoPublisher _logger = logger; } - public async Task Publish( - string todoId, + public async Task Publish( + string eventType, + T message, CancellationToken cancellationToken) { var userId = _currentUserService.GetUserId() ?? @@ -36,14 +37,33 @@ public class TodoPublisher : ITodoPublisher var connections = await _userConnectionStore.GetConnectionsAsync(userId); - await _hubContext - .Clients - .Clients(connections) - .SendAsync( - "todoCreated", - todoId, - cancellationToken); + switch (eventType) + { + case "todoCreated": + await _hubContext + .Clients + .Clients(connections) + .SendAsync( + "todoCreated", + message, + cancellationToken); + break; - _logger.LogInformation("todo created {TodoId}", todoId); + case "todoUpdated": + await _hubContext + .Clients + .Clients(connections) + .SendAsync( + "todoUpdated", + message, + cancellationToken); + break; + } + + + _logger.LogInformation( + "todo event: {EventType} with message: {Message}", + eventType, + message); } } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Application/Commands/Todo/ReplaceTodoCommand.cs b/src/backend/server/src/Todo.Core/Application/Commands/Todo/ReplaceTodoCommand.cs new file mode 100644 index 0000000..56261dc --- /dev/null +++ b/src/backend/server/src/Todo.Core/Application/Commands/Todo/ReplaceTodoCommand.cs @@ -0,0 +1,55 @@ +using System.Threading; +using MediatR; +using Todo.Core.Application.Notifications.Todo; +using Todo.Core.Interfaces.Persistence; +using Todo.Core.Interfaces.User; + +namespace Todo.Core.Application.Commands.Todo; + +public record ReplaceTodoCommand( + string Id, + string Title, + string Project, + string Description, + bool Status) : IRequest +{ + internal class Handler : IRequestHandler + { + private readonly ICurrentUserService _currentUserService; + private readonly ITodoRepository _todoRepository; + private readonly IMediator _mediator; + + public Handler( + ICurrentUserService currentUserService, + ITodoRepository todoRepository, + IMediator mediator) + { + _currentUserService = currentUserService; + _todoRepository = todoRepository; + _mediator = mediator; + } + + public async Task Handle(ReplaceTodoCommand request, CancellationToken cancellationToken) + { + var userId = _currentUserService.GetUserId(); + if (userId is null) + throw new InvalidOperationException("User was not found"); + //TODO: Make sure the author actually owns the todo + await _todoRepository.UpdateTodoAsync(request.To(userId)); + + await _mediator.Publish(new TodoUpdated(request.Id), cancellationToken); + + return Unit.Value; + } + } + + private Entities.Todo To(string authorId) => new() + { + Id = Id, + Description = Description, + Project = Project, + Status = Status, + Title = Title, + AuthorId = authorId + }; +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoCreated.cs b/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoCreated.cs index 692adc9..777ff9e 100644 --- a/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoCreated.cs +++ b/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoCreated.cs @@ -20,6 +20,7 @@ public record TodoCreated( CancellationToken cancellationToken) { await _todoPublisher.Publish( + "todoCreated", JsonSerializer.Serialize(notification), cancellationToken); } diff --git a/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoUpdated.cs b/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoUpdated.cs new file mode 100644 index 0000000..74503b1 --- /dev/null +++ b/src/backend/server/src/Todo.Core/Application/Notifications/Todo/TodoUpdated.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using MediatR; +using Todo.Core.Interfaces.Publisher; + +namespace Todo.Core.Application.Notifications.Todo; + +public record TodoUpdated( + [property: JsonPropertyName("todoId")] string TodoId) : INotification +{ + internal class Handler : INotificationHandler + { + private readonly ITodoPublisher _todoPublisher; + + public Handler(ITodoPublisher todoPublisher) => _todoPublisher = todoPublisher; + + public async Task Handle( + TodoUpdated notification, + CancellationToken cancellationToken) + { + await _todoPublisher.Publish( + "todoUpdated", + JsonSerializer.Serialize(notification), + cancellationToken); + } + } +} diff --git a/src/backend/server/src/Todo.Core/Application/Publisher/ITodoPublisher.cs b/src/backend/server/src/Todo.Core/Application/Publisher/ITodoPublisher.cs index dbe0157..1f4fb1b 100644 --- a/src/backend/server/src/Todo.Core/Application/Publisher/ITodoPublisher.cs +++ b/src/backend/server/src/Todo.Core/Application/Publisher/ITodoPublisher.cs @@ -4,5 +4,5 @@ namespace Todo.Core.Interfaces.Publisher; public interface ITodoPublisher { - Task Publish(string todoId, CancellationToken cancellationToken = new()); + Task Publish(string eventType, T message, CancellationToken cancellationToken = new()); } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Application/Queries/Todos/TodoViewModel.cs b/src/backend/server/src/Todo.Core/Application/Queries/Todos/TodoViewModel.cs index 40c0761..cd5e448 100644 --- a/src/backend/server/src/Todo.Core/Application/Queries/Todos/TodoViewModel.cs +++ b/src/backend/server/src/Todo.Core/Application/Queries/Todos/TodoViewModel.cs @@ -16,4 +16,4 @@ public record TodoViewModel( t.AuthorId, t.Project, t.Description); -}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Infrastructure/UserConnectionStore/InMemoryUserConnectionStore.cs b/src/backend/server/src/Todo.Infrastructure/UserConnectionStore/InMemoryUserConnectionStore.cs index ca4bae7..9d87c2e 100644 --- a/src/backend/server/src/Todo.Infrastructure/UserConnectionStore/InMemoryUserConnectionStore.cs +++ b/src/backend/server/src/Todo.Infrastructure/UserConnectionStore/InMemoryUserConnectionStore.cs @@ -39,7 +39,14 @@ internal class InMemoryUserConnectionStore : IUserConnectionStore ConnectedUsers.TryGetValue(userId, out var existingUserConnectionIds); // remove the connection id from the List - existingUserConnectionIds?.Remove(connectionId); + try + { + existingUserConnectionIds?.Remove(connectionId); + } + catch (Exception e) + { + Console.WriteLine(e); + } // If there are no connection ids in the List, delete the user from the global cache (ConnectedUsers). if (existingUserConnectionIds?.Count == 0) diff --git a/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs b/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs index 68dd2b2..3dc8211 100644 --- a/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs +++ b/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using MongoDB.Driver; using Todo.Core.Interfaces.Persistence; using Todo.Persistence.Mongo.Repositories.Dtos; @@ -35,7 +36,8 @@ public class TodoRepository : ITodoRepository Title = todo.Title, Status = false, Project = todo.ProjectName, - Description = todo.Description + Description = todo.Description, + AuthorId = todo.AuthorId }; } @@ -52,7 +54,8 @@ public class TodoRepository : ITodoRepository Title = t.Title, Status = t.Status, Project = t.ProjectName, - Description = t.Description + Description = t.Description, + AuthorId = t.AuthorId }); } @@ -84,7 +87,8 @@ public class TodoRepository : ITodoRepository Project = todo.ProjectName, Status = todo.Status, Title = todo.Title, - Description = todo.Description + Description = todo.Description, + AuthorId = todo.AuthorId }; } diff --git a/src/client/package.json b/src/client/package.json index 4853736..7a07505 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -9,17 +9,20 @@ }, "dependencies": { "@microsoft/signalr": "^6.0.0", + "@reduxjs/toolkit": "^1.6.2", "@tippyjs/react": "^4.2.6", "axios": "^0.24.0", "next": "12.0.3", "next-pwa": "^5.4.0", "react": "17.0.2", "react-dom": "17.0.2", + "react-redux": "^7.2.6", "react-textarea-autosize": "^8.3.3", "tailwindcss": "^2.2.19" }, "devDependencies": { "@types/react": "^17.0.34", + "@types/react-redux": "^7.1.20", "autoprefixer": "^10.4.0", "cssnano": "^5.0.10", "eslint": "7", diff --git a/src/client/src/boundary/todo/todoApi.ts b/src/client/src/boundary/todo/todoApi.ts deleted file mode 100644 index 1c6d1b7..0000000 --- a/src/client/src/boundary/todo/todoApi.ts +++ /dev/null @@ -1,31 +0,0 @@ -import axios from "axios"; -import { Todo } from "@src/core/entities/todo"; - -const getBaseUrl = () => - process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5000"; - -const createBaseClient = () => - axios.create({ - baseURL: getBaseUrl(), - withCredentials: true, - }); - -interface CreateTodoRequest { - title: string; - description?: string; - project?: string; -} - -export const createTodoAsync = async ( - createTodo: CreateTodoRequest -): Promise => - await createBaseClient().post( - "/api/todos", - createTodo - ); - -interface GetTodoByIdResponse extends Todo {} -export const getTodoByIdAsync = async (todoId: string) => - await createBaseClient().get( - `/api/todos/${todoId}` - ); diff --git a/src/client/src/components/common/buttons/primaryButton.tsx b/src/client/src/components/common/buttons/primaryButton.tsx index 9925a07..5546f89 100644 --- a/src/client/src/components/common/buttons/primaryButton.tsx +++ b/src/client/src/components/common/buttons/primaryButton.tsx @@ -10,7 +10,7 @@ export const PrimaryButton: FC> = ( tabIndex={2} disabled={props.disabled} className={ - "base-button bg-accent-500 disabled:bg-accent-800 active:bg-accent-400 border-none text-white " + + "base-button bg-accent-500 disabled:bg-accent-800 dark:bg-accent-500 active:bg-accent-400 border-none text-white " + props.className } > diff --git a/src/client/src/components/todos/todoItem.tsx b/src/client/src/components/todos/todoItem.tsx index bea6ab1..2bd2af3 100644 --- a/src/client/src/components/todos/todoItem.tsx +++ b/src/client/src/components/todos/todoItem.tsx @@ -3,6 +3,7 @@ import { FC, useState } from "react"; import { TodoCheckmark } from "@src/components/todos/todoCheckmark"; import Tippy from "@tippyjs/react"; import Link from "next/link"; +import { todosApi } from "@src/infrastructure/apis/todosApi"; interface TodoItemProps { todo: Todo; @@ -14,10 +15,15 @@ export const TodoItem: FC = (props) => { const [isHovering, setIsHovering] = useState(false); const [menuIsOpen, setMenuIsOpen] = useState(false); + const prefetchTodo = todosApi.usePrefetch("getTodoById"); + return (
setIsHovering(true)} + onMouseEnter={() => { + prefetchTodo(props.todo.id); + setIsHovering(true); + }} onMouseLeave={() => setIsHovering(false)} onFocus={() => setIsHovering(true)} > @@ -32,7 +38,7 @@ export const TodoItem: FC = (props) => {
{props.todo.description && ( -
+
)}
{props.displayProject && props.todo.project && ( diff --git a/src/client/src/components/todos/todoList.tsx b/src/client/src/components/todos/todoList.tsx index 8f0042b..a67288f 100644 --- a/src/client/src/components/todos/todoList.tsx +++ b/src/client/src/components/todos/todoList.tsx @@ -1,7 +1,7 @@ import { Todo } from "@src/core/entities/todo"; import { TodoItem } from "@src/components/todos/todoItem"; import { AddTodo } from "@src/components/todos/addTodo"; -import { useUpdateTodoState } from "@src/presentation/hooks/socketHooks"; +import { useUpdateTodo } from "@src/presentation/hooks/socketHooks"; export const TodoList = (props: { todos: Todo[]; @@ -9,19 +9,23 @@ export const TodoList = (props: { hideProject: boolean; project: string; }) => { - const { updateTodoState } = useUpdateTodoState(); + const { updateTodo } = useUpdateTodo(); return ( <>
    {props.todos - .filter((t) => t.status == false) + .filter((t) => { + if (!props.hideDone) return true; + + return t.status == false; + }) .map((t, i) => (
  • { - updateTodoState(todo.id, todo.status); + updateTodo(todo); }} displayProject={!props.hideProject} /> diff --git a/src/client/src/core/actions/utility/groupByProjects.ts b/src/client/src/core/actions/utility/groupByProjects.ts new file mode 100644 index 0000000..749969f --- /dev/null +++ b/src/client/src/core/actions/utility/groupByProjects.ts @@ -0,0 +1,16 @@ +export const groupByProjects = (arr) => { + if (!arr) { + return {}; + } + const criteria = "project"; + + return arr.reduce(function (acc, currentValue) { + const currentValueElement = currentValue[criteria] || "inbox"; + + if (!acc[currentValueElement]) { + acc[currentValueElement] = []; + } + acc[currentValueElement].push(currentValue); + return acc; + }, {}); +}; diff --git a/src/client/src/infrastructure/apis/todosApi.ts b/src/client/src/infrastructure/apis/todosApi.ts new file mode 100644 index 0000000..0a76b8e --- /dev/null +++ b/src/client/src/infrastructure/apis/todosApi.ts @@ -0,0 +1,63 @@ +import { createApi } from "@reduxjs/toolkit/query/react"; +import { baseQueryWithReauth } from "@src/infrastructure/apis/utilities"; +import { Todo } from "@src/core/entities/todo"; + +export const todosApi = createApi({ + reducerPath: "api", + tagTypes: ["todo"], + baseQuery: baseQueryWithReauth, + endpoints: (build) => ({ + getTodoById: build.query({ + query: (id) => `api/todos/${id}`, + providesTags: (result, error, id) => [{ type: "todo", id }], + }), + + getAllTodos: build.query({ + query: () => ({ url: `api/todos` }), + providesTags: (result) => + result + ? [ + ...result.map(({ id }) => ({ type: "todo" as const, id })), + { type: "todo", id: "LIST" }, + ] + : [], + }), + + createTodo: build.mutation>({ + query(body) { + return { + url: "/api/todos", + method: "POST", + body, + }; + }, + invalidatesTags: (result, error, id) => [{ type: "todo", id: "LIST" }], + }), + + replaceTodo: build.mutation>({ + query(data) { + const { id, ...body } = data; + return { + url: `/api/todos/${id}`, + method: "PUT", + body, + }; + }, + invalidatesTags: (result, error, { id }) => [{ type: "todo", id }], + async onQueryStarted({ id, ...body }, { dispatch, queryFulfilled }) { + const replaceResult = dispatch( + todosApi.util.updateQueryData("getTodoById", id, (draft) => { + Object.assign(draft, body); + }) + ); + + try { + await queryFulfilled; + } catch { + replaceResult.undo(); + dispatch(todosApi.util.invalidateTags([{ type: "todo", id }])); + } + }, + }), + }), +}); diff --git a/src/client/src/infrastructure/apis/utilities.ts b/src/client/src/infrastructure/apis/utilities.ts new file mode 100644 index 0000000..273668a --- /dev/null +++ b/src/client/src/infrastructure/apis/utilities.ts @@ -0,0 +1,27 @@ +import { + BaseQueryFn, + FetchArgs, + fetchBaseQuery, + FetchBaseQueryError, +} from "@reduxjs/toolkit/query/react"; + +export const getApiBaseUrl = () => + process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5000"; + +const baseQuery = fetchBaseQuery({ + baseUrl: getApiBaseUrl(), + credentials: "include", +}); +export const baseQueryWithReauth: BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError +> = async (args, api, extraOptions) => { + let result = await baseQuery(args, api, extraOptions); + if (result.error && result.error.status === 302) { + window.location.href = `${getApiBaseUrl()}/api/auth/login?returnUrl=${ + window.location.href + }`; + } + return result; +}; diff --git a/src/client/src/infrastructure/state/todo.ts b/src/client/src/infrastructure/state/todo.ts new file mode 100644 index 0000000..25443ed --- /dev/null +++ b/src/client/src/infrastructure/state/todo.ts @@ -0,0 +1,23 @@ +import { createEntityAdapter, createSlice } from "@reduxjs/toolkit"; +import { Todo } from "@src/core/entities/todo"; +import { AppState } from "@src/infrastructure/store"; + +export const todosAdapter = createEntityAdapter({ + selectId: (todo) => todo.id, + sortComparer: (a, b) => a.title.localeCompare(b.title), +}); + +export const todosSlice = createSlice({ + name: "todo", + initialState: todosAdapter.getInitialState(), + reducers: { + todoAdded: todosAdapter.addOne, + todosReceived(state, action) { + todosAdapter.setAll(state, action.payload.todos); + }, + }, +}); + +export const todosSelectors = todosAdapter.getSelectors( + (state) => state.todos +); diff --git a/src/client/src/infrastructure/store/hooks.ts b/src/client/src/infrastructure/store/hooks.ts new file mode 100644 index 0000000..da9c8b4 --- /dev/null +++ b/src/client/src/infrastructure/store/hooks.ts @@ -0,0 +1,6 @@ +import { AppDispatch, AppState } from "@src/infrastructure/store/store"; +import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; + +export const useAppDispatch = () => useDispatch(); + +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/client/src/infrastructure/store/index.ts b/src/client/src/infrastructure/store/index.ts new file mode 100644 index 0000000..d8aee62 --- /dev/null +++ b/src/client/src/infrastructure/store/index.ts @@ -0,0 +1,4 @@ +export * from "./hooks"; +export * from "./store"; + +export { default } from "./store"; diff --git a/src/client/src/infrastructure/store/store.ts b/src/client/src/infrastructure/store/store.ts new file mode 100644 index 0000000..9bc210c --- /dev/null +++ b/src/client/src/infrastructure/store/store.ts @@ -0,0 +1,30 @@ +import { Action, configureStore, ThunkAction } from "@reduxjs/toolkit"; +import { todosSlice } from "@src/infrastructure/state/todo"; +import { todosApi } from "@src/infrastructure/apis/todosApi"; +import { setupListeners } from "@reduxjs/toolkit/query"; + +export function makeStore() { + return configureStore({ + reducer: { + todos: todosSlice.reducer, + [todosApi.reducerPath]: todosApi.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(todosApi.middleware), + }); +} + +const store = makeStore(); + +setupListeners(store.dispatch); + +export type AppState = ReturnType; +export type AppDispatch = typeof store.dispatch; +export type AppThunk = ThunkAction< + ReturnType, + AppState, + unknown, + Action +>; + +export default store; diff --git a/src/client/src/pages/_app.tsx b/src/client/src/pages/_app.tsx index 9250469..71b36b9 100644 --- a/src/client/src/pages/_app.tsx +++ b/src/client/src/pages/_app.tsx @@ -1,8 +1,10 @@ -import { AppProps } from "next/app"; +import type { AppProps } from "next/app"; import Head from "next/head"; import "@src/styles/tailwind.css"; import SocketProvider from "@src/presentation/contexts/SocketContext"; +import store from "@src/infrastructure/store"; +import { Provider } from "react-redux"; const MyApp = ({ Component, pageProps }: AppProps) => ( <> @@ -34,9 +36,11 @@ const MyApp = ({ Component, pageProps }: AppProps) => ( - - - + + + + + ); export default MyApp; diff --git a/src/client/src/pages/index.tsx b/src/client/src/pages/index.tsx index 1156445..2d981c7 100644 --- a/src/client/src/pages/index.tsx +++ b/src/client/src/pages/index.tsx @@ -1,40 +1,33 @@ import { PageHeading } from "@src/components/common/headings/pageHeading"; import { TodoList } from "@src/components/todos"; -import { useSelectInboxTodos } from "@src/presentation/hooks/socketHooks"; import { ProjectsHeading } from "@src/components/common/headings/projectsHeading"; +import { groupByProjects } from "@src/core/actions/utility/groupByProjects"; +import { todosApi } from "@src/infrastructure/apis/todosApi"; import { AddTodo } from "@src/components/todos/addTodo"; -const groupByProjects = (arr) => { - if (!arr) { - return {}; - } - const criteria = "project"; - - return arr.reduce(function (acc, currentValue) { - const currentValueElement = currentValue[criteria] || "inbox"; - - if (!acc[currentValueElement]) { - acc[currentValueElement] = []; - } - acc[currentValueElement].push(currentValue); - return acc; - }, {}); -}; - const HomePage = () => { - const { todos, loading } = useSelectInboxTodos(); - const groupedTodos = groupByProjects(todos); + const { data, isLoading } = todosApi.useGetAllTodosQuery(); + + if (isLoading) { + return "loading..."; + } + + const groupedTodos = groupByProjects(data); const sections = Object.keys(groupedTodos) .filter((s) => s !== "inbox") .sort(); - return (
    {groupedTodos["inbox"] && (
    - +
    )} {sections && @@ -49,7 +42,7 @@ const HomePage = () => { />
))} - {!loading && todos && todos.length === 0 && ( + {data && data.length === 0 && (
diff --git a/src/client/src/pages/todos/[todoId]/index.tsx b/src/client/src/pages/todos/[todoId]/index.tsx index acd721d..8824db3 100644 --- a/src/client/src/pages/todos/[todoId]/index.tsx +++ b/src/client/src/pages/todos/[todoId]/index.tsx @@ -98,7 +98,11 @@ const TodoDetailsPage = () => { const router = useRouter(); const { todoId } = router.query as { todoId: string }; - const { todo } = useSelectTodo(todoId); + const { todo, isLoading } = useSelectTodo(todoId); + + if (isLoading) { + return "loading..."; + } return (
diff --git a/src/client/src/presentation/contexts/SocketContext.tsx b/src/client/src/presentation/contexts/SocketContext.tsx index 422e772..a3e564d 100644 --- a/src/client/src/presentation/contexts/SocketContext.tsx +++ b/src/client/src/presentation/contexts/SocketContext.tsx @@ -1,86 +1,40 @@ -import { createContext, FC, useEffect, useState } from "react"; +import { FC, useEffect, useState } from "react"; import { HubConnection, HubConnectionBuilder, LogLevel, } from "@microsoft/signalr"; -import { asTodo, StatusState, Todo } from "@src/core/entities/todo"; -import { getTodoByIdAsync } from "@src/boundary/todo/todoApi"; - -interface SocketContextProps { - conn: HubConnection; - todos: Todo[]; - inboxTodos: Todo[]; - getTodos: () => void; - getInboxTodos: () => void; - createTodo: (todoName: string, project: string, description: string) => void; - updateTodo: (todoId: string, todoStatus: StatusState) => void; - getTodoById(todoId: string): void; - replaceTodo(todo: Todo): void; -} - -export const SocketContext = createContext({ - conn: void 0, - todos: [], - inboxTodos: [], - getTodos: () => {}, - getInboxTodos: () => {}, - createTodo: (todoName, project, description) => {}, - updateTodo: (todoId, todoStatus) => {}, - getTodoById(todoId: string) {}, - replaceTodo(todo: Todo) {}, -}); +import { getApiBaseUrl } from "@src/infrastructure/apis/utilities"; +import { useAppDispatch } from "@src/infrastructure/store"; +import { todosApi } from "@src/infrastructure/apis/todosApi"; export const SocketProvider: FC = (props) => { const [conn, setConn] = useState(); - const [todos, setTodos] = useState([]); - const [inboxTodos, setInboxTodos] = useState([]); + const dispatch = useAppDispatch(); useEffect(() => { - const serverUrl = - process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:5000"; - const connection = new HubConnectionBuilder() - .withUrl(`${serverUrl}/hubs/todo`, { + .withUrl(`${getApiBaseUrl()}/hubs/todo`, { withCredentials: true, }) .withAutomaticReconnect() .configureLogging(LogLevel.Information) .build(); - connection.on("getTodos", (rawTodos) => { - const newTodos = JSON.parse(rawTodos) as Todo[]; - const validatedTodos = newTodos.map(asTodo); - setTodos(validatedTodos); - }); - connection.on("todoCreated", (todoCreated) => { const { todoId } = JSON.parse(todoCreated) as { todoId: string }; - - getTodoByIdAsync(todoId).then((response) => { - setTodos([...todos.filter((t) => t.id !== response.id), response]); - setInboxTodos([ - ...inboxTodos.filter((t) => t.id !== response.id), - response, - ]); - }); + if (todoId) { + dispatch(todosApi.endpoints.getTodoById.initiate(todoId)); + dispatch(todosApi.util.invalidateTags([{ type: "todo", id: "LIST" }])); + } }); - connection.on("getInboxTodos", (rawTodos) => { - const newTodos = JSON.parse(rawTodos) as Todo[]; - const validatedTodos = newTodos - .map(asTodo) - .filter((t) => t.status == false); - setInboxTodos(validatedTodos); - }); - - connection.on("getTodo", (todo) => { - const newTodo = JSON.parse(todo) as Todo; - const validatedTodo = asTodo(newTodo); - setTodos([ - ...todos.filter((t) => t.id !== validatedTodo.id), - validatedTodo, - ]); + connection.on("todoUpdated", (todoUpdated) => { + const { todoId } = JSON.parse(todoUpdated) as { todoId: string }; + if (todoId) { + dispatch(todosApi.util.invalidateTags([{ type: "todo", id: todoId }])); + dispatch(todosApi.util.invalidateTags([{ type: "todo", id: "LIST" }])); + } }); connection @@ -89,41 +43,13 @@ export const SocketProvider: FC = (props) => { setConn(connection); }) .catch((e) => { - window.location.href = `${serverUrl}/api/auth/login?returnUrl=${window.location.href}`; + window.location.href = `${getApiBaseUrl()}/api/auth/login?returnUrl=${ + window.location.href + }`; }); }, []); - return ( - { - conn.invoke("GetTodos").catch(console.error); - }, - getInboxTodos: () => { - conn.invoke("GetInboxTodos").catch(console.error); - }, - createTodo: (todoName, project, description) => { - conn - .invoke("CreateTodo", todoName, project, description) - .catch(console.error); - }, - updateTodo: (todoId, todoStatus) => { - conn.invoke("UpdateTodo", todoId, todoStatus).catch(console.error); - }, - getTodoById(todoId: string) { - conn.invoke("GetTodo", todoId).catch(console.error); - }, - replaceTodo: (todo) => { - conn.invoke("ReplaceTodo", JSON.stringify(todo)).catch(console.error); - }, - }} - > - {conn ? props.children : "loading"} - - ); + return <>{props.children}; }; export default SocketProvider; diff --git a/src/client/src/presentation/hooks/socketHooks.tsx b/src/client/src/presentation/hooks/socketHooks.tsx index 3f6645f..a8a9913 100644 --- a/src/client/src/presentation/hooks/socketHooks.tsx +++ b/src/client/src/presentation/hooks/socketHooks.tsx @@ -1,64 +1,51 @@ -import { useContext, useEffect } from "react"; -import { SocketContext } from "@src/presentation/contexts/SocketContext"; -import { StatusState, Todo } from "@src/core/entities/todo"; -import { createTodoAsync } from "@src/boundary/todo/todoApi"; +import { useEffect } from "react"; +import { useAppSelector } from "@src/infrastructure/store"; +import { todosSelectors } from "@src/infrastructure/state/todo"; +import { todosApi } from "@src/infrastructure/apis/todosApi"; +import { Todo } from "@src/core/entities/todo"; export const useSelectInboxTodos = () => { - const socketContext = useContext(SocketContext); - - useEffect(() => { - socketContext.getInboxTodos(); - }, []); + const todos = useAppSelector(todosSelectors.selectAll); return { loading: false, - todos: socketContext.inboxTodos, + todos, }; }; export const useCreateTodo = () => { - const socketContext = useContext(SocketContext); + const [createTodo, { isLoading }] = todosApi.useCreateTodoMutation(); return { createTodo: (todoName: string, project: string, description: string) => { - //socketContext.createTodo(todoName, project, description); - createTodoAsync({ + createTodo({ + title: todoName, project, description, - title: todoName, - }).catch(console.error); + }); }, }; }; -export const useUpdateTodoState = () => { - const socketContext = useContext(SocketContext); +export const useSelectTodo = ( + todoId: string +): { todo: Todo | undefined; isLoading: boolean } => { + const { data, isLoading } = todosApi.useGetTodoByIdQuery(todoId); + + useEffect(() => {}, []); return { - updateTodoState: (todoId: string, todoState: StatusState) => { - socketContext.updateTodo(todoId, todoState); - }, - }; -}; - -export const useSelectTodo = (todoId: string): { todo: Todo | undefined } => { - const socketContext = useContext(SocketContext); - - useEffect(() => { - socketContext.getTodoById(todoId); - }, []); - - return { - todo: socketContext.todos.find((t) => t.id === todoId), + todo: data, + isLoading: isLoading, }; }; export const useUpdateTodo = () => { - const socketContext = useContext(SocketContext); + const [replaceTodo, { isLoading }] = todosApi.useReplaceTodoMutation(); return { updateTodo: (todo: Todo) => { - socketContext.replaceTodo(todo); + replaceTodo(todo); }, }; }; diff --git a/src/client/yarn.lock b/src/client/yarn.lock index c1e463f..4e0d597 100644 --- a/src/client/yarn.lock +++ b/src/client/yarn.lock @@ -875,7 +875,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.16.3", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.16.3" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5" integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ== @@ -1107,6 +1107,16 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== +"@reduxjs/toolkit@^1.6.2": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.6.2.tgz#2f2b5365df77dd6697da28fdf44f33501ed9ba37" + integrity sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA== + dependencies: + immer "^9.0.6" + redux "^4.1.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@rollup/plugin-babel@^5.2.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" @@ -1182,6 +1192,14 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -1212,6 +1230,25 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== +"@types/react-redux@^7.1.20": + version "7.1.20" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.20.tgz#42f0e61ababb621e12c66c96dda94c58423bd7df" + integrity sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + +"@types/react@*": + version "17.0.35" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.35.tgz#217164cf830267d56cd1aec09dcf25a541eedd4c" + integrity sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/react@^17.0.34": version "17.0.34" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102" @@ -3060,6 +3097,13 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + hsl-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" @@ -3132,6 +3176,11 @@ image-size@1.0.0: dependencies: queue "6.0.2" +immer@^9.0.6: + version "9.0.6" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" + integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ== + import-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-3.0.0.tgz#20845547718015126ea9b3676b7592fb8bd4cf92" @@ -4574,16 +4623,28 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" -react-is@17.0.2: +react-is@17.0.2, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^16.8.1: +react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-redux@^7.2.6: + version "7.2.6" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.6.tgz#49633a24fe552b5f9caf58feb8a138936ddfe9aa" + integrity sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/react-redux" "^7.1.20" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^17.0.2" + react-refresh@0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" @@ -4644,6 +4705,18 @@ reduce-css-calc@^2.1.8: css-unit-converter "^1.1.1" postcss-value-parser "^3.3.0" +redux-thunk@^2.3.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.0.tgz#ac89e1d6b9bdb9ee49ce69a69071be41bbd82d67" + integrity sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA== + +redux@^4.0.0, redux@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.2.tgz#140f35426d99bb4729af760afcf79eaaac407104" + integrity sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw== + dependencies: + "@babel/runtime" "^7.9.2" + regenerate-unicode-properties@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-9.0.0.tgz#54d09c7115e1f53dc2314a974b32c1c344efe326" @@ -4720,6 +4793,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +reselect@^4.0.0: + version "4.1.4" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.4.tgz#66df0aff41b6ee0f51e2cc17cfaf2c1995916f32" + integrity sha512-i1LgXw8DKSU5qz1EV0ZIKz4yIUHJ7L3bODh+Da6HmVSm9vdL/hG7IpbgzQ3k2XSirzf8/eI7OMEs81gb1VV2fQ== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"