From d8d1d283165da28cf34f08ab7d4086a01e57047a Mon Sep 17 00:00:00 2001
From: rushil-bot
Date: Fri, 19 Dec 2025 13:31:56 -0800
Subject: [PATCH 1/4] Added Users and updated Front and backend routes and api.
Addedd assignees to tasks
---
backend/src/app.ts | 2 +
backend/src/controllers/task.ts | 6 +-
backend/src/controllers/tasks.ts | 2 +-
backend/src/controllers/user.ts | 124 +++++++++++++++++++++++++++++++
backend/src/models/task.ts | 2 +
backend/src/models/user.ts | 12 +++
backend/src/routes/user.ts | 24 ++++++
backend/src/validators/user.ts | 42 +++++++++++
frontend/src/api/tasks.ts | 6 ++
frontend/src/api/users.ts | 81 ++++++++++++++++++++
10 files changed, 298 insertions(+), 3 deletions(-)
create mode 100644 backend/src/controllers/user.ts
create mode 100644 backend/src/models/user.ts
create mode 100644 backend/src/routes/user.ts
create mode 100644 backend/src/validators/user.ts
create mode 100644 frontend/src/api/users.ts
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 4284170..59a208a 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -8,6 +8,7 @@ import express from "express";
import { isHttpError } from "http-errors";
import taskRoutes from "src/routes/task";
import tasksRoutes from "src/routes/tasks";
+import userRoutes from "src/routes/user";
import type { NextFunction, Request, Response } from "express";
@@ -29,6 +30,7 @@ app.use(
app.use("/api/task", taskRoutes);
app.use("/api/tasks", tasksRoutes);
+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 8610bbf..179c4c4 100644
--- a/backend/src/controllers/task.ts
+++ b/backend/src/controllers/task.ts
@@ -31,7 +31,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.");
@@ -77,6 +77,8 @@ export const createTask: RequestHandler = async (req, res, next) => {
dateCreated: Date.now(),
});
+ 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);
@@ -117,7 +119,7 @@ export const updateTask: RequestHandler = async (req, res, next) => {
throw createHttpError(404, "Task not found.");
}
- const updatedTask = await TaskModel.findById(id);
+ const updatedTask = await TaskModel.findById(id).populate("assignee");
res.status(200).json(updatedTask);
} catch (error) {
diff --git a/backend/src/controllers/tasks.ts b/backend/src/controllers/tasks.ts
index 0322269..3a4e548 100644
--- a/backend/src/controllers/tasks.ts
+++ b/backend/src/controllers/tasks.ts
@@ -5,7 +5,7 @@ import type { RequestHandler } from "express";
export const getAllTasks: RequestHandler = async (req, res, next) => {
try {
// your code here
- const sortedTasks = await TaskModel.find().sort({ dateCreated: "desc" });
+ const sortedTasks = await TaskModel.find().populate("assignee").sort({ dateCreated: "desc" });
res.status(200).json(sortedTasks);
} catch (error) {
diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts
new file mode 100644
index 0000000..af548bc
--- /dev/null
+++ b/backend/src/controllers/user.ts
@@ -0,0 +1,124 @@
+/**
+ * Functions that process task route requests.
+ */
+
+import { validationResult } from "express-validator";
+import createHttpError from "http-errors";
+import UserModel from "src/models/user";
+import validationErrorParser from "src/util/validationErrorParser";
+
+import type { RequestHandler } from "express";
+
+/**
+ * This is an example of an Express API request handler. We'll tell Express to
+ * run this function when our backend receives a request to retrieve a
+ * particular task.
+ *
+ * Request handlers typically have 3 parameters: req, res, and next.
+ *
+ * @param req The Request object from Express. This contains all the data from
+ * the API request. (https://expressjs.com/en/4x/api.html#req)
+ * @param res The Response object from Express. We use this to generate the API
+ * response for Express to send back. (https://expressjs.com/en/4x/api.html#res)
+ * @param next The next function in the chain of middleware. If there's no more
+ * processing we can do in this handler, but we're not completely done handling
+ * the request, then we can pass it along by calling next(). For all of the
+ * handlers defined in `src/controllers`, the next function is the global error
+ * handler in `src/app.ts`.
+ */
+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 UserModel.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);
+ }
+};
+
+// Define a custom type for the request body so we can have static typing
+// for the fields
+type CreateUserBody = {
+ name: string;
+ profilePictureURL?: string;
+};
+
+type UpdateUserBody = {
+ name: string;
+ profilePictureURL?: string;
+};
+
+export const createUser: RequestHandler = async (req, res, next) => {
+ // extract any errors that were found by the validator
+ const errors = validationResult(req);
+ const { name, profilePictureURL } = req.body as CreateUserBody;
+
+ try {
+ // if there are errors, then this function throws an exception
+ validationErrorParser(errors);
+
+ const user = await UserModel.create({
+ name,
+ profilePictureURL,
+ });
+
+ // 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 removeUser: RequestHandler = async (req, res, next) => {
+ const { id } = req.params;
+
+ try {
+ const result = await UserModel.deleteOne({ _id: id });
+
+ res.status(200).json(result);
+ } catch (error) {
+ next(error);
+ }
+};
+
+export const updateUser: RequestHandler = async (req, res, next) => {
+ // extract any errors that were found by the validator
+ const errors = validationResult(req);
+
+ //CHANGE THIS LINE EVENTUALLY
+ const { name, _id } = req.body as UpdateUserBody & { _id: string };
+ const { id } = req.params;
+
+ try {
+ // if there are errors, then this function throws an exception
+ validationErrorParser(errors);
+
+ if (_id && id !== _id) {
+ throw createHttpError(404, "User ID Mismatch.");
+ }
+
+ const task = await UserModel.findByIdAndUpdate(id, { name });
+
+ if (task === null) {
+ throw createHttpError(404, "Task not found.");
+ }
+
+ const updatedUser = await UserModel.findById(id);
+
+ res.status(200).json(updatedUser);
+ } catch (error) {
+ next(error);
+ }
+};
diff --git a/backend/src/models/task.ts b/backend/src/models/task.ts
index 8204067..1016e7b 100644
--- a/backend/src/models/task.ts
+++ b/backend/src/models/task.ts
@@ -1,3 +1,4 @@
+import { ObjectId } from "mongodb";
import { model, Schema } from "mongoose";
import type { InferSchemaType } from "mongoose";
@@ -12,6 +13,7 @@ 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: ObjectId, ref: "User" },
});
type Task = InferSchemaType;
diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts
new file mode 100644
index 0000000..f0f402a
--- /dev/null
+++ b/backend/src/models/user.ts
@@ -0,0 +1,12 @@
+import { model, Schema } from "mongoose";
+
+import type { InferSchemaType } from "mongoose";
+
+const userSchema = new Schema({
+ name: { type: String, required: true },
+ profilePictureURL: { type: String },
+});
+
+type User = InferSchemaType;
+
+export default model("User", userSchema);
diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts
new file mode 100644
index 0000000..a6ba95a
--- /dev/null
+++ b/backend/src/routes/user.ts
@@ -0,0 +1,24 @@
+/**
+ * User 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);
+
+/**
+ * UserValidator.createUser 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 UserValidator.createUser.
+ * UserValidator.createUser processes the request and determines whether the
+ * request should be sent through or an error should be thrown.
+ */
+router.post("/", UserValidator.createUser, UserController.createUser);
+router.put("/:id", UserValidator.updateUser, UserController.updateUser);
+router.delete("/:id", UserController.removeUser);
+
+export default router;
diff --git a/backend/src/validators/user.ts b/backend/src/validators/user.ts
new file mode 100644
index 0000000..259f82d
--- /dev/null
+++ b/backend/src/validators/user.ts
@@ -0,0 +1,42 @@
+import { body } from "express-validator";
+
+// more info about validators:
+// https://express-validator.github.io/docs/guides/validation-chain
+// https://github.com/validatorjs/validator.js#validators
+
+const makeIDValidator = () =>
+ body("_id")
+ .exists()
+ .withMessage("_id is required")
+ .bail()
+ .isMongoId()
+ .withMessage("_id must be a MongoDB object ID");
+const makeNameValidator = () =>
+ body("name")
+ // name must exist, if not this message will be displayed
+ .exists()
+ .withMessage("name is required")
+ // bail prevents the remainder of the validation chain for this field from being executed if
+ // there was an error
+ .bail()
+ .isString()
+ .withMessage("name must be a string")
+ .bail()
+ .notEmpty()
+ .withMessage("name cannot be empty");
+const makeProfilePictureURLValidator = () =>
+ body("profilePictureURL")
+ // order matters for the validation chain - by marking this field as optional, the rest of
+ // the chain will only be evaluated if it exists
+ .optional()
+ .isString()
+ .withMessage("profilePictureURL must be a string");
+
+// establishes a set of rules that the body of the task creation route must follow
+export const createUser = [makeNameValidator(), makeProfilePictureURLValidator()];
+
+export const updateUser = [
+ makeIDValidator(),
+ makeNameValidator(),
+ makeProfilePictureURLValidator(),
+];
diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts
index 223be90..1524b8f 100644
--- a/frontend/src/api/tasks.ts
+++ b/frontend/src/api/tasks.ts
@@ -1,6 +1,7 @@
import { get, handleAPIError, post, put } from "src/api/requests";
import type { APIResult } from "src/api/requests";
+import type { User } from "src/api/users.ts";
/**
* Defines the "shape" of a Task object (what fields are present and their types) for
@@ -13,6 +14,7 @@ export type Task = {
description?: string;
isChecked: boolean;
dateCreated: Date;
+ assignee?: User;
};
/**
@@ -30,6 +32,7 @@ type 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 type CreateTaskRequest = {
title: string;
description?: string;
+ assignee?: string;
};
/**
@@ -69,6 +74,7 @@ export type 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..e006843
--- /dev/null
+++ b/frontend/src/api/users.ts
@@ -0,0 +1,81 @@
+import { get, handleAPIError, post, put } from "src/api/requests";
+
+import type { APIResult } from "src/api/requests";
+
+/**
+ * Defines the "shape" of a Task object (what fields are present and their types) for
+ * frontend components to use. This will be the return type of most functions in this
+ * file.
+ */
+export type User = {
+ _id: string;
+ name: string;
+ profilePictureURL?: string;
+};
+
+/**
+ * The expected inputs when we want to create a new Task object. In the MVP, we only
+ * need to provide the title and optionally the description, but in the course of
+ * this tutorial you'll likely want to add more fields here.
+ */
+export type CreateUserRequest = {
+ name: string;
+ profilePictureURL?: string;
+};
+
+/**
+ * The expected inputs when we want to update an existing Task object. Similar to
+ * `CreateTaskRequest`.
+ */
+export type UpdateUserRequest = {
+ _id: string;
+ name: string;
+ profilePictureURL?: string;
+};
+
+/**
+ * The implementations of these API client functions are provided as part of the
+ * MVP. You can use them as a guide for writing the other client functions.
+ */
+export async function createUser(user: CreateUserRequest): Promise> {
+ try {
+ const response = await post("/api/user", user);
+ const json = (await response.json()) as User;
+ return { success: true, data: json };
+ } catch (error) {
+ return handleAPIError(error);
+ }
+}
+
+export async function getUser(id: string): Promise> {
+ try {
+ const response = await get(`/api/user/${id}`);
+ const json = (await response.json()) as User;
+ return { success: true, data: json };
+ } catch (error) {
+ return handleAPIError(error);
+ }
+}
+
+export async function getAllTasks(): Promise> {
+ try {
+ // your code here
+ const response = await get(`/api/user`);
+
+ const json = (await response.json()) as User[];
+
+ return { success: true, data: json };
+ } catch (error) {
+ return handleAPIError(error);
+ }
+}
+
+export async function updateTask(user: UpdateUserRequest): Promise> {
+ try {
+ const response = await put(`/api/user/${user._id}`, user);
+ const json = (await response.json()) as User;
+ return { success: true, data: json };
+ } catch (error) {
+ return handleAPIError(error);
+ }
+}
From f679aee77846636053f275eae69076fc78dc870c Mon Sep 17 00:00:00 2001
From: rushil-bot
Date: Sat, 20 Dec 2025 19:05:41 -0800
Subject: [PATCH 2/4] Added Task details page and linked each task to
appropriate page
---
frontend/public/userIcon.svg | 10 ++
frontend/src/App.tsx | 3 +-
frontend/src/components/TaskItem.module.css | 26 +++++
frontend/src/components/TaskItem.tsx | 7 +-
frontend/src/pages/TaskDetail.module.css | 102 +++++++++++++++++++
frontend/src/pages/TaskDetail.tsx | 107 ++++++++++++++++++++
frontend/src/pages/index.ts | 1 +
7 files changed, 253 insertions(+), 3 deletions(-)
create mode 100644 frontend/public/userIcon.svg
create mode 100644 frontend/src/pages/TaskDetail.module.css
create mode 100644 frontend/src/pages/TaskDetail.tsx
diff --git a/frontend/public/userIcon.svg b/frontend/public/userIcon.svg
new file mode 100644
index 0000000..d1e2a5d
--- /dev/null
+++ b/frontend/public/userIcon.svg
@@ -0,0 +1,10 @@
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0c28956..64eed75 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,6 @@
import { ThemeProvider } from "@tritonse/tse-constellation";
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/components/TaskItem.module.css b/frontend/src/components/TaskItem.module.css
index 659a375..59b11b5 100644
--- a/frontend/src/components/TaskItem.module.css
+++ b/frontend/src/components/TaskItem.module.css
@@ -5,6 +5,15 @@
justify-content: flex-start;
align-items: center;
column-gap: 0.25rem;
+ /* Add padding to ensure the background color looks good around the content */
+ padding: 0 0.5rem;
+ /* Smooth transition for the background color change */
+ transition: background-color 0.2s;
+}
+
+/* Change background color on hover */
+.item:hover {
+ background-color: var(--color-surface);
}
.textContainer {
@@ -29,6 +38,19 @@
font: var(--font-label);
}
+/* Override default link styling */
+.title a {
+ /* Inherit color from parent (.textContainer), which handles the .checked state */
+ color: inherit;
+ text-decoration: none;
+}
+
+.title a:hover {
+ text-decoration: underline;
+ /* Optional: maintain the inherited color on hover, or set a specific hover color if desired */
+ color: inherit;
+}
+
.description {
font: var(--font-body);
}
@@ -36,3 +58,7 @@
.checked {
color: var(--color-text-secondary);
}
+
+.errorModalText {
+ color: var(--color-tse-navy-blue);
+}
diff --git a/frontend/src/components/TaskItem.tsx b/frontend/src/components/TaskItem.tsx
index 8699b49..7f9b266 100644
--- a/frontend/src/components/TaskItem.tsx
+++ b/frontend/src/components/TaskItem.tsx
@@ -1,5 +1,6 @@
import { Dialog } from "@tritonse/tse-constellation";
-import React, { useState } from "react"; // update this line
+import React, { useState } from "react";
+import { Link } from "react-router-dom";
import { type Task, updateTask } from "src/api/tasks";
import { CheckButton } from "src/components";
import styles from "src/components/TaskItem.module.css";
@@ -41,7 +42,9 @@ export function TaskItem({ task: initialTask }: TaskItemProps) {
disabled={isLoading}
/>
}
+ isOpen={errorModalMessage !== null}
+ onClose={() => setErrorModalMessage(null)}
+ />
+
+ );
+}
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";
From 5d77405f52ff1bdefbf7becade3c44464aedc457 Mon Sep 17 00:00:00 2001
From: rushil-bot
Date: Wed, 31 Dec 2025 12:10:25 -0800
Subject: [PATCH 3/4] Added userTag component and created ability to assign
people to tasks
---
backend/src/controllers/task.ts | 58 ++++------
frontend/src/components/TaskForm.module.css | 1 +
frontend/src/components/TaskForm.test.tsx | 94 ++--------------
frontend/src/components/TaskForm.tsx | 112 +++++++++-----------
frontend/src/components/TaskItem.module.css | 36 +++++--
frontend/src/components/TaskItem.tsx | 26 ++---
frontend/src/components/TaskList.module.css | 19 ++++
frontend/src/components/UserTag.module.css | 34 ++++++
frontend/src/components/UserTag.tsx | 24 +++++
frontend/src/components/index.ts | 1 +
frontend/src/pages/TaskDetail.module.css | 4 +-
frontend/src/pages/TaskDetail.tsx | 46 ++++----
12 files changed, 224 insertions(+), 231 deletions(-)
create mode 100644 frontend/src/components/UserTag.module.css
create mode 100644 frontend/src/components/UserTag.tsx
diff --git a/backend/src/controllers/task.ts b/backend/src/controllers/task.ts
index 179c4c4..3775278 100644
--- a/backend/src/controllers/task.ts
+++ b/backend/src/controllers/task.ts
@@ -1,7 +1,3 @@
-/**
- * Functions that process task route requests.
- */
-
import { validationResult } from "express-validator";
import createHttpError from "http-errors";
import TaskModel from "src/models/task";
@@ -9,78 +5,55 @@ import validationErrorParser from "src/util/validationErrorParser";
import type { RequestHandler } from "express";
-/**
- * This is an example of an Express API request handler. We'll tell Express to
- * run this function when our backend receives a request to retrieve a
- * particular task.
- *
- * Request handlers typically have 3 parameters: req, res, and next.
- *
- * @param req The Request object from Express. This contains all the data from
- * the API request. (https://expressjs.com/en/4x/api.html#req)
- * @param res The Response object from Express. We use this to generate the API
- * response for Express to send back. (https://expressjs.com/en/4x/api.html#res)
- * @param next The next function in the chain of middleware. If there's no more
- * processing we can do in this handler, but we're not completely done handling
- * the request, then we can pass it along by calling next(). For all of the
- * handlers defined in `src/controllers`, the next function is the global error
- * handler in `src/app.ts`.
- */
export const getTask: RequestHandler = async (req, res, next) => {
const { id } = req.params;
try {
- // if the ID doesn't exist, then findById returns null
const task = await TaskModel.findById(id).populate("assignee");
if (task === null) {
throw createHttpError(404, "Task 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(task);
} catch (error) {
- // pass errors to the error handler
next(error);
}
};
-// Define a custom type for the request body so we can have static typing
-// for the fields
type CreateTaskBody = {
title: string;
description?: string;
isChecked?: boolean;
+ assignee?: string; // Added assignee
};
type UpdateTaskBody = {
title: string;
description?: string;
isChecked?: boolean;
+ assignee?: string; // Added assignee
};
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 as CreateTaskBody;
+ // Extract assignee along with other fields
+ const { title, description, isChecked, assignee } = req.body as CreateTaskBody;
try {
- // if there are errors, then this function throws an exception
validationErrorParser(errors);
const task = await TaskModel.create({
title,
description,
isChecked,
+ assignee, // Save the assignee
dateCreated: Date.now(),
});
+ // Populate the assignee so the frontend gets the user object back immediately
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);
} catch (error) {
next(error);
@@ -100,20 +73,31 @@ export const removeTask: RequestHandler = async (req, res, next) => {
};
export const updateTask: RequestHandler = async (req, res, next) => {
- // extract any errors that were found by the validator
const errors = validationResult(req);
- const { isChecked, _id } = req.body as UpdateTaskBody & { _id: string };
+ // Extract ALL fields: title, description, assignee, isChecked
+ const { title, description, assignee, isChecked, _id } = req.body as UpdateTaskBody & {
+ _id: string;
+ };
const { id } = req.params;
try {
- // if there are errors, then this function throws an exception
validationErrorParser(errors);
if (_id && id !== _id) {
throw createHttpError(404, "Task ID Mismatch.");
}
- const task = await TaskModel.findByIdAndUpdate(id, { isChecked });
+ // Update all fields in the database
+ const task = await TaskModel.findByIdAndUpdate(
+ id,
+ {
+ title,
+ description,
+ assignee,
+ isChecked,
+ },
+ { new: true }, // Optional: returns the modified document
+ );
if (task === null) {
throw createHttpError(404, "Task not found.");
diff --git a/frontend/src/components/TaskForm.module.css b/frontend/src/components/TaskForm.module.css
index 9e84604..f1a21b6 100644
--- a/frontend/src/components/TaskForm.module.css
+++ b/frontend/src/components/TaskForm.module.css
@@ -15,6 +15,7 @@
justify-content: flex-start;
column-gap: 2rem;
align-items: flex-end;
+ margin-bottom: 1rem; /* Added spacing between rows */
}
.textField {
diff --git a/frontend/src/components/TaskForm.test.tsx b/frontend/src/components/TaskForm.test.tsx
index e1291e4..08fa574 100644
--- a/frontend/src/components/TaskForm.test.tsx
+++ b/frontend/src/components/TaskForm.test.tsx
@@ -1,45 +1,21 @@
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";
const DESCRIPTION_INPUT_ID = "task-description-input";
const SAVE_BUTTON_ID = "task-save-button";
-/**
- * The `vi.mock()` function allows us to replace the exports of another module.
- * In this case, because `TaskForm` calls `createTask()` directly, we can't pass
- * in our mock function as a prop, so we use `vi.mock()` to replace `createTask`
- * from `src/api/tasks` with the mock function. Thus, if `TaskForm` is being
- * rendered by one of these tests (as opposed to a real browser) and it tries to
- * call `createTask()`, it will run the code that we provide below instead.
- *
- * See https://vitest.dev/guide/mocking.html#modules for more info about mocking
- * modules.
- */
vi.mock("src/api/tasks", () => ({
- /**
- * Some of our tests will verify that `TaskForm` calls the correct functions
- * when the user takes certain actions, like clicking the Save button. This mock
- * function replaces `createTask` from `src/api/tasks` so we can (1) check
- * whether that API function gets called and (2) prevent our test from trying to
- * send an actual HTTP request. Because we know how `createTask` is used in the
- * `TaskForm`, it's safe to just make this mock return `{ success: true }`--we
- * don't need to rewrite a full implementation unless that's required.
- *
- * See https://vitest.dev/guide/mocking#functions for more info about mock functions.
- */
createTask: vi.fn(async (_params: CreateTaskRequest) => Promise.resolve({ success: true })),
+ updateTask: vi.fn(async (_params: UpdateTaskRequest) => Promise.resolve({ success: true })),
}));
-/**
- * A fake Task object to use in tests
- */
const mockTask: Task = {
_id: "task123",
title: "My task",
@@ -48,65 +24,18 @@ const mockTask: Task = {
dateCreated: new Date(),
};
-/**
- * Renders the `TaskForm` component for use in tests
- */
function mountComponent(props: TaskFormProps) {
render();
}
-/**
- * The callback below runs after each test (see
- * https://vitest.dev/api/#aftereach). Often you'll run `clearAllMocks()` or
- * `resetAllMocks()`, which reset the state of all mock functions
- * (https://vitest.dev/api/vi.html#vi-clearallmocks). In this case, we only want
- * to "clear" because "reset" also removes any mock implementations, which we
- * should leave alone for this test suite.
- */
afterEach(() => {
vi.clearAllMocks();
cleanup();
});
-/**
- * A `describe` block helps to group tests together, but is not required. You
- * can nest them--for example, we could have something like:
- * ```js
- * describe("BigComponent", () => {
- * describe("functionality 1", () => {
- * it("does something", () => {
- * // ...
- * })
- * // ...
- * });
- * describe("functionality 2", () => {
- * // ...
- * })
- * })
- * ```
- * See https://vitest.dev/api/#describe for more information.
- */
describe("taskForm", () => {
- /**
- * The `it` function defines a single test. The first parameter is a string
- * that names the test. You should follow the format below (starts with a
- * present-tense verb) so it reads as "it renders create mode", where "it"
- * refers to "TaskForm". The second parameter is a function that contains the
- * code for the test.
- *
- * This first test simply renders the component, then checks that the "New
- * task" title is present. These kinds of tests are easy to write but do not
- * verify any actual behavior/logic, so be sure to write additional tests like
- * the third and fourth ones in this file.
- *
- * `it` is actually an alias for the `test` function. We use `it` so it reads
- * like a sentence.
- *
- * See https://vitest.dev/api/#test for more information.
- */
it("renders create mode", () => {
mountComponent({ mode: "create" });
- // https://vitest.dev/api/expect.html
expect(screen.queryByText("New task")).toBeInTheDocument();
});
@@ -121,8 +50,6 @@ describe("taskForm", () => {
});
it("calls submit handler with edited fields", async () => {
- // sometimes a test needs to be asynchronous, for example if we need to wait
- // for component state updates
mountComponent({
mode: "edit",
task: mockTask,
@@ -133,20 +60,15 @@ 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: mockTask._id,
title: "Updated title",
description: "Updated description",
+ isChecked: mockTask.isChecked,
+ assignee: "",
});
await waitFor(() => {
- // If the test ends before all state updates and rerenders occur, we'll
- // get a warning about updates not being wrapped in an `act(...)`
- // function. We resolve it here by waiting for the save button to be
- // re-enabled.
- // `@testing-library/react` actually uses `act()` in its helper functions,
- // like `fireEvent.click()`, so we'll only see that warning when there's
- // something missing in our tests.
- // More info: https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning
expect(saveButton).toBeEnabled();
});
});
diff --git a/frontend/src/components/TaskForm.tsx b/frontend/src/components/TaskForm.tsx
index 237498c..6b09b11 100644
--- a/frontend/src/components/TaskForm.tsx
+++ b/frontend/src/components/TaskForm.tsx
@@ -1,98 +1,78 @@
import { Dialog } from "@tritonse/tse-constellation";
import { useState } from "react";
-import { createTask } from "src/api/tasks";
+import { createTask, type Task, 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";
-
export type TaskFormProps = {
mode: "create" | "edit";
task?: Task;
onSubmit?: (task: Task) => void;
};
-/**
- * A simple way to handle error states in the form. We'll keep a
- * `TaskFormErrors` object in the form's state, initially empty. Before we
- * submit a request, we'll check each field for problems. For each invalid
- * field, we set the corresponding field in the errors object to true, and the
- * corresponding input component will show its error state if the field is true.
- * Look at where the `errors` object appears below for demonstration.
- *
- * In the MVP, the only possible error in this form is that the title is blank,
- * so this is slightly overengineered. However, a more complex form would need
- * a similar system.
- */
type TaskFormErrors = {
title?: boolean;
};
-/**
- * The form that creates or edits a Task object. In the MVP, this is only
- * capable of creating Tasks.
- *
- * @param props.mode Controls how the form renders and submits
- * @param props.task Optional initial data to populate the form with (such as
- * when we're editing an existing task)
- * @param props.onSubmit Optional callback to run after the user submits the
- * form and the request succeeds
- */
export function TaskForm({ mode, task, onSubmit }: TaskFormProps) {
const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || "");
+ const [assigneeId, setAssigneeId] = useState(task?.assignee?._id || "");
const [isLoading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
-
- // This state variable controls the error message that gets displayed to the user in the
- // Constellation `Dialog` component. If it's `null`, there's no error, so we don't display the Dialog.
- // If it's non-null, there is an error, so we should display that error to the user.
const [errorModalMessage, setErrorModalMessage] = useState(null);
const handleSubmit = () => {
- // first, do any validation that we can on the frontend
setErrors({});
if (title.length === 0) {
setErrors({ title: true });
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 using the Constellation `Dialog` component to show a popup.
- // For errors, you 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.
- setErrorModalMessage(result.error);
- }
- setLoading(false);
+
+ if (mode === "create") {
+ createTask({ title, description, assignee: assigneeId })
+ .then((result) => {
+ if (result.success) {
+ setTitle("");
+ setDescription("");
+ setAssigneeId("");
+ if (onSubmit) onSubmit(result.data);
+ } else {
+ setErrorModalMessage(result.error);
+ }
+ setLoading(false);
+ })
+ .catch(setErrorModalMessage);
+ } else {
+ updateTask({
+ _id: task!._id,
+ title,
+ description,
+ assignee: assigneeId,
+ dateCreated: task!.dateCreated,
+ isChecked: task!.isChecked,
})
- .catch(setErrorModalMessage);
+ .then((result) => {
+ if (result.success) {
+ if (onSubmit) onSubmit(result.data);
+ } else {
+ setErrorModalMessage(result.error);
+ }
+ setLoading(false);
+ })
+ .catch(setErrorModalMessage);
+ }
};
const formTitle = mode === "create" ? "New task" : "Edit task";
return (