diff --git a/todo-app/src/app/components/AddTodoForm.tsx b/todo-app/src/app/components/AddTodoForm.tsx new file mode 100644 index 0000000..4e29dbb --- /dev/null +++ b/todo-app/src/app/components/AddTodoForm.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useState } from 'react'; + +interface AddTodoFormProps { + onAdd: (title: string) => void; + isLoading?: boolean; +} + +export default function AddTodoForm({ onAdd, isLoading = false }: AddTodoFormProps) { + const [title, setTitle] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (title.trim()) { + onAdd(title.trim()); + setTitle(''); + } + }; + + return ( +
+
+ setTitle(e.target.value)} + placeholder="Add a new todo..." + disabled={isLoading} + className="flex-1 px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + /> + +
+
+ ); +} diff --git a/todo-app/src/app/components/DeleteConfirmationModal.tsx b/todo-app/src/app/components/DeleteConfirmationModal.tsx new file mode 100644 index 0000000..a9170ec --- /dev/null +++ b/todo-app/src/app/components/DeleteConfirmationModal.tsx @@ -0,0 +1,51 @@ +'use client'; + +interface DeleteConfirmationModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + todoTitle: string; +} + +export default function DeleteConfirmationModal({ + isOpen, + onConfirm, + onCancel, + todoTitle +}: DeleteConfirmationModalProps) { + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+

+ Delete Todo +

+

+ Are you sure you want to delete "{todoTitle}"? This action cannot be undone. +

+
+ + +
+
+
+ ); +} diff --git a/todo-app/src/app/components/TodoApp.tsx b/todo-app/src/app/components/TodoApp.tsx index f9cdc1d..1eae630 100644 --- a/todo-app/src/app/components/TodoApp.tsx +++ b/todo-app/src/app/components/TodoApp.tsx @@ -1,8 +1,11 @@ 'use client'; +import { useState } from 'react'; import { modelenceMutation, modelenceQuery } from '@modelence/react-query'; import { useMutation, useQuery } from '@tanstack/react-query'; +import AddTodoForm from './AddTodoForm'; import TodoList from './TodoList'; +import DeleteConfirmationModal from './DeleteConfirmationModal'; interface Todo { _id: string; @@ -11,27 +14,73 @@ interface Todo { } export default function TodoApp() { + const [todoToDelete, setTodoToDelete] = useState(null); + const { data: todos, refetch: refetchTodos, isFetching, error } = useQuery(modelenceQuery('todos.getAll')); const { mutateAsync: setCompleted } = useMutation(modelenceMutation('todos.setCompleted')); + const { mutateAsync: createTodo } = useMutation(modelenceMutation('todos.create')); + const { mutateAsync: updateTodo } = useMutation(modelenceMutation('todos.update')); + const { mutateAsync: deleteTodo } = useMutation(modelenceMutation('todos.delete')); if (error) return
Error: {error.message}
; - if (!todos && !isFetching) return
No todos found
; - const toggleTodo = async (todo: Todo) => { + const handleAddTodo = async (title: string) => { + await createTodo({ title }); + refetchTodos(); + }; + + const handleToggleTodo = async (todo: Todo) => { await setCompleted({ id: todo._id, completed: !todo.completed }); refetchTodos(); }; + const handleEditTodo = async (todo: Todo, newTitle: string) => { + await updateTodo({ id: todo._id, title: newTitle }); + refetchTodos(); + }; + + const handleDeleteTodo = (todo: Todo) => { + setTodoToDelete(todo); + }; + + const confirmDelete = async () => { + if (todoToDelete) { + await deleteTodo({ id: todoToDelete._id }); + setTodoToDelete(null); + refetchTodos(); + } + }; + + const cancelDelete = () => { + setTodoToDelete(null); + }; + return (

My Todo List

+ + + {isFetching && !todos ? (
Loading...
) : ( - + )} + +
); } diff --git a/todo-app/src/app/components/TodoItem.tsx b/todo-app/src/app/components/TodoItem.tsx index 172bbd6..cdce423 100644 --- a/todo-app/src/app/components/TodoItem.tsx +++ b/todo-app/src/app/components/TodoItem.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useState, useRef, useEffect } from 'react'; + interface Todo { _id: string; title: string; @@ -9,35 +11,110 @@ interface Todo { interface TodoItemProps { todo: Todo; onToggle: (todo: Todo) => void; + onEdit: (todo: Todo, newTitle: string) => void; + onDelete: (todo: Todo) => void; isDisabled?: boolean; } -export default function TodoItem({ todo, onToggle, isDisabled = false }: TodoItemProps) { +export default function TodoItem({ todo, onToggle, onEdit, onDelete, isDisabled = false }: TodoItemProps) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(todo.title); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleEditSubmit = () => { + if (editValue.trim() && editValue !== todo.title) { + onEdit(todo, editValue.trim()); + } + setIsEditing(false); + setEditValue(todo.title); + }; + + const handleEditCancel = () => { + setIsEditing(false); + setEditValue(todo.title); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleEditSubmit(); + } else if (e.key === 'Escape') { + handleEditCancel(); + } + }; + return (
  • -
    +
    !isDisabled && onToggle(todo)} - disabled={isDisabled} - className="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + disabled={isDisabled || isEditing} + className="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0" /> - - {todo.title} + {isEditing ? ( +
    + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleEditCancel} + className="flex-1 px-2 py-1 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
    + ) : ( + !isDisabled && setIsEditing(true)} + > + {todo.title} + + )} +
    +
    + + {todo.completed ? 'Completed' : 'Pending'} +
    - - {todo.completed ? 'Completed' : 'Pending'} -
  • ); } diff --git a/todo-app/src/app/components/TodoList.tsx b/todo-app/src/app/components/TodoList.tsx index 4922ad2..536c87d 100644 --- a/todo-app/src/app/components/TodoList.tsx +++ b/todo-app/src/app/components/TodoList.tsx @@ -11,22 +11,32 @@ interface Todo { interface TodoListProps { todos: Todo[]; onToggleTodo: (todo: Todo) => void; + onEditTodo: (todo: Todo, newTitle: string) => void; + onDeleteTodo: (todo: Todo) => void; isLoading?: boolean; } -export default function TodoList({ todos, onToggleTodo, isLoading = false }: TodoListProps) { +export default function TodoList({ todos, onToggleTodo, onEditTodo, onDeleteTodo, isLoading = false }: TodoListProps) { return (
    -
      - {todos.map((todo, index) => ( - - ))} -
    + {todos.length === 0 ? ( +

    + No todos yet. Add one above to get started! +

    + ) : ( +
      + {todos.map((todo, index) => ( + + ))} +
    + )}
    ); } diff --git a/todo-app/src/app/layout.tsx b/todo-app/src/app/layout.tsx index f7aced6..d28efa7 100644 --- a/todo-app/src/app/layout.tsx +++ b/todo-app/src/app/layout.tsx @@ -27,6 +27,7 @@ export default function RootLayout({ {children} diff --git a/todo-app/src/server/todos/index.ts b/todo-app/src/server/todos/index.ts index d24269c..a233aaf 100644 --- a/todo-app/src/server/todos/index.ts +++ b/todo-app/src/server/todos/index.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { Module } from 'modelence/server'; +import { Module, ObjectId } from 'modelence/server'; import { dbTodos } from './db'; export default new Module('todos', { @@ -22,5 +22,34 @@ export default new Module('todos', { }, }); }, + async create(args) { + const { title } = z.object({ + title: z.string().min(1), + }).parse(args); + + await dbTodos.insertOne({ + title, + completed: false, + }); + }, + async update(args) { + const { id, title } = z.object({ + id: z.string(), + title: z.string().min(1), + }).parse(args); + + await dbTodos.updateOne(id, { + $set: { + title, + }, + }); + }, + async delete(args) { + const { id } = z.object({ + id: z.string(), + }).parse(args); + + await dbTodos.deleteMany({ _id: new ObjectId(id) }); + }, }, });