Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import cors from "cors";
import express from "express";
import { isHttpError } from "http-errors";
import taskRoutes from "src/routes/task";
import tasksRoutes from "src/routes/tasks";

import type { NextFunction, Request, Response } from "express";

Expand All @@ -27,6 +28,7 @@ app.use(
);

app.use("/api/task", taskRoutes);
app.use("/api/tasks", tasksRoutes);

/**
* Error handler; all errors thrown by server are handled here.
Expand Down
37 changes: 37 additions & 0 deletions backend/src/controllers/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,40 @@ export const removeTask: RequestHandler = async (req, res, next) => {
next(error);
}
};

// Define a custom type for the request body so we can have static typing
// for the fields
type UpdateTaskBody = {
_id: string;
title: string;
description?: string;
isChecked: boolean;
dateCreated: Date;
};

export const updateTask: RequestHandler = async (req, res, next) => {
// extract any errors that were found by the validator
const errors = validationResult(req);
const { _id, title, description, isChecked, dateCreated } = req.body as UpdateTaskBody;
const { id } = req.params;
try {
// if there are errors, then this function throws an exception
validationErrorParser(errors);
if (id !== _id) {
res.status(400);
} else {
const task = await TaskModel.findByIdAndUpdate(
id,
{ $set: { title, description, isChecked, dateCreated } },
{ new: true },
);

if (task === null) {
throw createHttpError(404, "Task not found.");
}
res.status(200).json(task);
}
} catch (error) {
next(error);
}
};
12 changes: 12 additions & 0 deletions backend/src/controllers/tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import TaskModel from "src/models/task";

import type { RequestHandler } from "express";

export const getAllTasks: RequestHandler = async (req, res, next) => {
try {
const tasks = await TaskModel.find().sort({ dateCreated: "desc" });
res.status(200).json(tasks);
} catch (error) {
next(error);
}
};
1 change: 1 addition & 0 deletions backend/src/routes/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ router.get("/:id", TaskController.getTask);
*/
router.post("/", TaskValidator.createTask, TaskController.createTask);
router.delete("/:id", TaskController.removeTask);
router.put("/:id", TaskValidator.updateTask, TaskController.updateTask);

export default router;
8 changes: 8 additions & 0 deletions backend/src/routes/tasks.ts
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 22 additions & 1 deletion frontend/src/api/tasks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { get, handleAPIError, post } from "src/api/requests";
import { get, handleAPIError, post, put } from "src/api/requests";

import type { APIResult } from "src/api/requests";

Expand Down Expand Up @@ -94,3 +94,24 @@ export async function getTask(id: string): Promise<APIResult<Task>> {
return handleAPIError(error);
}
}

export async function getAllTasks(): Promise<APIResult<Task[]>> {
try {
const response = await get("/api/tasks");
const json = (await response.json()) as TaskJSON[];
const tasks = json.map((item) => parseTask(item));
return { success: true, data: tasks };
} catch (error) {
return handleAPIError(error);
}
}

export async function updateTask(task: UpdateTaskRequest): Promise<APIResult<Task>> {
try {
const response = await put(`/api/task/${task._id}`, task);
const json = (await response.json()) as TaskJSON;
return { success: true, data: parseTask(json) };
} catch (error) {
return handleAPIError(error);
}
}
38 changes: 38 additions & 0 deletions frontend/src/components/TaskItem.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.item {
height: 3rem;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
column-gap: 0.25rem;
}

.textContainer {
height: 100%;
border-bottom: 1px solid var(--color-text-secondary);
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
overflow: hidden;
}

.textContainer span {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.title {
font: var(--font-label);
}

.description {
font: var(--font-body);
}

.checked {
color: var(--color-text-secondary);
}
50 changes: 50 additions & 0 deletions frontend/src/components/TaskItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Dialog } from "@tritonse/tse-constellation";
import React, { useState } from "react";
import { type Task, updateTask } from "src/api/tasks";
import { CheckButton } from "src/components";
import styles from "src/components/TaskItem.module.css";

export type TaskItemProps = {
task: Task;
};

export function TaskItem({ task: initialTask }: TaskItemProps) {
const [task, setTask] = useState<Task>(initialTask);
const [isLoading, setLoading] = useState<boolean>(false);
const [errorModalMessage, setErrorModalMessage] = useState<string | null>(null);

const handleToggleCheck = () => {
setLoading(true);
updateTask({ ...task, isChecked: !task.isChecked })
.then((result) => {
if (result.success) {
setTask(result.data);
setLoading(false);
}
})
.catch(setErrorModalMessage);
};

let textContainerClass = styles.textContainer;
if (task.isChecked) {
textContainerClass += ` ${styles.checked}`;
}
return (
<div className={styles.item}>
<CheckButton checked={task.isChecked} onPress={handleToggleCheck} disabled={isLoading} />
<div className={textContainerClass}>
<span className={styles.title}>{task.title}</span>
{task.description && <span className={styles.description}>{task.description}</span>}
</div>
<Dialog
styleVersion="styled"
variant="error"
title="An error occurred"
// Override the text color so it doesn't show white text on a white background
content={<p className={styles.errorModalText}>{errorModalMessage}</p>}
isOpen={errorModalMessage !== null}
onClose={() => setErrorModalMessage(null)}
/>
</div>
);
}
18 changes: 18 additions & 0 deletions frontend/src/components/TaskList.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.listTitle {
font: var(--font-heading);
}

.itemContainer {
width: 100%;
justify-content: center;
flex-direction: column;
align-items: stretch;
}

.listContainer {
margin-top: 3rem;
}

.errorModalText {
color: var(--color-tse-navy-blue);
}
46 changes: 46 additions & 0 deletions frontend/src/components/TaskList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Dialog } from "@tritonse/tse-constellation";
import React, { useEffect, useState } from "react";
import { getAllTasks, type Task } from "src/api/tasks";
import { TaskItem } from "src/components";
import styles from "src/components/TaskList.module.css";

export type TaskListProps = {
title: string;
};

export function TaskList({ title }: TaskListProps) {
const [tasks, setTasks] = useState<Task[]>([]);
const [errorModalMessage, setErrorModalMessage] = useState<string | null>(null);

useEffect(() => {
getAllTasks()
.then((result) => {
if (result.success) {
setTasks(result.data);
}
})
.catch(setErrorModalMessage);
}, []);

return (
<div className={styles.listContainer}>
<span className={styles.listTitle}>{title}</span>
<div className={styles.itemContainer}>
{tasks.length === 0 ? (
<p>No tasks yet. Add one above to get started.</p>
) : (
tasks.map((task) => <TaskItem task={task} key={task._id} />)
)}
</div>
<Dialog
styleVersion="styled"
variant="error"
title="An error occurred"
// Override the text color so it doesn't show white text on a white background
content={<p className={styles.errorModalText}>{errorModalMessage}</p>}
isOpen={errorModalMessage !== null}
onClose={() => setErrorModalMessage(null)}
/>
</div>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export { CheckButton } from "./CheckButton";
export { HeaderBar } from "./HeaderBar";
export { Page } from "./Page";
export { TaskForm } from "./TaskForm";
export { TaskItem } from "./TaskItem";
export { TaskList } from "./TaskList";
export { TextField } from "./TextField";
3 changes: 2 additions & 1 deletion frontend/src/pages/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { Page, TaskForm } from "src/components";
import { Page, TaskForm, TaskList } from "src/components";

export function Home() {
return (
Expand All @@ -12,6 +12,7 @@ export function Home() {
<Link to="/about">About this app</Link>
</p>
<TaskForm mode="create" />
<TaskList title="All Tasks" />
</Page>
);
}