diff --git a/src/backend/server/src/Todo.Api/Hubs/Models/UpdateTodoRequest.cs b/src/backend/server/src/Todo.Api/Hubs/Models/UpdateTodoRequest.cs new file mode 100644 index 0000000..84dbe01 --- /dev/null +++ b/src/backend/server/src/Todo.Api/Hubs/Models/UpdateTodoRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Todo.Api.Hubs.Models; + +public record UpdateTodoRequest +{ + [JsonPropertyName("id")] + public string Id { get; init; } + + [JsonPropertyName("title")] + public string Title { get; init; } + + [JsonPropertyName("status")] + public bool Status { get; init; } + + [JsonPropertyName("project")] + public string Project { get; set; } +} \ No newline at end of file diff --git a/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs b/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs index 59a6523..66b0368 100644 --- a/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs +++ b/src/backend/server/src/Todo.Api/Hubs/TodoHub.cs @@ -51,7 +51,6 @@ namespace Todo.Api.Hubs await Clients.Caller.SendAsync("todos", serializedTodos); } - public async Task GetInboxTodos() { var todos = await _todoRepository.GetNotDoneTodos(); @@ -61,5 +60,44 @@ namespace Todo.Api.Hubs await Clients.Caller.SendAsync("getInboxTodos", serializedTodos); } + + public async Task GetTodo(string todoId) + { + var todo = await _todoRepository.GetTodoByIdAsync(todoId); + var serializedTodo = JsonSerializer.Serialize(new TodoResponse() + { + Id = todo.Id, + Project = todo.Project, + Status = todo.Status, + Title = todo.Title, + }); + + await Clients.Caller.SendAsync("getTodo", serializedTodo); + } + + public async Task ReplaceTodo(string updateTodoRequest) + { + var updateTodo = JsonSerializer.Deserialize(updateTodoRequest); + if (updateTodo is null) + throw new InvalidOperationException("Could not parse invalid updateTodo"); + + var updatedTodo = await _todoRepository.UpdateTodoAsync(new Core.Entities.Todo() + { + Id = updateTodo.Id, + Project = updateTodo.Project, + Status = updateTodo.Status, + Title = updateTodo.Title + }); + + var serializedTodo = JsonSerializer.Serialize(new TodoResponse() + { + Id = updatedTodo.Id, + Project = updatedTodo.Project, + Status = updatedTodo.Status, + Title = updatedTodo.Title, + }); + + await Clients.Caller.SendAsync("getTodo", serializedTodo); + } } } \ No newline at end of file diff --git a/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs b/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs index 149ccf0..b70c410 100644 --- a/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs +++ b/src/backend/server/src/Todo.Core/Interfaces/Persistence/ITodoRepository.cs @@ -1,3 +1,4 @@ + namespace Todo.Core.Interfaces.Persistence; public interface ITodoRepository @@ -6,4 +7,6 @@ public interface ITodoRepository Task> GetTodosAsync(); Task UpdateTodoStatus(string todoId, bool todoStatus); Task> GetNotDoneTodos(); + Task GetTodoByIdAsync(string todoId); + Task UpdateTodoAsync(Entities.Todo todo); } \ No newline at end of file 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 ccc7c5e..445413c 100644 --- a/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs +++ b/src/backend/server/src/Todo.Persistence/Mongo/Repositories/TodoRepository.cs @@ -44,4 +44,37 @@ public class TodoRepository : ITodoRepository var todos = await GetTodosAsync(); return todos.Where(t => t.Status == false); } + + public async Task GetTodoByIdAsync(string todoId) + { + var todoCursor = await _todosCollection.FindAsync(f => f.Id == todoId); + var todo = await todoCursor.FirstOrDefaultAsync(); + + return new Core.Entities.Todo() + { + Id = todo.Id, + Project = todo.ProjectName, + Status = todo.Status, + Title = todo.Title + }; + } + + public async Task UpdateTodoAsync(Core.Entities.Todo todo) + { + var updatedTodo = await _todosCollection.FindOneAndReplaceAsync(f => f.Id == todo.Id, new MongoTodo() + { + Id = todo.Id, + Status = todo.Status, + Title = todo.Title, + ProjectName = todo.Project + }); + + return new Core.Entities.Todo() + { + Id = updatedTodo.Id, + Project = updatedTodo.ProjectName, + Status = updatedTodo.Status, + Title = updatedTodo.Title + }; + } } \ No newline at end of file diff --git a/src/client/src/components/todos/todoCheckmark.tsx b/src/client/src/components/todos/todoCheckmark.tsx index 7920d15..a9d4e12 100644 --- a/src/client/src/components/todos/todoCheckmark.tsx +++ b/src/client/src/components/todos/todoCheckmark.tsx @@ -13,7 +13,7 @@ export const TodoCheckmark: FC = (props) => ( } className={`todo-checkmark h-5 w-5 rounded-full border dark:border-gray-500 ${ props.todo.status === StatusState.done - ? "dark:bg-gray-700" + ? "dark:bg-gray-500" : "hover:dark:bg-gray-600" }`} /> diff --git a/src/client/src/components/todos/todoItem.tsx b/src/client/src/components/todos/todoItem.tsx index a468532..87341ff 100644 --- a/src/client/src/components/todos/todoItem.tsx +++ b/src/client/src/components/todos/todoItem.tsx @@ -2,6 +2,7 @@ import { Todo } from "@src/core/entities/todo"; import { FC, useState } from "react"; import { TodoCheckmark } from "@src/components/todos/todoCheckmark"; import Tippy from "@tippyjs/react"; +import Link from "next/link"; interface TodoItemProps { todo: Todo; @@ -23,9 +24,11 @@ export const TodoItem: FC = (props) => {
-
- {props.todo.title} -
+ + + {props.todo.title} + +
{props.displayProject && props.todo.project && (
@@ -51,7 +54,9 @@ export const TodoItem: FC = (props) => { tabIndex={0} > - + + Edit +
} > diff --git a/src/client/src/core/entities/todo.tsx b/src/client/src/core/entities/todo.tsx index c6747f8..22f97c1 100644 --- a/src/client/src/core/entities/todo.tsx +++ b/src/client/src/core/entities/todo.tsx @@ -22,7 +22,7 @@ export const asTodo = (item: Todo): Todo => { throw new Error("Validation failed: title is null"); } - if (!!item.status) { + if (typeof item.status === "undefined") { throw new Error("Validation failed: status is null"); } diff --git a/src/client/src/pages/todos/[todoId]/index.tsx b/src/client/src/pages/todos/[todoId]/index.tsx new file mode 100644 index 0000000..92e122b --- /dev/null +++ b/src/client/src/pages/todos/[todoId]/index.tsx @@ -0,0 +1,105 @@ +import { useRouter } from "next/router"; +import { + useSelectTodo, + useUpdateTodo, +} from "@src/presentation/hooks/socketHooks"; +import { Todo } from "@src/core/entities/todo"; +import { FC, useEffect, useState } from "react"; +import { PageHeading } from "@src/components/common/headings/pageHeading"; +import { TodoCheckmark } from "@src/components/todos/todoCheckmark"; + +interface EditTodoProps { + todo: Todo; + onCancel: () => void; + onSave: (todo: Todo) => void; +} +const EditTodo: FC = ({ todo, onCancel, onSave }) => { + const [todoTitle, setTodoTitle] = useState(todo.title); + + return ( +
+
+ setTodoTitle(e.target.value)} + type="text" + /> +
+
+ + +
+
+ ); +}; + +interface TodoDetailsProps { + todo: Todo; +} + +const TodoDetails: FC = ({ todo }) => { + const [updatedTodo, setUpdatedTodo] = useState(todo); + const { updateTodo } = useUpdateTodo(); + const [editMode, setEditMode] = useState(false); + + useEffect(() => { + updateTodo(updatedTodo); + }, [updatedTodo]); + + return ( +
+ +
+ {editMode ? ( + setEditMode(false)} + onSave={(todo) => { + setUpdatedTodo(todo); + setEditMode(false); + }} + /> + ) : ( + <> + { + setUpdatedTodo(t); + }} + todo={updatedTodo} + /> +

setEditMode(true)}> + {updatedTodo.title} +

+ + )} +
+
+ ); +}; + +const TodoDetailsPage = () => { + const router = useRouter(); + const { todoId } = router.query as { todoId: string }; + + const { todo } = useSelectTodo(todoId); + + return ( +
+
+ {todo ? :
Todo was not found
} +
+
+ ); +}; + +export default TodoDetailsPage; diff --git a/src/client/src/presentation/contexts/SocketContext.tsx b/src/client/src/presentation/contexts/SocketContext.tsx index 28acee6..47b36fe 100644 --- a/src/client/src/presentation/contexts/SocketContext.tsx +++ b/src/client/src/presentation/contexts/SocketContext.tsx @@ -14,6 +14,8 @@ interface SocketContextProps { getInboxTodos: () => void; createTodo: (todoName: string, project: string) => void; updateTodo: (todoId: string, todoStatus: StatusState) => void; + getTodoById(todoId: string): void; + replaceTodo(todo: Todo): void; } export const SocketContext = createContext({ @@ -24,6 +26,8 @@ export const SocketContext = createContext({ getInboxTodos: () => {}, createTodo: (todoName, project) => {}, updateTodo: (todoId, todoStatus) => {}, + getTodoById(todoId: string) {}, + replaceTodo(todo: Todo) {}, }); export const SocketProvider: FC = (props) => { @@ -55,6 +59,15 @@ export const SocketProvider: FC = (props) => { 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.start().then(() => { setConn(connection); }); @@ -78,6 +91,12 @@ export const SocketProvider: FC = (props) => { 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"} diff --git a/src/client/src/presentation/hooks/socketHooks.tsx b/src/client/src/presentation/hooks/socketHooks.tsx index fd1f317..2ae141d 100644 --- a/src/client/src/presentation/hooks/socketHooks.tsx +++ b/src/client/src/presentation/hooks/socketHooks.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect } from "react"; import { SocketContext } from "@src/presentation/contexts/SocketContext"; -import { StatusState } from "@src/core/entities/todo"; +import { StatusState, Todo } from "@src/core/entities/todo"; export const useSelectInboxTodos = () => { const socketContext = useContext(SocketContext); @@ -34,3 +34,25 @@ export const useUpdateTodoState = () => { }, }; }; + +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), + }; +}; + +export const useUpdateTodo = () => { + const socketContext = useContext(SocketContext); + + return { + updateTodo: (todo: Todo) => { + socketContext.replaceTodo(todo); + }, + }; +};