diff --git a/backend/src/app.ts b/backend/src/app.ts index 75a1fba..4284170 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,7 @@ import cors from "cors"; import express from "express"; import { isHttpError } from "http-errors"; import taskRoutes from "src/routes/task"; +import tasksRoutes from "src/routes/tasks"; import type { NextFunction, Request, Response } from "express"; @@ -27,6 +28,7 @@ app.use( ); app.use("/api/task", taskRoutes); +app.use("/api/tasks", tasksRoutes); /** * Error handler; all errors thrown by server are handled here. diff --git a/backend/src/controllers/task.ts b/backend/src/controllers/task.ts index feceb12..de4d887 100644 --- a/backend/src/controllers/task.ts +++ b/backend/src/controllers/task.ts @@ -90,3 +90,40 @@ export const removeTask: RequestHandler = async (req, res, next) => { next(error); } }; + +// Define a custom type for the request body so we can have static typing +// for the fields +type UpdateTaskBody = { + _id: string; + title: string; + description?: string; + isChecked: boolean; + dateCreated: Date; +}; + +export const updateTask: RequestHandler = async (req, res, next) => { + // extract any errors that were found by the validator + const errors = validationResult(req); + const { _id, title, description, isChecked, dateCreated } = req.body as UpdateTaskBody; + const { id } = req.params; + try { + // if there are errors, then this function throws an exception + validationErrorParser(errors); + if (id !== _id) { + res.status(400); + } else { + const task = await TaskModel.findByIdAndUpdate( + id, + { $set: { title, description, isChecked, dateCreated } }, + { new: true }, + ); + + if (task === null) { + throw createHttpError(404, "Task not found."); + } + res.status(200).json(task); + } + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/tasks.ts b/backend/src/controllers/tasks.ts new file mode 100644 index 0000000..ed2d7d5 --- /dev/null +++ b/backend/src/controllers/tasks.ts @@ -0,0 +1,12 @@ +import TaskModel from "src/models/task"; + +import type { RequestHandler } from "express"; + +export const getAllTasks: RequestHandler = async (req, res, next) => { + try { + const tasks = await TaskModel.find().sort({ dateCreated: "desc" }); + res.status(200).json(tasks); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/routes/task.ts b/backend/src/routes/task.ts index 69c0a34..17188a4 100644 --- a/backend/src/routes/task.ts +++ b/backend/src/routes/task.ts @@ -19,5 +19,6 @@ router.get("/:id", TaskController.getTask); */ router.post("/", TaskValidator.createTask, TaskController.createTask); router.delete("/:id", TaskController.removeTask); +router.put("/:id", TaskValidator.updateTask, TaskController.updateTask); export default router; diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts new file mode 100644 index 0000000..03b3b9a --- /dev/null +++ b/backend/src/routes/tasks.ts @@ -0,0 +1,8 @@ +import express from "express"; +import * as TasksController from "src/controllers/tasks"; + +const router = express.Router(); + +router.get("/", TasksController.getAllTasks); + +export default router; diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index f157885..bc5db2f 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -1,4 +1,4 @@ -import { get, handleAPIError, post } from "src/api/requests"; +import { get, handleAPIError, post, put } from "src/api/requests"; import type { APIResult } from "src/api/requests"; @@ -94,3 +94,24 @@ export async function getTask(id: string): Promise> { return handleAPIError(error); } } + +export async function getAllTasks(): Promise> { + try { + const response = await get("/api/tasks"); + const json = (await response.json()) as TaskJSON[]; + const tasks = json.map((item) => parseTask(item)); + return { success: true, data: tasks }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function updateTask(task: UpdateTaskRequest): Promise> { + try { + const response = await put(`/api/task/${task._id}`, task); + const json = (await response.json()) as TaskJSON; + return { success: true, data: parseTask(json) }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/components/TaskItem.module.css b/frontend/src/components/TaskItem.module.css new file mode 100644 index 0000000..46217e8 --- /dev/null +++ b/frontend/src/components/TaskItem.module.css @@ -0,0 +1,38 @@ +.item { + height: 3rem; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + column-gap: 0.25rem; +} + +.textContainer { + height: 100%; + border-bottom: 1px solid var(--color-text-secondary); + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + overflow: hidden; +} + +.textContainer span { + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.title { + font: var(--font-label); +} + +.description { + font: var(--font-body); +} + +.checked { + color: var(--color-text-secondary); +} diff --git a/frontend/src/components/TaskItem.tsx b/frontend/src/components/TaskItem.tsx new file mode 100644 index 0000000..6f9c1c9 --- /dev/null +++ b/frontend/src/components/TaskItem.tsx @@ -0,0 +1,50 @@ +import { Dialog } from "@tritonse/tse-constellation"; +import React, { useState } from "react"; +import { type Task, updateTask } from "src/api/tasks"; +import { CheckButton } from "src/components"; +import styles from "src/components/TaskItem.module.css"; + +export type TaskItemProps = { + task: Task; +}; + +export function TaskItem({ task: initialTask }: TaskItemProps) { + const [task, setTask] = useState(initialTask); + const [isLoading, setLoading] = useState(false); + const [errorModalMessage, setErrorModalMessage] = useState(null); + + const handleToggleCheck = () => { + setLoading(true); + updateTask({ ...task, isChecked: !task.isChecked }) + .then((result) => { + if (result.success) { + setTask(result.data); + setLoading(false); + } + }) + .catch(setErrorModalMessage); + }; + + let textContainerClass = styles.textContainer; + if (task.isChecked) { + textContainerClass += ` ${styles.checked}`; + } + return ( +
+ +
+ {task.title} + {task.description && {task.description}} +
+ {errorModalMessage}

} + isOpen={errorModalMessage !== null} + onClose={() => setErrorModalMessage(null)} + /> +
+ ); +} diff --git a/frontend/src/components/TaskList.module.css b/frontend/src/components/TaskList.module.css new file mode 100644 index 0000000..c75fff9 --- /dev/null +++ b/frontend/src/components/TaskList.module.css @@ -0,0 +1,18 @@ +.listTitle { + font: var(--font-heading); +} + +.itemContainer { + width: 100%; + justify-content: center; + flex-direction: column; + align-items: stretch; +} + +.listContainer { + margin-top: 3rem; +} + +.errorModalText { + color: var(--color-tse-navy-blue); +} diff --git a/frontend/src/components/TaskList.tsx b/frontend/src/components/TaskList.tsx new file mode 100644 index 0000000..0782b88 --- /dev/null +++ b/frontend/src/components/TaskList.tsx @@ -0,0 +1,46 @@ +import { Dialog } from "@tritonse/tse-constellation"; +import React, { useEffect, useState } from "react"; +import { getAllTasks, type Task } from "src/api/tasks"; +import { TaskItem } from "src/components"; +import styles from "src/components/TaskList.module.css"; + +export type TaskListProps = { + title: string; +}; + +export function TaskList({ title }: TaskListProps) { + const [tasks, setTasks] = useState([]); + const [errorModalMessage, setErrorModalMessage] = useState(null); + + useEffect(() => { + getAllTasks() + .then((result) => { + if (result.success) { + setTasks(result.data); + } + }) + .catch(setErrorModalMessage); + }, []); + + return ( +
+ {title} +
+ {tasks.length === 0 ? ( +

No tasks yet. Add one above to get started.

+ ) : ( + tasks.map((task) => ) + )} +
+ {errorModalMessage}

} + isOpen={errorModalMessage !== null} + onClose={() => setErrorModalMessage(null)} + /> +
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 6a5a32a..cd1434b 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -5,4 +5,6 @@ export { CheckButton } from "./CheckButton"; export { HeaderBar } from "./HeaderBar"; export { Page } from "./Page"; export { TaskForm } from "./TaskForm"; +export { TaskItem } from "./TaskItem"; +export { TaskList } from "./TaskList"; export { TextField } from "./TextField"; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 0d588d6..6d8de68 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router-dom"; -import { Page, TaskForm } from "src/components"; +import { Page, TaskForm, TaskList } from "src/components"; export function Home() { return ( @@ -12,6 +12,7 @@ export function Home() { About this app

+ ); }