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..147a385 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,76 +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); + 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; }; type UpdateTaskBody = { title: string; description?: string; isChecked?: boolean; + assignee?: string; }; 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(), }); - // 201 means a new resource has been created successfully - // the newly created task is sent back to the user + // Populate the assignee so the frontend gets the user object back immediately + await task.populate("assignee"); + res.status(201).json(task); } catch (error) { next(error); @@ -98,26 +73,37 @@ 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."); } - 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..0aac1d3 100644 --- a/backend/src/controllers/tasks.ts +++ b/backend/src/controllers/tasks.ts @@ -4,8 +4,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/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/api/tasks.ts b/frontend/src/api/tasks.ts index 223be90..5027da4 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; }; /** @@ -97,7 +103,6 @@ export async function getTask(id: string): Promise> { export async function getAllTasks(): Promise> { try { - // your code here const response = await get(`/api/tasks`); const json = (await response.json()) as TaskJSON[]; diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..ce5ef60 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,80 @@ +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 { + 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); + } +} diff --git a/frontend/src/components/TaskForm.module.css b/frontend/src/components/TaskForm.module.css index 9e84604..a389ef9 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; } .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..a6f1a31 100644 --- a/frontend/src/components/TaskForm.tsx +++ b/frontend/src/components/TaskForm.tsx @@ -1,98 +1,77 @@ 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 (
- {/* we could just use a `
` element because we don't need the special - functionality that browsers give to `` elements, but using `` - is better for accessibility because it's more accurate for this purpose-- - we are making a form, so we should use `` */} {formTitle} +
- {/* `data-testid` is used by React Testing Library--see the tests in - `TaskForm.test.tsx` */} setDescription(event.target.value)} /> - {/* set `type="primary"` on the button so the browser doesn't try to - handle it specially (because it's inside a ``) */} +
+ +
+ setAssigneeId(event.target.value)} + />
- {/* Use the Constellation Dialog component to display an error message if there's an error - See the docs (https://tritonse.github.io/TSE-Constellation/?path=/docs/organisms-dialog--documentation) - for a demo and more info on the prop types */} + {errorModalMessage}

} isOpen={errorModalMessage !== null} onClose={() => setErrorModalMessage(null)} diff --git a/frontend/src/components/TaskItem.module.css b/frontend/src/components/TaskItem.module.css index 659a375..dbd22ef 100644 --- a/frontend/src/components/TaskItem.module.css +++ b/frontend/src/components/TaskItem.module.css @@ -5,16 +5,31 @@ justify-content: flex-start; align-items: center; column-gap: 0.25rem; + padding: 0 0.5rem; + transition: background-color 0.2s; } -.textContainer { +.item:hover { + background-color: var(--color-surface); +} + +.contentContainer { height: 100%; + flex-grow: 1; + display: flex; + flex-direction: row; + align-items: center; border-bottom: 1px solid var(--color-text-secondary); - flex-grow: 1; /* Takes up all remaining space in the parent div */ + overflow: hidden; +} + +.textContainer { + height: 100%; + flex-grow: 1; display: flex; - flex-direction: column; /* Lays out children vertically */ - justify-content: center; /* Centers children vertically (main axis) */ - align-items: stretch; /* Stretches children horizontally (cross axis) */ + flex-direction: column; + justify-content: center; + align-items: stretch; overflow: hidden; } @@ -25,10 +40,28 @@ text-overflow: ellipsis; } +.userTagContainer { + width: 12rem; + flex-shrink: 0; + display: flex; + justify-content: flex-end; + align-items: center; +} + .title { font: var(--font-label); } +.title a { + color: inherit; + text-decoration: none; +} + +.title a:hover { + text-decoration: underline; + color: inherit; +} + .description { font: var(--font-body); } @@ -36,3 +69,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..daedc72 100644 --- a/frontend/src/components/TaskItem.tsx +++ b/frontend/src/components/TaskItem.tsx @@ -1,7 +1,8 @@ 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 { CheckButton, UserTag } from "src/components"; import styles from "src/components/TaskItem.module.css"; export type TaskItemProps = { @@ -34,21 +35,22 @@ export function TaskItem({ task: initialTask }: TaskItemProps) { } return (
- {/* render CheckButton here */} - handleToggleCheck()} - disabled={isLoading} - /> -
- {task.title} - {task.description && {task.description}} + +
+
+ + {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 index e7aecdc..52739a0 100644 --- a/frontend/src/components/TaskList.module.css +++ b/frontend/src/components/TaskList.module.css @@ -7,20 +7,36 @@ font: var(--font-heading); font-size: 1.5rem; display: block; - margin-bottom: 1rem; /* Added for spacing between title and list */ + margin-bottom: 1rem; } -/* 3. Inner Item Container: Flexbox, column, stretched horizontally */ .itemContainer { width: 100%; display: flex; flex-direction: column; align-items: stretch; - gap: 0.5rem; /* Optional: adds nice spacing between tasks */ + gap: 0.5rem; overflow: hidden; } -/* 4. Error Modal Text: Blue color override */ +.userTagContainer { + width: 12rem; + flex-shrink: 0; + display: flex; + justify-content: flex-end; +} + +.item { + height: 3rem; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + column-gap: 0.25rem; + padding: 0 0.5rem; + transition: background-color 0.2s; +} + .errorModalText { color: var(--color-tse-navy-blue); } diff --git a/frontend/src/components/TaskList.tsx b/frontend/src/components/TaskList.tsx index 55b11ff..4a70fd7 100644 --- a/frontend/src/components/TaskList.tsx +++ b/frontend/src/components/TaskList.tsx @@ -44,7 +44,6 @@ export function TaskList({ title }: TaskListProps) { styleVersion="styled" variant="error" title="An error occurred" - // Override the text color so it doesn't show white text on a white background content={

{errorModalMessage}

} isOpen={errorModalMessage !== null} onClose={() => setErrorModalMessage(null)} diff --git a/frontend/src/components/UserTag.module.css b/frontend/src/components/UserTag.module.css new file mode 100644 index 0000000..2d38308 --- /dev/null +++ b/frontend/src/components/UserTag.module.css @@ -0,0 +1,33 @@ +.assingeeLabel { + font: var(--font-label); + margin-right: 1rem; +} + +.assingeeName { + font: var(--font-body); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.assingeeIcon { + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 0.5rem; + flex-shrink: 0; +} + +.assingeeItem { + height: 2rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; +} + +.noUserMessage { + font: var(--font-body); + word-break: break-all; + white-space: normal; +} diff --git a/frontend/src/components/UserTag.tsx b/frontend/src/components/UserTag.tsx new file mode 100644 index 0000000..0344f1d --- /dev/null +++ b/frontend/src/components/UserTag.tsx @@ -0,0 +1,23 @@ +import styles from "src/components/UserTag.module.css"; + +import type { User } from "src/api/users"; + +export type UserTagProps = { + user?: User; +}; + +export function UserTag({ user }: UserTagProps) { + if (!user) { + return Not assigned; + } + let profilePicUrl = "/userIcon.svg"; + if (user?.profilePictureURL) { + profilePicUrl = user.profilePictureURL; + } + return ( +
+ User Icon + {user.name} +
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index cd1434b..482b2b5 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -8,3 +8,4 @@ export { TaskForm } from "./TaskForm"; export { TaskItem } from "./TaskItem"; export { TaskList } from "./TaskList"; export { TextField } from "./TextField"; +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..51d76f7 --- /dev/null +++ b/frontend/src/pages/TaskDetail.module.css @@ -0,0 +1,88 @@ +.errorModalText { + color: var(--color-tse-navy-blue); +} + +.taskTitle { + font: var(--font-heading); +} + +.item { + height: 3rem; + margin-top: 1rem; + display: flex; + flex-direction: row; + align-items: center; + column-gap: 0.25rem; +} + +.taskButton { + margin-left: auto; +} + +.description { + font: var(--font-body); + word-break: break-all; + white-space: normal; +} + +.textContainer span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.assingeeLabel { + font: var(--font-label); + margin-right: 1rem; +} + +.assingeeName { + font: var(--font-body); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.assingeeIcon { + width: 32px; + height: 32px; + margin-right: 0.5rem; + flex-shrink: 0; +} + +.assingeeItem { + height: 2rem; + margin-top: 2rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; +} + +.statusItem { + height: 2rem; + margin-top: 1rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; +} + +.statusLabel { + font: var(--font-label); + margin-right: 1rem; +} + +.dateItem { + height: 2rem; + margin-top: 1rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; +} + +.dateLabel { + font: var(--font-label); + margin-right: 1rem; +} diff --git a/frontend/src/pages/TaskDetail.tsx b/frontend/src/pages/TaskDetail.tsx new file mode 100644 index 0000000..c8a4657 --- /dev/null +++ b/frontend/src/pages/TaskDetail.tsx @@ -0,0 +1,114 @@ +import { Dialog } from "@tritonse/tse-constellation"; +import React, { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { getTask, type Task } from "src/api/tasks"; +import { Button, Page, TaskForm, UserTag } from "src/components"; +import styles from "src/pages/TaskDetail.module.css"; + +const dateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "full", + timeStyle: "short", +}); + +export function TaskDetail() { + const [task, setTask] = useState(null); + const [errorModalMessage, setErrorModalMessage] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const { id } = useParams(); + + useEffect(() => { + if (id) { + getTask(id) + .then((result) => { + if (result.success) { + setTask(result.data); + } else { + setErrorModalMessage(result.error); + } + }) + .catch(setErrorModalMessage); + } + }, [id]); + + useEffect(() => { + if (task) { + document.title = `${task.title} | TSE Todos`; + } + }, [task]); + + if (!task) { + return ( + +

Loading...

+
+ ); + } + + if (isEditing) { + return ( + + { + setTask(updatedTask); + setIsEditing(false); + }} + /> + + ); + } + + return ( + +

+ Back to home +

+ +
+ {/* Title Row Matches PDF Page 5 */} +
+ {task.title} +
+
+
+ +
+

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

+
+ +
+ Assignee + +
+ +
+ Status + {task.isChecked ? "Done" : "Not Done"} +
+ +
+ Date Created + {dateFormatter.format(new Date(task.dateCreated))} +
+
+ + {errorModalMessage}

} + 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";