diff --git a/-H b/-H new file mode 100644 index 0000000..e69de29 diff --git a/-d b/-d new file mode 100644 index 0000000..eece1fa --- /dev/null +++ b/-d @@ -0,0 +1 @@ +{"error":"User validation failed: name: Path `name` is required."}{"error":"User validation failed: name: Path `name` is required."} \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index 691b251..9c3070c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,6 +8,7 @@ import cors from "cors"; import { isHttpError } from "http-errors"; import taskRoutes from "src/routes/task"; import tasksRoutes from "src/routes/tasks"; // add this line +import userRoutes from "src/routes/user"; const app = express(); @@ -27,6 +28,7 @@ app.use( app.use("/api/task", taskRoutes); app.use("/api/tasks", tasksRoutes); // add this line +app.use("/api/user", userRoutes); /** * 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 e698a54..14f4d91 100644 --- a/backend/src/controllers/task.ts +++ b/backend/src/controllers/task.ts @@ -30,7 +30,7 @@ export const getTask: RequestHandler = async (req, res, next) => { try { // if the ID doesn't exist, then findById returns null - const task = await TaskModel.findById(id); + const task = await TaskModel.findById(id).populate("assignee"); if (task === null) { throw createHttpError(404, "Task not found."); @@ -49,22 +49,25 @@ export const getTask: RequestHandler = async (req, res, next) => { export const createTask: RequestHandler = async (req, res, next) => { // extract any errors that were found by the validator const errors = validationResult(req); - const { title, description, isChecked } = req.body; + const { title, description, isChecked, assignee } = req.body; try { // if there are errors, then this function throws an exception validationErrorParser(errors); - const task = await TaskModel.create({ title: title, description: description, isChecked: isChecked, dateCreated: Date.now(), + assignee, }); + console.log("Task returned from DB:", task); + //await task.populate("assignee"); + const populatedTask = await task.populate("assignee"); // 201 means a new resource has been created successfully // the newly created task is sent back to the user - res.status(201).json(task); + res.status(201).json(populatedTask); } catch (error) { next(error); } @@ -91,7 +94,9 @@ export const updateTask: RequestHandler = async (req, res, next) => { if (req.params.id !== req.body._id) { res.status(400); } - const result = await TaskModel.findByIdAndUpdate(req.params.id, req.body, { new: true }); + const result = await TaskModel.findByIdAndUpdate(req.params.id, req.body, { + new: true, + }).populate("assignee"); if (!result) { throw createHttpError(404, "Task not found"); } diff --git a/backend/src/controllers/tasks.ts b/backend/src/controllers/tasks.ts index 1ca73f3..7ee4d11 100644 --- a/backend/src/controllers/tasks.ts +++ b/backend/src/controllers/tasks.ts @@ -8,7 +8,7 @@ export const getAllTasks: RequestHandler = async (req, res, next) => { try { // your code here - const taskSet = await TaskModel.find(tasks).sort({ dateCreated: -1 }); + const taskSet = await TaskModel.find(tasks).sort({ dateCreated: -1 }).populate("assignee"); if (!taskSet || taskSet.length == 0) { throw createHttpError(404, "Task not found"); diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts new file mode 100644 index 0000000..35e65b4 --- /dev/null +++ b/backend/src/controllers/user.ts @@ -0,0 +1,47 @@ +import { RequestHandler } from "express"; +import createHttpError from "http-errors"; +import User from "../models/user"; + +export const createUser: RequestHandler = async (req, res, next) => { + // ... + // extract any errors that were found by the validator + // extract any errors that were found by the validator + //const errors = validationResult(req); + //const { _id, name, profilePictureURL } = req.body; + + try { + // if there are errors, then this function throws an exception + const { name, _id, profilePictureURL } = req.body; + //validationErrorParser(errors); + const user = new User({ name, _id, profilePictureURL }); + + await user.save(); + + // 201 means a new resource has been created successfully + // the newly created task is sent back to the user + res.status(201).json(user); + } catch (error) { + next(error); + } +}; + +export const getUser: RequestHandler = async (req, res, next) => { + const { id } = req.params; + + try { + // if the ID doesn't exist, then findById returns null + const user = await User.findById(id); + + if (user === null) { + throw createHttpError(404, "User not found."); + } + + // Set the status code (200) and body (the task object as JSON) of the response. + // Note that you don't need to return anything, but you can still use a return + // statement to exit the function early. + res.status(200).json(user); + } catch (error) { + // pass errors to the error handler + next(error); + } +}; diff --git a/backend/src/models/task.ts b/backend/src/models/task.ts index bb36d3a..9c01b12 100644 --- a/backend/src/models/task.ts +++ b/backend/src/models/task.ts @@ -10,6 +10,11 @@ const taskSchema = new Schema({ // When we send a Task object in the JSON body of an API response, the date // will automatically get "serialized" into a standard date string. dateCreated: { type: Date, required: true }, + assignee: { + type: Schema.Types.ObjectId, + ref: "User", + required: false, + }, }); type Task = InferSchemaType; diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts new file mode 100644 index 0000000..40f097d --- /dev/null +++ b/backend/src/models/user.ts @@ -0,0 +1,10 @@ +import { InferSchemaType, Schema, model } from "mongoose"; + +const userSchema = new Schema({ + name: { type: String, required: true }, + profilePictureURL: { type: String, required: false }, +}); + +type User = InferSchemaType; + +export default model("User", userSchema); 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/backend/src/routes/user.ts b/backend/src/routes/user.ts new file mode 100644 index 0000000..29760b1 --- /dev/null +++ b/backend/src/routes/user.ts @@ -0,0 +1,26 @@ +/** + * Task route requests. + */ + +import express from "express"; +import * as UserController from "src/controllers/user"; +import * as UserValidator from "src/validators/user"; + +const router = express.Router(); + +router.get("/:id", UserController.getUser); + +/** + * TaskValidator.createTask serves as middleware for this route. This means + * that instead of immediately serving up the route when the request is made, + * Express firsts passes the request to TaskValidator.createTask. + * TaskValidator.createTask processes the request and determines whether the + * request should be sent through or an error should be thrown. + */ + +//router.put("/:id", UserValidator.updateTask, UserController.updateTask); + +router.post("/", UserValidator.createUser, UserController.createUser); +//router.delete("/:id", UserController.removeTask); + +export default router; diff --git a/backend/src/validators/user.ts b/backend/src/validators/user.ts new file mode 100644 index 0000000..0ae2599 --- /dev/null +++ b/backend/src/validators/user.ts @@ -0,0 +1,27 @@ +import { body } from "express-validator"; + +/*const makeIDValidator = () => + body("_id") + .exists() + .withMessage("_id is required") + .bail() + .isMongoId() + .withMessage("_id must be a MongoDB object ID"); + */ //Not needed because it seems like the HTTP request doesn't have an ID as an input but rather +//the backend itself is the one assigning an ID to the user. +const makeNameValidator = () => + body("name").notEmpty().isString().isLength({ min: 1, max: 50 }).trim(); +const makeProfileValidator = () => + body("profilePictureURL") + .optional() + .isURL() + .withMessage("Must be a valid URL") + .matches(/\.(jpg|jpeg|png|gif|bmp|svg)$/i) + .withMessage("URL must point to an image (e.g., .jpg, .png") + .trim(); + +export const createUser = [ + // ... + makeNameValidator(), + makeProfileValidator(), +]; diff --git a/frontend/public/userDefault.svg b/frontend/public/userDefault.svg new file mode 100644 index 0000000..d1e2a5d --- /dev/null +++ b/frontend/public/userDefault.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d716324..968eb3a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { HelmetProvider } from "react-helmet-async"; import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { About, Home } from "src/pages"; +import { About, Home, TaskDetail } from "src/pages"; import "src/globals.css"; export default function App() { @@ -10,6 +10,7 @@ export default function App() { } /> } /> + } /> diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index c216061..f07bee9 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -1,4 +1,5 @@ import { get, handleAPIError, post, put } from "src/api/requests"; +import { User } from "src/api/users"; import type { APIResult } from "src/api/requests"; @@ -13,6 +14,7 @@ export interface Task { description?: string; isChecked: boolean; dateCreated: Date; + assignee?: User; } /** @@ -30,6 +32,7 @@ interface TaskJSON { description?: string; isChecked: boolean; dateCreated: string; + assignee?: User; } /** @@ -46,6 +49,7 @@ function parseTask(task: TaskJSON): Task { description: task.description, isChecked: task.isChecked, dateCreated: new Date(task.dateCreated), + assignee: task.assignee, }; } @@ -57,6 +61,7 @@ function parseTask(task: TaskJSON): Task { export interface CreateTaskRequest { title: string; description?: string; + assignee?: string; } /** @@ -69,6 +74,7 @@ export interface UpdateTaskRequest { description?: string; isChecked: boolean; dateCreated: Date; + assignee?: string; } /** diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..0a38dda --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,33 @@ +import { get, handleAPIError } from "src/api/requests"; + +import type { APIResult } from "src/api/requests"; + +export interface User { + _id: string; + name: string; + profilePictureURL: string; +} + +interface UserJSON { + _id: string; + name: string; + profilePictureURL: string; +} + +function parseUser(user: UserJSON): User { + return { + _id: user._id, + name: user.name, + profilePictureURL: user.profilePictureURL, + }; +} + +export async function getUser(id: string): Promise> { + try { + const response = await get(`/api/user/${id}`); + const json = (await response.json()) as UserJSON; + return { success: true, data: parseUser(json) }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/components/TaskForm.test.tsx b/frontend/src/components/TaskForm.test.tsx index 1b0f944..5341ea1 100644 --- a/frontend/src/components/TaskForm.test.tsx +++ b/frontend/src/components/TaskForm.test.tsx @@ -1,10 +1,10 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { createTask } from "src/api/tasks"; +import { createTask, updateTask } from "src/api/tasks"; import { TaskForm } from "src/components/TaskForm"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { CreateTaskRequest, Task } from "src/api/tasks"; +import type { CreateTaskRequest, Task, UpdateTaskRequest } from "src/api/tasks"; import type { TaskFormProps } from "src/components/TaskForm"; const TITLE_INPUT_ID = "task-title-input"; @@ -34,6 +34,7 @@ vi.mock("src/api/tasks", () => ({ * * See https://vitest.dev/guide/mocking#functions for more info about mock functions. */ + updateTask: vi.fn((_params: UpdateTaskRequest) => Promise.resolve({ success: true })), createTask: vi.fn((_params: CreateTaskRequest) => Promise.resolve({ success: true })), })); @@ -133,10 +134,14 @@ describe("TaskForm", () => { }); const saveButton = screen.getByTestId(SAVE_BUTTON_ID); fireEvent.click(saveButton); - expect(createTask).toHaveBeenCalledTimes(1); - expect(createTask).toHaveBeenCalledWith({ + expect(updateTask).toHaveBeenCalledTimes(1); + expect(updateTask).toHaveBeenCalledWith({ + _id: "task123", // or `mockTask._id` title: "Updated title", description: "Updated description", + //assignee: "", // or whatever you're passing + isChecked: false, // from `mockTask` + dateCreated: mockTask.dateCreated, // or appropriate fallback }); await waitFor(() => { // If the test ends before all state updates and rerenders occur, we'll diff --git a/frontend/src/components/TaskForm.tsx b/frontend/src/components/TaskForm.tsx index a667423..50cea19 100644 --- a/frontend/src/components/TaskForm.tsx +++ b/frontend/src/components/TaskForm.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; -import { createTask } from "src/api/tasks"; +import { createTask, updateTask } from "src/api/tasks"; import { Button, TextField } from "src/components"; import styles from "src/components/TaskForm.module.css"; -import type { Task } from "src/api/tasks"; +import type { Task, UpdateTaskRequest } from "src/api/tasks"; export interface TaskFormProps { mode: "create" | "edit"; @@ -40,6 +40,7 @@ interface TaskFormErrors { export function TaskForm({ mode, task, onSubmit }: TaskFormProps) { const [title, setTitle] = useState(task?.title || ""); const [description, setDescription] = useState(task?.description || ""); + const [assignee, setAssignee] = useState(task?.assignee?._id || ""); const [isLoading, setLoading] = useState(false); const [errors, setErrors] = useState({}); @@ -51,30 +52,64 @@ export function TaskForm({ mode, task, onSubmit }: TaskFormProps) { return; } setLoading(true); - createTask({ title, description }) - .then((result) => { - if (result.success) { - // clear the form - setTitle(""); - setDescription(""); - // only call onSubmit if it's NOT undefined - if (onSubmit) onSubmit(result.data); - } else { - // You should always clearly inform the user when something goes wrong. - // In this case, we're just doing an `alert()` for brevity, but you'd - // generally want to show some kind of error state or notification - // within your UI. If the problem is with the user's input, then use - // the error states of your smaller components (like the `TextField`s). - // If the problem is something we don't really control, such as network - // issues or an unexpected exception on the server side, then use a - // banner, modal, popup, or similar. - alert(result.error); - } - setLoading(false); - }) - .catch((reason) => alert(reason)); - }; + if (mode === "create") { + createTask({ title, description }) + .then((result) => { + if (result.success) { + // clear the form + setTitle(""); + setDescription(""); + setAssignee(""); + // only call onSubmit if it's NOT undefined + if (onSubmit) onSubmit(result.data); + } else { + // You should always clearly inform the user when something goes wrong. + // In this case, we're just doing an `alert()` for brevity, but you'd + // generally want to show some kind of error state or notification + // within your UI. If the problem is with the user's input, then use + // the error states of your smaller components (like the `TextField`s). + // If the problem is something we don't really control, such as network + // issues or an unexpected exception on the server side, then use a + // banner, modal, popup, or similar. + alert(result.error); + } + setLoading(false); + }) + .catch((reason) => alert(reason)); + } + if (mode === "edit") { + if (!task) { + alert("Cannot update task: task is undefined."); + return; + } + const payload: UpdateTaskRequest = { + _id: task._id, + title, + description, + isChecked: task.isChecked, + dateCreated: task.dateCreated, + }; + + if (assignee.trim() !== "") { + payload.assignee = assignee; + } + console.log("Payload being sent:", payload); + updateTask(payload) + .then((result) => { + if (result.success) { + setTitle(""); + setDescription(""); + setAssignee(""); + if (onSubmit) onSubmit(result.data); + } else { + alert(result.error); + } + setLoading(false); + }) + .catch((reason) => alert(reason)); + } + }; const formTitle = mode === "create" ? "New task" : "Edit task"; return ( @@ -104,6 +139,15 @@ export function TaskForm({ mode, task, onSubmit }: TaskFormProps) { /> {/* set `type="primary"` on the button so the browser doesn't try to handle it specially (because it's inside a `
`) */} + +
+ setAssignee(event.target.value)} + />
); diff --git a/frontend/src/components/TaskList.module.css b/frontend/src/components/TaskList.module.css index 292b5ac..b9a3267 100644 --- a/frontend/src/components/TaskList.module.css +++ b/frontend/src/components/TaskList.module.css @@ -1,5 +1,12 @@ .title { font: var(--font-label); + width: 102px; + height: 28px; + font: "Rubik"; + font-weight: 700; + font-size: 24px; + line-height: 100%; + letter-spacing: 0%; } .innerDivContainer { diff --git a/frontend/src/components/UserTag.module.css b/frontend/src/components/UserTag.module.css new file mode 100644 index 0000000..264fd0f --- /dev/null +++ b/frontend/src/components/UserTag.module.css @@ -0,0 +1,77 @@ +.mainDiv { + display: flex; + flex-direction: column; + width: 240px; + height: 124px; + top: 20px; + left: 20px; + radius: 5px; + justify-content: center; + align-items: center; + /*border: 1px solid #9747FF;*/ +} + +.userTagStyle { + width: 12rem; /* or 192px */ + width: 200px; + height: 32px; + gap: 8px; + max-width: 87%; + text-overflow: ellipsis; /* Show "..." at the end when text overflows */ +} + +.assignedFalse { + display: flex; + flex-direction: row; + width: 200px; + height: 32px; + top: 72px; + left: 20px; + gap: 8px; + align-items: center; +} + +.assignedTrue { + display: flex; + width: 200px; + height: 32px; + top: 20px; + left: 20px; + gap: 8px; +} + +.textBoxTrue { + width: 160px; /* limits how wide the text can go */ + height: 19px; /* optional */ + flex: 1; + min-width: 0; + color: #ffffff; + font-family: "Rubik", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 100%; + letter-spacing: 0; + text-overflow: ellipsis; /* Show "..." at the end when text overflows */ + overflow: hidden; /* ⬅️ Must include */ + white-space: nowrap; /* ⬅️ Must include */ +} + +.textBoxFalse { + display: flex; + align-items: center; + width: 200px; /* limits how wide the text can go */ + height: 32px; /* optional */ + color: #ffffff; + font-family: "Rubik", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 100%; + letter-spacing: 0; + line-height: 1; + margin: 0; +} + +.image { + width: 32px; + height: 32px; +} diff --git a/frontend/src/components/UserTag.tsx b/frontend/src/components/UserTag.tsx new file mode 100644 index 0000000..be5b9b5 --- /dev/null +++ b/frontend/src/components/UserTag.tsx @@ -0,0 +1,20 @@ +import { User } from "src/api/users"; +import styles from "src/components/UserTag.module.css"; + +export function UserTag({ user, className }: { user?: User; className?: string }) { + const userImage = "/userDefault.svg"; + return ( +
+ {user ? ( +
+ user +

{user.name}

+
+ ) : ( +
+

Not assigned

+
+ )} +
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 2675989..2586137 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -8,3 +8,4 @@ export { TaskForm } from "./TaskForm"; export { TextField } from "./TextField"; export { TaskItem } from "./TaskItem"; export { TaskList } from "./TaskList"; +export { UserTag } from "./UserTag"; diff --git a/frontend/src/pages/TaskDetail.module.css b/frontend/src/pages/TaskDetail.module.css new file mode 100644 index 0000000..5ede41d --- /dev/null +++ b/frontend/src/pages/TaskDetail.module.css @@ -0,0 +1,62 @@ +.outerDiv { + display: flex; + flex-direction: column; + width: Fixed(900px); + height: 346px; + padding-top: 48px; + padding-bottom: 48px; + gap: 24px; + align-items: baseline; /* ← changed from center */ + margin-bottom: 1rem; +} + +.outerDivEdit { + display: flex; + flex-direction: column; + width: Fixed(900px); + height: 346px; + padding-top: 48px; + padding-bottom: 48px; +} + +.innerDiv1 { + display: flex; + flex-direction: row; + justify-content: space-between; + width: Fill(900px); + height: 35px; + gap: 16px; + align-items: baseline; /* ← changed from center */ + margin-bottom: 1rem; + width: 100%; +} + +.taskTitle { + white-space: nowrap; /* Keep text on one line */ + overflow: hidden; /* Hide anything that overflows the box */ + text-overflow: ellipsis; /* Show "..." at the end when text overflows */ + max-width: 87%; /* Optional: limit width of the element */ + display: inline-block; +} + +.innerDiv2 { + display: flex; + flex-direction: row; + width: 900px; + /*height: 19px; */ + gap: 24px; + align-items: baseline; /* ← changed from center */ +} + +.assigneeText { + display: flex; + align-items: center; + width: 200px; /* limits how wide the text can go */ + height: 32px; /* optional */ + color: #ffffff; + font-size: 16px; + line-height: 100%; + letter-spacing: 0; + line-height: 1; + margin: 0; +} diff --git a/frontend/src/pages/TaskDetail.tsx b/frontend/src/pages/TaskDetail.tsx new file mode 100644 index 0000000..3dcee98 --- /dev/null +++ b/frontend/src/pages/TaskDetail.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { Link, useParams } from "react-router-dom"; +import { Task, getTask } from "src/api/tasks"; +import { Button, Page, TaskForm } from "src/components"; +import { UserTag } from "src/components/UserTag"; +import styles2 from "src/components/UserTag.module.css"; +import styles from "src/pages/TaskDetail.module.css"; + +export function TaskDetail() { + const [task, updateTask] = useState(null); + const { id } = useParams(); + const [notFound, setNotFound] = useState(false); + const [isEditing, setEditing] = useState(false); + + useEffect(() => { + const fetch = async () => { + if (!id) return; + const result = await getTask(id); + + if (result.success) { + updateTask(result.data); + } else { + setNotFound(true); + console.error(result.error); + } + }; + fetch(); + }, [id]); + + return ( + + + Home | TSE Todos + + + {notFound ? ( +
+ Back to home +

This task doesn’t exist!

+
+ ) : ( + task && ( + <> +
+

+ {/* `` renders an `` element with a correct `href` attribute + but uses the react-router library's client-side routing so the new page + loads faster (see https://reactrouter.com/en/main/components/link) */} + Back to Home +

+ + {isEditing ? ( + { + updateTask(updatedTask); // updates the task in state + setEditing(false); // closes the form + }} + > + ) : ( + <> +
+

{task.title}

+
+

{task.description ? task.description : "(No description)"}

+
+ Assignee +
+ {" "} + {task.assignee && ( + + )} + {!task.assignee && "not assigned"} +
+
+
+ Status {task.isChecked ? "Done" : "Not done"} +
+
+ Date created + + {new Date(task.dateCreated).toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + })} + +
+ + )} +
+ + ) + )} + + {/* If you want your individual tasks to be back, just call the taskItem component with + a task object as its parameter (note that you will have to fill all of its fields*/} +
+ ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 74e3b9c..74a17b1 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -2,3 +2,4 @@ // IMO this is fine for an index file export { About } from "./About"; export { Home } from "./Home"; +export { TaskDetail } from "./TaskDetail";