From 63b3905bd27a689c34692c9018a946c1c8fbb18e Mon Sep 17 00:00:00 2001 From: Aaron Chan <42254254+aaronchan32@users.noreply.github.com> Date: Fri, 16 Feb 2024 23:18:43 -0800 Subject: [PATCH 1/9] feat: combine edit student backend and frontend --- backend/src/controllers/student.ts | 43 ++++++++- backend/src/errors/handler.ts | 4 +- backend/src/models/student.ts | 4 +- backend/src/routes/student.ts | 4 +- backend/src/validators/student.ts | 49 ++++++---- frontend/src/api/requests.ts | 11 ++- frontend/src/api/students.ts | 86 +++++++++++++++++ frontend/src/components/Checkbox.tsx | 3 +- .../components/StudentForm/ContactInfo.tsx | 5 +- .../StudentForm/StudentBackground.tsx | 19 +++- .../components/StudentForm/StudentInfo.tsx | 16 ++-- frontend/src/components/StudentForm/types.ts | 17 ++-- frontend/src/components/StudentFormButton.tsx | 94 +++++++++++++++---- frontend/src/components/Textfield.tsx | 12 +-- frontend/src/pages/index.tsx | 70 ++++++++------ frontend/src/sampleStudentData.json | 29 ------ 16 files changed, 328 insertions(+), 138 deletions(-) create mode 100644 frontend/src/api/students.ts delete mode 100644 frontend/src/sampleStudentData.json diff --git a/backend/src/controllers/student.ts b/backend/src/controllers/student.ts index 5892a84f..886ff385 100644 --- a/backend/src/controllers/student.ts +++ b/backend/src/controllers/student.ts @@ -9,6 +9,8 @@ import { validationResult } from "express-validator"; import StudentModel from "../models/student"; import validationErrorParser from "../util/validationErrorParser"; +import { StudentJSON } from "./../../../frontend/src/api/students"; + export type contact = { lastName: string; firstName: string; @@ -25,8 +27,8 @@ export type typedModel = { birthday: string; intakeDate: string; tourDate: string; - prog1: string[]; - prog2: string[]; + regularPrograms: string[]; + varyingPrograms: string[]; dietary: string[]; otherString: string; }; @@ -44,3 +46,40 @@ export const createStudent: RequestHandler = async (req, res, next) => { next(error); } }; + +export const editStudent: RequestHandler = async (req, res, next) => { + try { + const errors = validationResult(req); + + validationErrorParser(errors); + + const studentId = req.params.id; + const studentData = req.body as StudentJSON; + + if (studentId !== studentData._id) { + return res.status(400).json({ message: "Invalid student ID" }); + } + + const editedStudent = await StudentModel.findOneAndUpdate({ _id: studentId }, studentData, { + new: true, + }); + + if (!editedStudent) { + return res.status(404).json({ message: "No object in database with provided ID" }); + } + + res.status(200).json(editedStudent); + } catch (error) { + next(error); + } +}; + +export const getAllStudents: RequestHandler = async (_, res, next) => { + try { + const students = await StudentModel.find(); + + res.status(200).json(students); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/errors/handler.ts b/backend/src/errors/handler.ts index d55e5774..e5720a68 100644 --- a/backend/src/errors/handler.ts +++ b/backend/src/errors/handler.ts @@ -1,4 +1,4 @@ -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import { CustomError } from "./errors"; import { InternalError } from "./internal"; @@ -6,7 +6,7 @@ import { InternalError } from "./internal"; /** * Generic Error Handler */ -export const errorHandler = (err: Error, req: Request, res: Response) => { +export const errorHandler = (err: Error, req: Request, res: Response, _: NextFunction) => { if (!err) return; if (err instanceof CustomError && !(err instanceof InternalError)) { console.log(err.displayMessage(true)); diff --git a/backend/src/models/student.ts b/backend/src/models/student.ts index 03589a21..3aac0f24 100644 --- a/backend/src/models/student.ts +++ b/backend/src/models/student.ts @@ -37,8 +37,8 @@ const studentSchema = new Schema({ //For now, chose to express these as a list of strings. Will probably be replaced with //program subdocs in the future once they have been defined - prog1: { type: [String], required: true }, - prog2: { type: [String], default: "" }, + regularPrograms: { type: [String], required: true }, + varyingPrograms: { type: [String], required: true }, //Will contain list of all dietary restrictions dietary: { type: [String] }, diff --git a/backend/src/routes/student.ts b/backend/src/routes/student.ts index 918f9452..a534d31f 100644 --- a/backend/src/routes/student.ts +++ b/backend/src/routes/student.ts @@ -9,6 +9,8 @@ import * as StudentValidator from "../validators/student"; const router = express.Router(); -router.post("/", StudentValidator.createStudent, StudentController.createStudent); +router.post("/create", StudentValidator.createStudent, StudentController.createStudent); +router.put("/edit/:id", StudentValidator.editStudent, StudentController.editStudent); +router.get("/all", StudentController.getAllStudents); export default router; diff --git a/backend/src/validators/student.ts b/backend/src/validators/student.ts index 223dfa56..e51c8db3 100644 --- a/backend/src/validators/student.ts +++ b/backend/src/validators/student.ts @@ -3,8 +3,18 @@ */ import { body } from "express-validator"; +import mongoose from "mongoose"; //designed these to use the globstar operator from express-validator to more cleanly + +const makeIdValidator = () => + body("**._id") + .exists() + .withMessage("ID is required") + .bail() + .custom((value: string) => mongoose.Types.ObjectId.isValid(value)) + .withMessage("Mongo ID format is invalid"); + //validate contacts const makeLastNamesValidator = () => body("**.lastName") @@ -43,16 +53,11 @@ const makeEmailsValidator = () => const makePhoneNumbersValidator = () => body("**.phoneNumber") .trim() - .exists() + .exists({ checkFalsy: true }) .withMessage("Phone number required") .bail() - .isNumeric() - .withMessage("Field must have a valid number") - .isLength({ min: 10, max: 11 }) - .withMessage("Phone number has an incorrect length") - .bail() - .notEmpty() - .withMessage("Phone number required"); + .matches(/^(?:\d{10}|\d{3}-\d{3}-\d{4})$/) + .withMessage("Phone number must be in the format 1234567890 or 123-123-1234"); //validate location const makeLocationValidator = () => @@ -85,6 +90,7 @@ const makeBirthdayValidator = () => .withMessage("Birthday field required") .bail() .isISO8601() + .toDate() .withMessage("Birthday string must be a valid date-time string"); //intake date @@ -94,6 +100,7 @@ const makeIntakeDateValidator = () => .withMessage("Intake Date field required") .bail() .isISO8601() + .toDate() .withMessage("Intake Date string must be a valid date-time string"); //tour date @@ -103,20 +110,26 @@ const makeTourDateValidator = () => .withMessage("Tour Date field required") .bail() .isISO8601() + .toDate() .withMessage("Tour Date string must be a valid date-time string"); //prog1 --placeholder, will later validate for a program objectid -const makeProg1Validator = () => - body("prog1") +const makeRegularProgramsValidator = () => + body("regularPrograms") .exists() - .withMessage("Program 1 field required") + .withMessage("Regular Programs field required") .bail() - .isString() - .withMessage("Program 1 must be a string"); + .isArray() + .withMessage("Regular Programs must be an array"); //prog2 -const makeProg2Validator = () => - body("prog2").optional().isString().withMessage("Program 2 must be a string"); +const makeVaryingProgramsValidator = () => + body("varyingPrograms") + .exists() + .withMessage("Varying Programs field required") + .bail() + .isArray() + .withMessage("Varying Programs must be an array"); //dietary //validates entire array @@ -146,9 +159,11 @@ export const createStudent = [ makeBirthdayValidator(), makeIntakeDateValidator(), makeTourDateValidator(), - makeProg1Validator(), - makeProg2Validator(), + makeRegularProgramsValidator(), + makeVaryingProgramsValidator(), makeDietaryArrayValidator(), makeDietaryItemsValidator(), makeDietaryOtherValidator(), ]; + +export const editStudent = [...createStudent, makeIdValidator()]; diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts index 01e0c7b4..4be97c64 100644 --- a/frontend/src/api/requests.ts +++ b/frontend/src/api/requests.ts @@ -7,6 +7,7 @@ * Custom type definition for the HTTP methods handled by this module. */ type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; /** * Throws an error if the status code of the HTTP response indicates an error. If an HTTP error was @@ -68,7 +69,7 @@ async function fetchRequest( * @returns A `Response` object returned by `fetch()` */ export async function GET(url: string, headers: Record = {}): Promise { - return await fetchRequest("GET", url, undefined, headers); + return await fetchRequest("GET", API_BASE_URL + url, undefined, headers); } /** @@ -84,7 +85,7 @@ export async function POST( body: unknown, headers: Record = {}, ): Promise { - return await fetchRequest("POST", url, body, headers); + return await fetchRequest("POST", API_BASE_URL + url, body, headers); } /** @@ -100,7 +101,7 @@ export async function PUT( body: unknown, headers: Record = {}, ): Promise { - return await fetchRequest("PUT", url, body, headers); + return await fetchRequest("PUT", API_BASE_URL + url, body, headers); } /** @@ -116,7 +117,7 @@ export async function PATCH( body: unknown, headers: Record = {}, ): Promise { - return await fetchRequest("PATCH", url, body, headers); + return await fetchRequest("PATCH", API_BASE_URL + url, body, headers); } /** @@ -132,7 +133,7 @@ export async function DELETE( body: unknown, headers: Record = {}, ): Promise { - return await fetchRequest("DELETE", url, body, headers); + return await fetchRequest("DELETE", API_BASE_URL + url, body, headers); } /** diff --git a/frontend/src/api/students.ts b/frontend/src/api/students.ts new file mode 100644 index 00000000..2eedffac --- /dev/null +++ b/frontend/src/api/students.ts @@ -0,0 +1,86 @@ +import { GET, POST, PUT, handleAPIError } from "../api/requests"; +import { Contact, StudentData } from "../components/StudentForm/types"; + +import type { APIResult } from "../api/requests"; + +export type Student = { + _id: string; + student: Contact; + emergency: Contact; + serviceCoordinator: Contact; + location: string; + medication?: string; + birthday: Date; + intakeDate: Date; + tourDate: Date; + regularPrograms: string[]; + varyingPrograms: string[]; + dietary: string[]; + otherString?: string; +}; + +export type CreateStudentRequest = StudentData; + +export type StudentJSON = { + _id: string; + student: Contact; + emergency: Contact; + serviceCoordinator: Contact; + location: string; + medication?: string; + birthday: Date; + intakeDate: Date; + tourDate: Date; + regularPrograms: string[]; + varyingPrograms: string[]; + dietary: string[]; + otherString?: string; +}; + +function parseStudent(studentJSON: StudentJSON): Student { + return { + _id: studentJSON._id, + student: studentJSON.student, + emergency: studentJSON.emergency, + serviceCoordinator: studentJSON.serviceCoordinator, + location: studentJSON.location, + medication: studentJSON.medication, + birthday: studentJSON.birthday, + intakeDate: studentJSON.intakeDate, + tourDate: studentJSON.tourDate, + regularPrograms: studentJSON.regularPrograms, + varyingPrograms: studentJSON.varyingPrograms, + dietary: studentJSON.dietary, + otherString: studentJSON.otherString, + } as Student; +} + +export async function createStudent(student: CreateStudentRequest): Promise> { + try { + const response = await POST("/student/create", student); + const json = (await response.json()) as StudentJSON; + return { success: true, data: parseStudent(json) }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function editStudent(student: StudentJSON): Promise> { + try { + const response = await PUT(`/student/edit/${student._id}`, student); + const json = (await response.json()) as StudentJSON; + return { success: true, data: parseStudent(json) }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function getAllStudents(): Promise> { + try { + const response = await GET("/student/all"); + const json = (await response.json()) as [StudentJSON]; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/components/Checkbox.tsx b/frontend/src/components/Checkbox.tsx index 47663286..0606498b 100644 --- a/frontend/src/components/Checkbox.tsx +++ b/frontend/src/components/Checkbox.tsx @@ -1,4 +1,3 @@ -"use client"; import { FieldValues, Path, UseFormRegister } from "react-hook-form"; import { cn } from "../lib/utils"; @@ -22,6 +21,8 @@ export function Checkbox({ defaultValue, defaultOtherValue, }: CheckboxProps) { + defaultValue = defaultValue?.map((item) => item.toLowerCase()); + return (
{options.map((item, index) => { diff --git a/frontend/src/components/StudentForm/ContactInfo.tsx b/frontend/src/components/StudentForm/ContactInfo.tsx index 30defb6d..ccbc6f3b 100644 --- a/frontend/src/components/StudentForm/ContactInfo.tsx +++ b/frontend/src/components/StudentForm/ContactInfo.tsx @@ -1,9 +1,10 @@ import { UseFormRegister } from "react-hook-form"; +import { StudentJSON } from "../../api/students"; import { cn } from "../../lib/utils"; import { Textfield } from "../Textfield"; -import { StudentData, StudentFormData } from "./types"; +import { StudentFormData } from "./types"; type ContactRole = "student" | "emergency" | "serviceCoordinator"; @@ -13,7 +14,7 @@ type ContactInfoProps = { register: UseFormRegister; classname?: string; type: "add" | "edit"; - data: StudentData | null; + data: StudentJSON | null; }; type FieldProps = { diff --git a/frontend/src/components/StudentForm/StudentBackground.tsx b/frontend/src/components/StudentForm/StudentBackground.tsx index 59d1bcea..b29e4a37 100644 --- a/frontend/src/components/StudentForm/StudentBackground.tsx +++ b/frontend/src/components/StudentForm/StudentBackground.tsx @@ -1,20 +1,31 @@ import { UseFormRegister, UseFormSetValue } from "react-hook-form"; +import { StudentJSON } from "../../api/students"; import { cn } from "../../lib/utils"; import { Checkbox } from "../Checkbox"; import { Textfield } from "../Textfield"; -import { StudentData, StudentFormData } from "./types"; +import { StudentFormData } from "./types"; type StudentBackgroundProps = { register: UseFormRegister; classname?: string; setCalendarValue: UseFormSetValue; - data: StudentData | null; + data: StudentJSON | null; }; const dietaryList = ["Nuts", "Eggs", "Seafood", "Pollen", "Dairy", "Other"]; +export const convertDateToString = (date: Date | undefined) => { + return date + ? new Date(date).toLocaleDateString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + : ""; +}; + export default function StudentBackground({ data, register, @@ -41,7 +52,7 @@ export default function StudentBackground({ placeholder="00/00/0000" calendar={true} setCalendarValue={setCalendarValue} - defaultValue={data?.birthday} + defaultValue={convertDateToString(data?.birthday)} />
@@ -60,7 +71,7 @@ export default function StudentBackground({ register={register} name="dietary" options={dietaryList} - defaultValue={data?.dietary.map((item) => item.toLowerCase())} + defaultValue={data?.dietary} defaultOtherValue={data?.otherString} className="sm:grid-cols-2 min-[1150px]:grid-cols-3" /> diff --git a/frontend/src/components/StudentForm/StudentInfo.tsx b/frontend/src/components/StudentForm/StudentInfo.tsx index bcec3220..f57547e7 100644 --- a/frontend/src/components/StudentForm/StudentInfo.tsx +++ b/frontend/src/components/StudentForm/StudentInfo.tsx @@ -1,16 +1,18 @@ import { UseFormRegister, UseFormSetValue } from "react-hook-form"; +import { StudentJSON } from "../../api/students"; import { cn } from "../../lib/utils"; import { Checkbox } from "../Checkbox"; import { Textfield } from "../Textfield"; -import { StudentData, StudentFormData } from "./types"; +import { convertDateToString } from "./StudentBackground"; +import { StudentFormData } from "./types"; type StudentInfoProps = { register: UseFormRegister; classname?: string; setCalendarValue: UseFormSetValue; - data: StudentData | null; + data: StudentJSON | null; }; const regularPrograms = ["Intro", "ENTR"]; @@ -33,7 +35,7 @@ export default function StudentInfo({ placeholder="00/00/0000" calendar={true} setCalendarValue={setCalendarValue} - defaultValue={data?.intakeDate} + defaultValue={convertDateToString(data?.intakeDate)} />
@@ -44,7 +46,7 @@ export default function StudentInfo({ placeholder="00/00/0000" calendar={true} setCalendarValue={setCalendarValue} - defaultValue={data?.tourDate} + defaultValue={convertDateToString(data?.tourDate)} />
@@ -55,16 +57,16 @@ export default function StudentInfo({ register={register} name="regular_programs" options={regularPrograms} - defaultValue={data?.prog1} + defaultValue={data?.regularPrograms} />

Varying Programs

diff --git a/frontend/src/components/StudentForm/types.ts b/frontend/src/components/StudentForm/types.ts index ec93a05b..5b6f025f 100644 --- a/frontend/src/components/StudentForm/types.ts +++ b/frontend/src/components/StudentForm/types.ts @@ -11,11 +11,11 @@ export type StudentData = { serviceCoordinator: Contact; location: string; medication: string; - birthday: string; - intakeDate: string; - tourDate: string; - prog1: string[]; - prog2: string[]; + birthday: Date; + intakeDate: Date; + tourDate: Date; + regularPrograms: string[]; + varyingPrograms: string[]; dietary: string[]; otherString: string; }; @@ -34,11 +34,12 @@ export type StudentFormData = { serviceCoordinator_email: string; serviceCoordinator_phone: string; address: string; - birthdate: string; + birthdate: Date; medication: string; dietary: string[]; other: string; - intake_date: string; - tour_date: string; + intake_date: Date; + tour_date: Date; regular_programs: string[]; + varying_programs: string[]; }; diff --git a/frontend/src/components/StudentFormButton.tsx b/frontend/src/components/StudentFormButton.tsx index bd19010b..599b8793 100644 --- a/frontend/src/components/StudentFormButton.tsx +++ b/frontend/src/components/StudentFormButton.tsx @@ -1,7 +1,9 @@ -import { useState } from "react"; +import { Dispatch, SetStateAction, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; +import { StudentJSON, createStudent, editStudent } from "../api/students"; import { cn } from "../lib/utils"; +import { StudentMap } from "../pages"; import { Button } from "./Button"; import ContactInfo from "./StudentForm/ContactInfo"; @@ -12,28 +14,38 @@ import { Dialog, DialogClose, DialogContent, DialogTrigger } from "./ui/dialog"; type BaseProps = { classname?: string; + setAllStudents: Dispatch>; }; type EditProps = BaseProps & { type: "edit"; - data: StudentData | null; + data: StudentJSON | null; }; type AddProps = BaseProps & { type: "add"; - data?: StudentData | null; + data?: StudentJSON | null; }; type StudentFormProps = EditProps | AddProps; export default function StudentFormButton({ - type = "edit", - data = null, + type, + data = null, //Student data so form can be populated + setAllStudents, //Update state of allStudents after creating or editing student classname, }: StudentFormProps) { - const { register, setValue: setCalendarValue, reset, handleSubmit } = useForm(); + const { + register, + setValue: setCalendarValue, + reset, + handleSubmit, + } = useForm({ defaultValues: { varying_programs: [], regular_programs: [] } }); + //Default values can be set for all fields but I specified these two fields because the checkbox value can sometimes be a string if it's a single value rather than array of strings. https://github.com/react-hook-form/react-hook-form/releases/tag/v7.30.0 - const onSubmit: SubmitHandler = (formData: StudentFormData) => { + const [openForm, setOpenForm] = useState(false); + + const onFormSubmit: SubmitHandler = (formData: StudentFormData) => { const transformedData: StudentData = { student: { firstName: formData.student_name, @@ -55,19 +67,63 @@ export default function StudentFormButton({ }, location: formData.address, medication: formData.medication, - birthday: formData.birthdate, - intakeDate: formData.intake_date, - tourDate: formData.tour_date, - prog1: formData.regular_programs, - prog2: formData.regular_programs, + birthday: new Date(formData.birthdate), + intakeDate: new Date(formData.intake_date), + tourDate: new Date(formData.tour_date), + regularPrograms: formData.regular_programs, + varyingPrograms: formData.varying_programs, dietary: formData.dietary, otherString: formData.other, }; - reset(); //Clear form - console.log(`${type} student data:`, transformedData); - }; - const [openForm, setOpenForm] = useState(false); + if (type === "add") { + createStudent(transformedData).then( + (result) => { + if (result.success) { + const newStudent = result.data; + reset(); // only clear form on success + setOpenForm(false); + setAllStudents((prevStudents) => { + return { ...prevStudents, [newStudent._id]: newStudent }; + }); + } else { + console.log(result.error); + alert("Unable to create student: " + result.error); + } + }, + (error) => { + console.log(error); + }, + ); + } + + if (type === "edit" && data) { + const editedData: StudentJSON = { ...transformedData, _id: data._id }; + editStudent(editedData).then( + (result) => { + if (result.success) { + const editedStudent = result.data; + setOpenForm(false); + setAllStudents((prevStudents) => { + if (Object.keys(prevStudents).includes(editedStudent._id)) { + return { ...prevStudents, [editedStudent._id]: editedStudent }; + } else { + console.log("Student ID is invalid"); + alert("Student ID is invalid"); + return prevStudents; + } + }); + } else { + console.log(result.error); + alert("Unable to edit student: " + result.error); + } + }, + (error) => { + console.log(error); + }, + ); + } + }; return ( <> @@ -82,7 +138,7 @@ export default function StudentFormButton({