From a512f8e2b74a3ae7aa0d2804d30a5bcfc59f39d8 Mon Sep 17 00:00:00 2001 From: parth4apple <72187062+parth4apple@users.noreply.github.com> Date: Sun, 24 Mar 2024 11:39:20 -0700 Subject: [PATCH] V1.5? Admin Home Page (#66) * feat: add edit student route code * feat: add edit student route code again * feat: table skeleton * more functional table * feat: better file structure, styling changes * feat: better responsiveness * fix: rename folder * chore: lint fix * chore: fix import * chore: lint fix again * chore: fix interface/type issue * chore: investigate layout issues * chore: modify next config * fix: update api base url for prod * fix: typo * fix: update dropdown look --- backend/src/controllers/student.ts | 54 ++++- backend/src/models/student.ts | 4 +- backend/src/routes/student.ts | 4 +- backend/src/validators/program.ts | 7 +- backend/src/validators/student.ts | 50 +++-- frontend/next.config.js | 1 + frontend/package-lock.json | 197 ++++++++++++++++++ frontend/package.json | 3 + frontend/public/eye.svg | 4 + frontend/public/ic_round-arrow-drop-up.svg | 5 + frontend/public/noStudents.svg | 9 + frontend/src/api/requests.ts | 11 +- frontend/src/api/students.ts | 53 +++++ frontend/src/components/Checkbox.tsx | 3 +- frontend/src/components/DebouncedInput.tsx | 46 ++++ frontend/src/components/Dropdown.tsx | 96 +++++++++ frontend/src/components/Landing.tsx | 5 +- frontend/src/components/Navigation.tsx | 5 +- .../components/StudentForm/ContactInfo.tsx | 8 +- .../StudentForm/StudentBackground.tsx | 19 +- .../components/StudentForm/StudentInfo.tsx | 16 +- frontend/src/components/StudentForm/types.ts | 17 +- frontend/src/components/StudentFormButton.tsx | 119 ++++++++--- .../StudentsTable/ProgramFilter.tsx | 34 +++ .../StudentsTable/StudentsTable.tsx | 116 +++++++++++ .../src/components/StudentsTable/TBody.tsx | 45 ++++ .../src/components/StudentsTable/THead.tsx | 94 +++++++++ .../src/components/StudentsTable/types.ts | 7 + .../StudentsTable/useColumnSchema.tsx | 100 +++++++++ frontend/src/components/Textfield.tsx | 12 +- frontend/src/components/ui/dropdown.tsx | 187 +++++++++++++++++ frontend/src/components/ui/table.tsx | 91 ++++++++ frontend/src/hooks/useWindowSize.ts | 8 +- frontend/src/lib/formatPhoneNumber.ts | 8 + frontend/src/lib/fuzzyFilter.ts | 28 +++ frontend/src/pages/_app.tsx | 30 +-- frontend/src/pages/create_user.tsx | 10 +- frontend/src/pages/create_user_2.tsx | 10 +- frontend/src/pages/create_user_3.tsx | 10 +- frontend/src/pages/home.tsx | 8 +- frontend/src/pages/index.tsx | 10 +- frontend/src/pages/login.tsx | 12 +- frontend/tailwind.config.ts | 3 + 43 files changed, 1432 insertions(+), 127 deletions(-) create mode 100644 frontend/public/eye.svg create mode 100644 frontend/public/ic_round-arrow-drop-up.svg create mode 100644 frontend/public/noStudents.svg create mode 100644 frontend/src/api/students.ts create mode 100644 frontend/src/components/DebouncedInput.tsx create mode 100644 frontend/src/components/Dropdown.tsx create mode 100644 frontend/src/components/StudentsTable/ProgramFilter.tsx create mode 100644 frontend/src/components/StudentsTable/StudentsTable.tsx create mode 100644 frontend/src/components/StudentsTable/TBody.tsx create mode 100644 frontend/src/components/StudentsTable/THead.tsx create mode 100644 frontend/src/components/StudentsTable/types.ts create mode 100644 frontend/src/components/StudentsTable/useColumnSchema.tsx create mode 100644 frontend/src/components/ui/dropdown.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/lib/formatPhoneNumber.ts create mode 100644 frontend/src/lib/fuzzyFilter.ts diff --git a/backend/src/controllers/student.ts b/backend/src/controllers/student.ts index 911d6ac9..9d73a52e 100644 --- a/backend/src/controllers/student.ts +++ b/backend/src/controllers/student.ts @@ -25,12 +25,35 @@ export type typedModel = { birthday: string; intakeDate: string; tourDate: string; - prog1: string[]; - prog2: string[]; + regularPrograms: string[]; + varyingPrograms: string[]; dietary: string[]; otherString: string; }; +type Contact = { + lastName: string; + firstName: string; + email: string; + phoneNumber: string; +}; + +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 const createStudent: RequestHandler = async (req, res, next) => { try { const errors = validationResult(req); @@ -45,6 +68,33 @@ export const createStudent: RequestHandler = async (req, res, next) => { } }; +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 Student; + + 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(); 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 f1713f64..8828db8c 100644 --- a/backend/src/routes/student.ts +++ b/backend/src/routes/student.ts @@ -9,7 +9,9 @@ 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); router.get("/all", StudentController.getAllStudents); diff --git a/backend/src/validators/program.ts b/backend/src/validators/program.ts index 488f214c..48650ddf 100644 --- a/backend/src/validators/program.ts +++ b/backend/src/validators/program.ts @@ -11,7 +11,12 @@ const makeNameValidator = () => .withMessage("name must be a string") .bail() .notEmpty() - .withMessage("name must not be empty"); + .withMessage("name must not be empty") + .custom((value: string) => { + // needed for students table dropdown filtering + if (value.includes(";")) throw new Error("name cannot contain semicolons"); + return true; + }); const makeAbbreviationValidator = () => body("abbreviation") .exists() diff --git a/backend/src/validators/student.ts b/backend/src/validators/student.ts index 223dfa56..19ede088 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,27 @@ 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({ min: 1 }) + .withMessage("Regular Programs must be a non-empty array") + .bail(); //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({ min: 1 }) + .withMessage("Varying Programs must be a non-empty array"); //dietary //validates entire array @@ -146,9 +160,11 @@ export const createStudent = [ makeBirthdayValidator(), makeIntakeDateValidator(), makeTourDateValidator(), - makeProg1Validator(), - makeProg2Validator(), + makeRegularProgramsValidator(), + makeVaryingProgramsValidator(), makeDietaryArrayValidator(), makeDietaryItemsValidator(), makeDietaryOtherValidator(), ]; + +export const editStudent = [...createStudent, makeIdValidator()]; diff --git a/frontend/next.config.js b/frontend/next.config.js index 8d7f01b5..0efa51b1 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -4,6 +4,7 @@ const nextConfig = { images: { unoptimized: true, }, + trailingSlash: true, }; module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5790dfc2..7a94f9c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,8 +10,11 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-slot": "^1.0.2", + "@tanstack/match-sorter-utils": "^8.11.8", + "@tanstack/react-table": "^8.12.0", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "autoprefixer": "^10.4.16", @@ -2250,6 +2253,32 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", @@ -2320,6 +2349,23 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", @@ -2347,6 +2393,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz", + "integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", @@ -2407,6 +2482,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz", + "integrity": "sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", @@ -2546,6 +2661,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", + "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", @@ -2713,6 +2859,52 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.11.8", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.8.tgz", + "integrity": "sha512-3VPh0SYMGCa5dWQEqNab87UpCMk+ANWHDP4ALs5PeEW9EpfTAbrezzaOk/OiM52IESViefkoAOYuxdoa04p6aA==", + "dependencies": { + "remove-accents": "0.4.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.12.0.tgz", + "integrity": "sha512-LlEQ1Gpz4bfpiET+qmle4BhKDgKN3Y/sssc+O/wLqX8HRtjV+nhusYbllZlutZfMR8oeef83whKTj/VhaV8EeA==", + "dependencies": { + "@tanstack/table-core": "8.12.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.12.0.tgz", + "integrity": "sha512-cq/ylWVrOwixmwNXQjgZaQw1Izf7+nPxjczum7paAnMtwPg1S2qRAJU+Jb8rEBUWm69voC/zcChmePlk2hc6ug==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.3", "license": "MIT", @@ -7762,6 +7954,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "node_modules/require-directory": { "version": "2.1.1", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index d95ffef2..5666bd7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,11 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-slot": "^1.0.2", + "@tanstack/match-sorter-utils": "^8.11.8", + "@tanstack/react-table": "^8.12.0", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "autoprefixer": "^10.4.16", diff --git a/frontend/public/eye.svg b/frontend/public/eye.svg new file mode 100644 index 00000000..aac79031 --- /dev/null +++ b/frontend/public/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/ic_round-arrow-drop-up.svg b/frontend/public/ic_round-arrow-drop-up.svg new file mode 100644 index 00000000..1500545d --- /dev/null +++ b/frontend/public/ic_round-arrow-drop-up.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/noStudents.svg b/frontend/public/noStudents.svg new file mode 100644 index 00000000..92561354 --- /dev/null +++ b/frontend/public/noStudents.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts index 01e0c7b4..be5ab874 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 ?? "/api"; /** * 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..4f89f69e --- /dev/null +++ b/frontend/src/api/students.ts @@ -0,0 +1,53 @@ +import { GET, POST, PUT, handleAPIError } from "../api/requests"; +import { Contact, StudentData } from "../components/StudentForm/types"; + +import type { APIResult } from "../api/requests"; + +export type CreateStudentRequest = StudentData; + +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 async function createStudent(student: CreateStudentRequest): Promise> { + try { + const response = await POST("/student/create", student); + const json = (await response.json()) as Student; + console.log({ json }); + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function editStudent(student: Student): Promise> { + try { + const response = await PUT(`/student/edit/${student._id}`, student); + const json = (await response.json()) as Student; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function getAllStudents(): Promise> { + try { + const response = await GET("/student/all"); + const json = (await response.json()) as [Student]; + 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/DebouncedInput.tsx b/frontend/src/components/DebouncedInput.tsx new file mode 100644 index 00000000..1ddced1c --- /dev/null +++ b/frontend/src/components/DebouncedInput.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; + +import { Textfield } from "./Textfield"; + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + placeholder = "", +}: { + value: string; + onChange: (val: string) => void; + debounce?: number; + placeholder?: string; +} & Omit, "onChange">) => { + const [value, setValue] = useState(initialValue); + const { register } = useForm(); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value); + }, debounce); + + return () => { + clearTimeout(timeout); + }; + }, [value]); + + return ( + { + setValue(e.target.value); + }} + /> + ); +}; + +export default DebouncedInput; diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx new file mode 100644 index 00000000..0d97f000 --- /dev/null +++ b/frontend/src/components/Dropdown.tsx @@ -0,0 +1,96 @@ +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { FieldValues, Path, PathValue, UseFormSetValue } from "react-hook-form"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../components/ui/dropdown"; + +import { cn } from "@/lib/utils"; + +type BaseProps = { + name: Path; + label?: string; + placeholder: string; + defaultValue?: string; + className?: string; +}; + +type DropdownProps = BaseProps & { + options: string[]; + setDropdownValue?: UseFormSetValue; + onChange?: (val: string) => void; +}; + +export function Dropdown({ + setDropdownValue, + label, + name, + options, + onChange = () => void 0, + defaultValue = "", + className, +}: DropdownProps) { + const [selectedOption, setSelectedOption] = useState(defaultValue); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (selectedOption && setDropdownValue) { + setDropdownValue(name, selectedOption as PathValue>); + } + }, [selectedOption]); + + return ( + { + setOpen(!open); + }} + > + + {label + ": "} + {selectedOption ?? ""} + dropdown toggle + + + { + onChange(""); + setSelectedOption(defaultValue); + }} + > + {defaultValue} + + {options.map((option) => ( + { + onChange(option); + setSelectedOption(option); + }} + > + {option} + + ))} + + + ); +} diff --git a/frontend/src/components/Landing.tsx b/frontend/src/components/Landing.tsx index 842c9f0a..72a2400a 100644 --- a/frontend/src/components/Landing.tsx +++ b/frontend/src/components/Landing.tsx @@ -6,7 +6,7 @@ */ import { Poppins } from "next/font/google"; import Image from "next/image"; -import React, { useMemo } from "react"; +import React from "react"; import { useWindowSize } from "../hooks/useWindowSize"; import { cn } from "../lib/utils"; @@ -32,8 +32,7 @@ const Logo = () => { // Navigation component that wraps the content of the page function Landing({ children }: { children: React.ReactNode }) { - const { width } = useWindowSize(); - const isMobile = useMemo(() => width <= 640, [width]); + const { isMobile } = useWindowSize(); return (
width <= 640, [width]); + const { isMobile } = useWindowSize(); useEffect(() => { const ordering = navigation.map((item) => item.href); diff --git a/frontend/src/components/StudentForm/ContactInfo.tsx b/frontend/src/components/StudentForm/ContactInfo.tsx index 30defb6d..a7f73f18 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 { Student } 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: Student | null; }; type FieldProps = { @@ -63,7 +64,8 @@ export default function ContactInfo({ register, type, data, classname }: Contact const toTitleCase = (word: string) => { return word - .split("_") + .replace(/[A-Z]/g, " $&") + .split(" ") .map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()) .join(" "); }; diff --git a/frontend/src/components/StudentForm/StudentBackground.tsx b/frontend/src/components/StudentForm/StudentBackground.tsx index 59d1bcea..6bd8be40 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 { Student } 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: Student | 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..07532dc1 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 { Student } 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: Student | 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..c86a6c97 100644 --- a/frontend/src/components/StudentFormButton.tsx +++ b/frontend/src/components/StudentFormButton.tsx @@ -1,6 +1,8 @@ -import { useState } from "react"; +import Image from "next/image"; +import { Dispatch, SetStateAction, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; +import { Student, createStudent, editStudent } from "../api/students"; import { cn } from "../lib/utils"; import { Button } from "./Button"; @@ -8,32 +10,45 @@ import ContactInfo from "./StudentForm/ContactInfo"; import StudentBackground from "./StudentForm/StudentBackground"; import StudentInfo from "./StudentForm/StudentInfo"; import { StudentData, StudentFormData } from "./StudentForm/types"; +import { StudentMap } from "./StudentsTable/types"; import { Dialog, DialogClose, DialogContent, DialogTrigger } from "./ui/dialog"; type BaseProps = { classname?: string; + setAllStudents: Dispatch>; }; type EditProps = BaseProps & { type: "edit"; - data: StudentData | null; + data: Student | null; }; type AddProps = BaseProps & { type: "add"; - data?: StudentData | null; + data?: Student | 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: [], dietary: [] }, + }); + //Default values can be set for all fields but I specified these three 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,34 +70,88 @@ 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: StudentMap) => { + 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: Student = { ...transformedData, _id: data._id }; + editStudent(editedData).then( + (result) => { + if (result.success) { + const editedStudent = result.data; + setOpenForm(false); + setAllStudents((prevStudents: StudentMap) => { + 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 ( <> - - -