diff --git a/devU-api/src/entities/assignment/assignment.controller.ts b/devU-api/src/entities/assignment/assignment.controller.ts index 878df67..ab4e20a 100644 --- a/devU-api/src/entities/assignment/assignment.controller.ts +++ b/devU-api/src/entities/assignment/assignment.controller.ts @@ -5,8 +5,7 @@ import AssignmentService from './assignment.service' import { GenericResponse, NotFound, Updated } from '../../utils/apiResponse.utils' import { serialize } from './assignment.serializer' -import { BucketNames, downloadFile, uploadFile } from '../../fileStorage' -import { generateFilename } from '../../utils/fileUpload.utils' +import { BucketNames, downloadFile } from '../../fileStorage' export async function detail(req: Request, res: Response, next: NextFunction) { try { @@ -85,34 +84,9 @@ export async function getReleased(req: Request, res: Response, next: NextFunctio } -async function processFiles(req: Request) { - let fileHashes: string[] = [] - let fileNames: string[] = [] - - // save files - if (req.files) { - console.log() - if (Array.isArray(req.files)) { - for (let index = 0; index < req.files.length; index++) { - const item = req.files[index] - const filename = generateFilename(item.originalname, item.size) - await uploadFile(BucketNames.ASSIGNMENTSATTACHMENTS, item, filename) - fileHashes.push(filename) - fileNames.push(item.originalname) - } - } else { - console.warn(`Files where not in array format ${req.files}`) - } - } else { - console.warn(`No files where processed`) - } - - return { fileHashes, fileNames } -} - export async function post(req: Request, res: Response, next: NextFunction) { try { - const { fileNames, fileHashes } = await processFiles(req) + const { fileNames, fileHashes } = await AssignmentService.processFiles(req) req.body['attachmentsFilenames'] = fileNames req.body['attachmentsHashes'] = fileHashes @@ -130,7 +104,7 @@ export async function post(req: Request, res: Response, next: NextFunction) { export async function put(req: Request, res: Response, next: NextFunction) { try { - const { fileNames, fileHashes } = await processFiles(req) + const { fileNames, fileHashes } = await AssignmentService.processFiles(req) req.body['attachmentsFilenames'] = fileNames req.body['attachmentsHashes'] = fileHashes diff --git a/devU-api/src/entities/assignment/assignment.service.ts b/devU-api/src/entities/assignment/assignment.service.ts index 312d03a..2948264 100644 --- a/devU-api/src/entities/assignment/assignment.service.ts +++ b/devU-api/src/entities/assignment/assignment.service.ts @@ -4,6 +4,9 @@ import { dataSource } from '../../database' import AssignmentModel from './assignment.model' import { Assignment } from 'devu-shared-modules' +import { Request } from 'express' +import { generateFilename } from '../../utils/fileUpload.utils' +import { BucketNames, uploadFile } from '../../fileStorage' const connect = () => dataSource.getRepository(AssignmentModel) @@ -40,7 +43,7 @@ export async function update(assignment: Assignment) { maxSubmissions, disableHandins, attachmentsHashes, - attachmentsFilenames + attachmentsFilenames, }) } @@ -78,6 +81,36 @@ export async function isReleased(id: number) { return startDate && startDate < currentDate } +async function getMaxSubmissionsAndDeadline(id: number) { + return await connect().findOne({ where: { id: id, deletedAt: IsNull() }, select: ['maxSubmissions', 'maxFileSize', 'disableHandins', 'endDate'] }) +} + +async function processFiles(req: Request) { + let fileHashes: string[] = [] + let fileNames: string[] = [] + + // save files + if (req.files) { + console.log() + if (Array.isArray(req.files)) { + for (let index = 0; index < req.files.length; index++) { + const item = req.files[index] + const filename = generateFilename(item.originalname, item.size) + await uploadFile(BucketNames.ASSIGNMENTSATTACHMENTS, item, filename) + fileHashes.push(filename) + fileNames.push(item.originalname) + } + } else { + console.warn(`Files where not in array format ${req.files}`) + } + } else { + console.warn(`No files where processed`) + } + + return { fileHashes, fileNames } +} + + export default { create, retrieve, @@ -87,4 +120,6 @@ export default { listByCourse, listByCourseReleased, isReleased, + getMaxSubmissionsForAssignment: getMaxSubmissionsAndDeadline, + processFiles, } diff --git a/devU-api/src/entities/submission/submission.middleware.ts b/devU-api/src/entities/submission/submission.middleware.ts new file mode 100644 index 0000000..1e3af18 --- /dev/null +++ b/devU-api/src/entities/submission/submission.middleware.ts @@ -0,0 +1,77 @@ +// middleware to enforce mac submissions + +import { Request, Response, NextFunction } from 'express' +import SubmissionService from './submission.service' +import AssignmentService from '../assignment/assignment.service' + +// TODO discuss how to bypass this when an instructor wants to eg bypass for a specific student +// checks number of submissions and checks if the assignment is beyond the deadline +async function checkSubmissions(req: Request, res: Response, next: NextFunction) { + const userID = req.currentUser?.userId + const assignmentId = req.body.assignmentId + + if (!userID) { + return res.status(403).send('userid is missing') + } + + try { + const assignmentInfo = await AssignmentService.getMaxSubmissionsForAssignment(assignmentId) + + if (assignmentInfo == null) return res.status(403).send('could not retrieve assignment info') + + if (assignmentInfo!.disableHandins) { + // console.debug('Handins are now disabled') + return res.status(403).json({ 'Error': 'Handins are now disabled for this assignment' }) + } + + const currentTime = new Date() + + if (assignmentInfo!.endDate < currentTime) { + // console.debug('Submission after enddate') + return res.status(403).json({ + 'Error': 'Submission after end date', + 'endDate': assignmentInfo!.endDate, + 'currentTime': currentTime, + }) + } + + if (assignmentInfo!.maxSubmissions == null) { + console.debug('Max submissions are not specified, skipping check') + // check file size + if (req.file && req.file.size > assignmentInfo!.maxFileSize!) { + return res.status(403).json({ + 'error': 'file is bigger than allowed max file size', + 'allowed size': assignmentInfo!.maxFileSize!, + 'file size': req.file.size, + }) + } + + return next() + } + + const submissions = await SubmissionService.listByAssignment(assignmentId, userID) + // check submissions + if (submissions.length >= assignmentInfo!.maxSubmissions!) { + return res.status(403).json({ + 'error': 'max submissions reached.', + 'Max submissions': assignmentInfo!.maxSubmissions!, + 'Current submissions': submissions.length, + }) + } + + // check file size + if (req.file && req.file.size > assignmentInfo!.maxFileSize!) { + return res.status(403).json({ + 'error': 'file is bigger than allowed max file size', + 'allowed size': assignmentInfo!.maxFileSize!, + 'file size': req.file.size, + }) + } + + next() + } catch (e) { + return res.status(500).send(e) + } +} + +export { checkSubmissions } \ No newline at end of file diff --git a/devU-api/src/entities/submission/submission.router.ts b/devU-api/src/entities/submission/submission.router.ts index c8b4517..a364626 100644 --- a/devU-api/src/entities/submission/submission.router.ts +++ b/devU-api/src/entities/submission/submission.router.ts @@ -9,6 +9,7 @@ import { asInt } from '../../middleware/validator/generic.validator' // Controller import SubmissionController from '../submission/submission.controller' +import { checkSubmissions } from './submission.middleware' const Router = express.Router({ mergeParams: true }) const upload = Multer() @@ -97,7 +98,7 @@ Router.get('/user/:userId', isAuthorized('enrolled'), asInt('userId'), Submissio * schema: * $ref: '#/components/schemas/Submission' */ -Router.post('/', isAuthorized('enrolled'), upload.single('files'), validator, SubmissionController.post) +Router.post('/', isAuthorized('enrolled'), upload.single('files'), validator, checkSubmissions, SubmissionController.post) // TODO: submissionCreateSelf or submissionCreateAll /** diff --git a/devU-api/src/entities/submission/submission.service.ts b/devU-api/src/entities/submission/submission.service.ts index e7a9ba0..4e21c8a 100644 --- a/devU-api/src/entities/submission/submission.service.ts +++ b/devU-api/src/entities/submission/submission.service.ts @@ -38,7 +38,7 @@ export async function create(submission: Submission, file?: Express.Multer.File if (!content.filepaths) { content.filepaths = [] } - content.filepaths.push(filename) + content.filepaths.push(`${bucket}/${filename}`) submission.content = JSON.stringify(content) await fileConn().save(fileModel) diff --git a/devU-api/src/entities/submission/submission.validator.ts b/devU-api/src/entities/submission/submission.validator.ts index 4930606..66c6753 100644 --- a/devU-api/src/entities/submission/submission.validator.ts +++ b/devU-api/src/entities/submission/submission.validator.ts @@ -2,7 +2,6 @@ import { check } from 'express-validator' import validate from '../../middleware/validator/generic.validator' -const userId = check('userId').isNumeric() const assignmentId = check('assignmentId').isNumeric() const courseId = check('courseId').isNumeric() const content = check('content').isString() @@ -17,6 +16,6 @@ const file = check('file') } }) -const validator = [courseId, assignmentId, userId, content, file, validate] +const validator = [courseId, assignmentId, content, file, validate] export default validator diff --git a/devU-api/src/entities/user/user.controller.ts b/devU-api/src/entities/user/user.controller.ts index b811734..48e1772 100644 --- a/devU-api/src/entities/user/user.controller.ts +++ b/devU-api/src/entities/user/user.controller.ts @@ -87,4 +87,4 @@ export async function _delete(req: Request, res: Response, next: NextFunction) { } } -export default { get, detail, post, put, _delete, getByCourse } +export default { get, detail, post, put, _delete, getByCourse } \ No newline at end of file diff --git a/devU-api/src/entities/user/user.router.ts b/devU-api/src/entities/user/user.router.ts index d7b3fd9..d2557d4 100644 --- a/devU-api/src/entities/user/user.router.ts +++ b/devU-api/src/entities/user/user.router.ts @@ -20,6 +20,8 @@ const Router = express.Router() * description: OK */ Router.get('/', UserController.get) +// Router.get('/', isAuthorized('admin'), UserController.get) + /** * @swagger diff --git a/devU-api/src/entities/user/user.service.ts b/devU-api/src/entities/user/user.service.ts index fa2b584..b25bb9a 100644 --- a/devU-api/src/entities/user/user.service.ts +++ b/devU-api/src/entities/user/user.service.ts @@ -29,6 +29,10 @@ export async function retrieve(id: number) { return await connect().findOneBy({ id, deletedAt: IsNull() }) } +export async function retrieveByEmail(email: string) { + return await connect().findOneBy({ email: email, deletedAt: IsNull() }) +} + export async function list() { return await connect().findBy({ deletedAt: IsNull() }) } @@ -56,6 +60,7 @@ export async function ensure(userInfo: User) { export default { create, retrieve, + retrieveByEmail, update, _delete, list, diff --git a/devU-api/src/entities/userCourse/userCourse.controller.ts b/devU-api/src/entities/userCourse/userCourse.controller.ts index 4c98af8..c9befeb 100644 --- a/devU-api/src/entities/userCourse/userCourse.controller.ts +++ b/devU-api/src/entities/userCourse/userCourse.controller.ts @@ -73,12 +73,16 @@ export async function detailByUser(req: Request, res: Response, next: NextFuncti export async function post(req: Request, res: Response, next: NextFunction) { try { const userCourse = await UserCourseService.create(req.body) + if (userCourse === null) { + return res.status(409).json('User already enrolled') + } + const response = serialize(userCourse) res.status(201).json(response) } catch (err) { if (err instanceof Error) { - res.status(400).json(new GenericResponse(err.message)) + res.status(400).json(new GenericResponse(err.message)) } } } @@ -88,7 +92,10 @@ export async function put(req: Request, res: Response, next: NextFunction) { req.body.courseId = parseInt(req.params.id) const currentUser = req.currentUser?.userId if (!currentUser) return res.status(401).json({ message: 'Unauthorized' }) - const results = await UserCourseService.update(req.body, currentUser) + + req.body.userId = currentUser + + const results = await UserCourseService.update(req.body) if (!results.affected) return res.status(404).json(NotFound) res.status(200).json(Updated) @@ -115,6 +122,7 @@ export async function checkEnroll(req: Request, res: Response, next: NextFunctio export async function _delete(req: Request, res: Response, next: NextFunction) { try { const id = parseInt(req.params.courseId) + console.log("DELETE PARAMS: ", req.params) const currentUser = req.currentUser?.userId if (!currentUser) return res.status(401).json({ message: 'Unauthorized' }) @@ -128,4 +136,62 @@ export async function _delete(req: Request, res: Response, next: NextFunction) { } } -export default { get, getByCourse, getAll, detail, detailByUser, post, put, _delete, checkEnroll } + +export async function _deleteUser(req: Request, res: Response, next: NextFunction) { + try { + const courseID = parseInt(req.params.courseId) + console.log("DELETE PARAMS2: ", req.params) + // const currentUser = req.currentUser?.userId + const userID = parseInt(req.params.id) + if (!userID) return res.status(401).json({ message: 'Unauthorized' }) + + const results = await UserCourseService._delete(courseID, userID) + + if (!results.affected) return res.status(404).json(NotFound) + + res.status(204).send() + } catch (err) { + next(err) + } +} + +export async function addStudents(req: Request, res: Response, next: NextFunction) { + try { + const userEmails = req.body['users'] as string[] + if (!userEmails || userEmails.length == 0) return res.status(422).json({ message: 'users field not found or is empty' }) + const courseId = parseInt(req.params.courseId) + + const result = await UserCourseService.bulkCreate(userEmails, courseId, false) + res.status(201).json(result) + } catch (err) { + next(err) + } +} + +export async function dropStudents(req: Request, res: Response, next: NextFunction) { + try { + const userEmails = req.body['users'] as string[] + if (!userEmails || userEmails.length == 0) return res.status(422).json({ message: 'users field not found or is empty' }) + const courseId = parseInt(req.params.courseId) + + const result = await UserCourseService.bulkCreate(userEmails, courseId, true) + res.status(201).json(result) + } catch (err) { + next(err) + } +} + +export default { + get, + getByCourse, + getAll, + detail, + detailByUser, + post, + put, + _delete, + _deleteUser, + checkEnroll, + addStudents, + dropStudents, +} diff --git a/devU-api/src/entities/userCourse/userCourse.router.ts b/devU-api/src/entities/userCourse/userCourse.router.ts index c781c49..5fd4883 100644 --- a/devU-api/src/entities/userCourse/userCourse.router.ts +++ b/devU-api/src/entities/userCourse/userCourse.router.ts @@ -80,7 +80,7 @@ Router.get( extractOwnerByPathParam('userId'), isAuthorized('courseViewAll', 'enrolled'), asInt('userId'), - UserCourseController.detailByUser + UserCourseController.detailByUser, ) /** @@ -102,6 +102,104 @@ Router.get( Router.post('/', validator, UserCourseController.post) // TODO: userCourseEditAll eventually. For now, allow self enroll + +/** + * @swagger + * /courses/{courseId}/students/add: + * put: + * summary: Add multiple students to a course + * tags: + * - UserCourses + * responses: + * 200: + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: string + * description: Array of successfully enrolled users as a string + * example: '["test@test1.com enrolled successfully"]' + * failed: + * type: string + * description: Array of failed enrollments with error messages as a string + * example: '["user@email.com: Error: User already enrolled in course", "user2@email.com not found"]' + * required: + * - success + * - failed + * parameters: + * - name: courseId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * description: A list of emails in a json array + * content: + * application/json: + * schema: + * type: object + * properties: + * users: + * type: array + * items: + * type: string + * format: email + * required: + * - users + */ +Router.post('/students/add', asInt('courseId'), isAuthorized('courseViewAll'), UserCourseController.addStudents) + +/** + * @swagger + * /courses/{courseId}/students/drop: + * put: + * summary: Drop multiple students from a course + * tags: + * - UserCourses + * responses: + * 200: + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: string + * description: Array of successfully dropped students as a string + * example: '["test@test1.com dropped successfully"]' + * failed: + * type: string + * description: Array of failed drops with error messages as a string + * example: '["user2@email.com not found"]' + * required: + * - success + * - failed + * parameters: + * - name: courseId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * description: A list of emails in a json array + * content: + * application/json: + * schema: + * type: object + * properties: + * users: + * type: array + * items: + * type: string + * format: email + * required: + * - users + */ +Router.post('/students/drop', asInt('courseId'), isAuthorized('courseViewAll'), UserCourseController.dropStudents) + + /** * @swagger * /course/:courseId/users-courses/{id}: @@ -128,9 +226,9 @@ Router.put('/:id', isAuthorized('userCourseEditAll'), asInt(), validator, UserCo /** * @swagger - * /course/:courseId/user-courses/{id}: + * /course/:courseId/user-courses: * delete: - * summary: Delete a user-course association + * summary: Delete a user-course association for current user * tags: * - UserCourses * responses: @@ -145,4 +243,24 @@ Router.put('/:id', isAuthorized('userCourseEditAll'), asInt(), validator, UserCo */ Router.delete('/', UserCourseController._delete) // TODO: eventually add authorization to this. For now, everyone can remove anyone + +/** + * @swagger + * /course/:courseId/user-courses/{id}: + * delete: + * summary: Delete a user-course association given a specific user + * tags: + * - UserCourses + * responses: + * '200': + * description: OK + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: integer + */ +Router.delete('/:id', UserCourseController._deleteUser) +// TODO: eventually add authorization to this. For now, everyone can remove anyone export default Router diff --git a/devU-api/src/entities/userCourse/userCourse.service.ts b/devU-api/src/entities/userCourse/userCourse.service.ts index 88baae8..4fefa9c 100644 --- a/devU-api/src/entities/userCourse/userCourse.service.ts +++ b/devU-api/src/entities/userCourse/userCourse.service.ts @@ -4,25 +4,73 @@ import { dataSource } from '../../database' import { UserCourse as UserCourseType } from 'devu-shared-modules' import UserCourse from './userCourse.model' +import UserService from '../user/user.service' const connect = () => dataSource.getRepository(UserCourse) export async function create(userCourse: UserCourseType) { const userId = userCourse.userId const hasEnrolled = await connect().findOneBy({ userId, courseId: userCourse.courseId }) + if (hasEnrolled) throw new Error('User already enrolled in course') return await connect().save(userCourse) } -export async function update(userCourse: UserCourseType, currentUser: number) { - const { courseId, role, dropped } = userCourse +// Add/drop students based on a list of users, +// to drop students, set the third param to true +export async function bulkAddDrop(userEmails: string[], courseId: number, drop: boolean) { + const failed: string[] = [] + const success: string[] = [] + + for (const email of userEmails) { + const user = await UserService.retrieveByEmail(email) + if (user === null) { + failed.push(`${email} not found`) + continue + } + + const student: UserCourseType = { + userId: user.id, + role: 'student', + courseId: courseId, + dropped: drop, + } + + try { + if (!drop) { + try { + await create(student) + } catch (error) { + if (error instanceof Error && error.message === 'User already enrolled in course') { + // update student drop to false, since they re-enrolled after being dropped + await update(student) + } else { + throw error; // re-throw if it's a different error + } + } + success.push(`${email} enrolled successfully`) + } else { + await update(student) + success.push(`${email} dropped successfully`) + } + } catch (e) { + console.error(`Error occurred while bulk add/drop ${e}`) + failed.push(`${email}: ${e}`) + } + } + + return { 'success': JSON.stringify(success), 'failed': JSON.stringify(failed) } +} + +export async function update(userCourse: UserCourseType) { + const { courseId, role, dropped, userId } = userCourse if (!courseId) throw new Error('Missing course Id') - const userCourseData = await connect().findOneBy({ courseId, userId: currentUser }) + const userCourseData = await connect().findOneBy({ courseId, userId: userId }) if (!userCourseData) throw new Error('User not enrolled in course') userCourseData.role = role userCourseData.dropped = dropped - if (!userCourse.id) throw new Error('Missing Id') - return await connect().update(userCourse.id, userCourseData) + if (!userCourseData.id) throw new Error('Missing Id') + return await connect().update(userCourseData.id, userCourseData) } export async function _delete(courseId: number, userId: number) { @@ -43,6 +91,7 @@ export async function list(userId: number) { // TODO: look into/test this return await connect().findBy({ userId, deletedAt: IsNull() }) } + export async function listAll() { return await connect().findBy({ deletedAt: IsNull() }) } @@ -71,4 +120,5 @@ export default { listByCourse, listByUser, checking: checkIfEnrolled, + bulkCreate: bulkAddDrop, } diff --git a/devU-api/src/fileUpload/fileUpload.controller.ts b/devU-api/src/fileUpload/fileUpload.controller.ts index 344ae48..0c30911 100644 --- a/devU-api/src/fileUpload/fileUpload.controller.ts +++ b/devU-api/src/fileUpload/fileUpload.controller.ts @@ -26,7 +26,9 @@ export async function detail(req: Request, res: Response, next: NextFunction) { if (!file) return res.status(404).json(NotFound) - res.status(200).json(file) + res.setHeader('Content-Type', 'application/octet-stream') + res.setHeader('Content-Length', file.length) + res.send(file) } catch (err) { next(err) } diff --git a/devU-client/src/assets/global.scss b/devU-client/src/assets/global.scss index 6e34f3b..4458b36 100644 --- a/devU-client/src/assets/global.scss +++ b/devU-client/src/assets/global.scss @@ -61,17 +61,11 @@ --background: white; --text-color: black; - --text-color2: white; --text-color-secondary: #363636; --focus: var(--blue); - --primary-lighter: #41a8f7; - --primary: var(--purple); - // --primary: #D9D9D9; - - --primary-darker: #054272; --secondary-lighter: #cfd1d1; --secondary: #a8abab; --secondary-darker: #686b6b; @@ -98,14 +92,21 @@ --input-field-background: var(--grey-lightest); --input-field-label: var(--grey); + --table-row-even: var(--grey-lighter); + --table-row-odd: var(--grey-lightest); + + --red-text: var(--red); + --yellow-text: var(--yellow-dark); + // Non theme colors - will not update with light/dark toggle --grey-lightest: #e5e5e5; --grey-lighter: #c5c5c5; --grey: #555555; - --grey-dark: #333333; + --grey-darker: #444444; + --grey-darkest: #333333; - //--blue-lighter: #78B7FF; - //--blue: #1F3D7A; + --blue-lighter: #78B7FF; + --blue: #1F3D7A; --red-lighter: #FFA3A3; --red: #8A2626; @@ -117,7 +118,7 @@ --green-lighter: #B0EEA2; --green: #306025; - --yellow-lighter: #ffeaa7; + --yellow-dark: #664600; --yellow: #F8D487; transition: background-color 150ms linear; @@ -127,16 +128,9 @@ --background: #1e1e1e; --text-color: #FFF; --text-color-secondary: #9b9b9b; - --blue-darker: #2767a3; - --primary-lighter: #3796bc; - --primary: #5c36c3; - // --primary-darker: rgb(92, 54, 195); + --focus: var(--blue-lighter); - //--primary: #7257EB; -// --primary-darker: #2F2363; - - // --primary: #636666; - --primary-darker: #133441; + --primary: #7257EB; --secondary-lighter: #636666; --secondary: #3d3f3f; @@ -161,6 +155,12 @@ --input-field-background: var(--grey); --input-field-label: var(--grey-lightest); + --table-row-even: var(--grey-darker); + --table-row-odd: var(--grey-darkest); + + --red-text: var(--red-lighter); + --yellow-text: var(--yellow); + background-color: var(--background); color: var(--text-color); } diff --git a/devU-client/src/assets/variables.scss b/devU-client/src/assets/variables.scss index 496eb9b..1056e10 100644 --- a/devU-client/src/assets/variables.scss +++ b/devU-client/src/assets/variables.scss @@ -8,13 +8,8 @@ $background: var(--background); $text-color: var(--text-color); $text-color-secondary: var(--text-color-secondary); -$text-color2 : var(--text-color2); -$primary-lighter: var(--primary-lighter); -$primary: var(--primary); -$primary-darker: var(--primary-darker); -$blue-darker: var(--blue-darker); - +$primary: var(--primary); $secondary-lighter: var(--secondary-lighter); $secondary: var(--secondary); @@ -22,7 +17,7 @@ $secondary-darker: var(--secondary-darker); $list-item-background: var(--list-item-background); $list-item-background-hover: var(--list-item-background-hover); -$list-item-subtext: var(--list-item-subtext); +$list-item-subtext: var(--list-item-subtext); $list-simple-item-background: var(--list-simple-item-background); $list-simple-item-background-hover: var(--list-simple-item-background-hover); @@ -31,6 +26,9 @@ $list-simple-item-subtext: var(--list-item-subtext); $input-field-background: var(--input-field-background); $input-field-label: var(--input-field-label); +$table-row-even: var(--table-row-even); +$table-row-odd: var(--table-row-odd); + $focus: var(--focus); // These variables WILL NOT update with dark vs light theme @@ -41,21 +39,22 @@ $grey: var(--grey); $blue-lighter: var(--blue-lighter); $blue: var(--blue); - - - $red-lighter: var(--red-lighter); $red: var(--red); $purple-lighter: var(--purple-lighter); $purple: var(--purple); +$purple-darker: var(--purple-darker); $green-lighter: var(--green-lighter); $green: var(--green); -$yellow-lighter: var(--yellow-lighter); +$yellow-dark: var(--yellow-dark); $yellow: var(--yellow); +$redText: var(--red-text); +$yellowText: var(--yellow-text); + // Non color CSS variables $border-radius: 3px; @@ -72,4 +71,4 @@ $extreme: 780px; text-overflow: ellipsis; white-space: nowrap; word-wrap: normal; -} +} \ No newline at end of file diff --git a/devU-client/src/components/misc/editAssignmentModal.tsx b/devU-client/src/components/misc/editAssignmentModal.tsx new file mode 100644 index 0000000..e69de29 diff --git a/devU-client/src/components/misc/globalToolbar.scss b/devU-client/src/components/misc/globalToolbar.scss index 60ed99e..0ddc42d 100644 --- a/devU-client/src/components/misc/globalToolbar.scss +++ b/devU-client/src/components/misc/globalToolbar.scss @@ -16,8 +16,8 @@ $font-size: 16px; @extend .flex; text-decoration: none; + color: #FFF; - color: #d9d9d9; font-size: $font-size; //height: $bar-height; @@ -40,23 +40,21 @@ $font-size: 16px; @extend .link; font-size: 40px; border-radius: 30px; - color: #D9D9D9; font-weight: 550; //font-size: 2em; //font-weight: bold; } .bar { - height: 80px; - background-color: $purple; - //height: $bar-height; + height: 80px; + background-color: $primary; font-size: 40px; - border-radius: 30px; color: #D9D9D9; font-weight: 550; + @extend .flex; justify-content: space-between; } @@ -73,7 +71,7 @@ $font-size: 16px; // Controls turning the menu options into a sidebar // As well as whether or not that sidebar is being shown -@media (max-width: 300px) { +@media (max-width: 410px) { .flex { gap: 1rem; diff --git a/devU-client/src/components/misc/navbar.scss b/devU-client/src/components/misc/navbar.scss index 0c8cf19..9ee712c 100644 --- a/devU-client/src/components/misc/navbar.scss +++ b/devU-client/src/components/misc/navbar.scss @@ -3,14 +3,17 @@ .breadcrumbContainer { display: flex; align-items: center; + margin-left: 3.5rem; + margin-top:10px; + } .link { text-decoration: none; color: $text-color; /* Breadcrumb link color */ - } + .link:hover { text-decoration: underline; } diff --git a/devU-client/src/components/pages/courses/courseDetailPage.scss b/devU-client/src/components/pages/courses/courseDetailPage.scss index 78b039b..6efc4d2 100644 --- a/devU-client/src/components/pages/courses/courseDetailPage.scss +++ b/devU-client/src/components/pages/courses/courseDetailPage.scss @@ -17,8 +17,8 @@ .color{ - background-color: $purple; - color: $text-color2; + background-color: $primary; + color: #FFF; // always white against purple bg width: 100%; padding: 20px; text-align: center; diff --git a/devU-client/src/components/pages/forms/assignments/assignmentFormPage.tsx b/devU-client/src/components/pages/forms/assignments/assignmentFormPage.tsx index 0d238fa..a83cc2e 100644 --- a/devU-client/src/components/pages/forms/assignments/assignmentFormPage.tsx +++ b/devU-client/src/components/pages/forms/assignments/assignmentFormPage.tsx @@ -31,10 +31,10 @@ const AssignmentCreatePage = () => { const [formData, setFormData] = useState({ courseId: courseId, name: '', - categoryName: null, - description: null, + categoryName: '', + description: '', maxFileSize: 0, - maxSubmissions: null, + maxSubmissions: 0, disableHandins: false, }) const [endDate, setEndDate] = useState(new Date()) @@ -98,7 +98,23 @@ const AssignmentCreatePage = () => { disableHandins: formData.disableHandins, } - RequestService.post(`/api/course/${courseId}/assignments/`, finalFormData) + const multipart = new FormData + multipart.append('courseId', finalFormData.courseId) + multipart.append('name', finalFormData.name) + multipart.append('startDate', finalFormData.startDate) + multipart.append('dueDate', finalFormData.dueDate) + multipart.append('endDate', finalFormData.endDate) + multipart.append('categoryName', finalFormData.categoryName) + if(finalFormData.description !== null) { multipart.append('description', finalFormData.description) } + multipart.append('maxFileSize', finalFormData.maxFileSize.toString()) + if(finalFormData.maxSubmissions !== null) { multipart.append('maxSubmissions', finalFormData.maxSubmissions.toString()) } + multipart.append('disableHandins', finalFormData.disableHandins.toString()) + for(const file of files.values()){ + multipart.append('files', file) + } + + + RequestService.postMultipart(`/api/course/${courseId}/assignments/`, multipart) .then(() => { setAlert({ autoDelete: true, type: 'success', message: 'Assignment Added' }) history.goBack() @@ -117,16 +133,6 @@ const AssignmentCreatePage = () => { } - // const handleFile = (file : File) => { - // if(Object.keys(files).length < 5){ - // setFiles(prevState => ({...prevState, [file.name] : file})) - // } else { - // //TODO: Add alert - // console.log('Max files reached') - // } - // console.log(files) - // } - const handleFile = (file: File) => { if (files.size < 5) { setFiles(prevState => new Map(prevState).set(file.name, file)); @@ -134,7 +140,6 @@ const AssignmentCreatePage = () => { // TODO: Add alert console.log('Max files reached'); } - console.log(files); }; const handleFileRemoval = (e: React.MouseEvent) => { diff --git a/devU-client/src/components/pages/forms/assignments/assignmentUpdatePage.scss b/devU-client/src/components/pages/forms/assignments/assignmentUpdatePage.scss new file mode 100644 index 0000000..d1aa3c9 --- /dev/null +++ b/devU-client/src/components/pages/forms/assignments/assignmentUpdatePage.scss @@ -0,0 +1,129 @@ +@import 'variables'; + +.grid { + display: grid; + grid-template-columns: 0.3fr 1fr 0.5fr; + grid-template-rows: 1fr 0.75fr; + grid-column-gap: 10px; + grid-row-gap: 10px; + width: 100%; +} + +.assignmentsList, .form, .problemsList, .attachments {background-color: $list-item-background;} +.assignmentsList {grid-area: 1 / 1 / 3 / 2;} +.form {grid-area: 1 / 2 / 2 / 3;} +.problemsList {grid-area: 1 / 3 / 2 / 4;} +.attachments {grid-area: 2 / 2 / 3 / 4;} + + +.textFieldContainer { + display:flex; + width: 100%; + justify-content: center; + align-items: center; +} + +.textField { + align-items: center; +} + +.datepickerContainer { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: 1fr; + grid-column-gap: 10px; + grid-row-gap: 0px; + width: 100%; + justify-content: center; + align-items: center; + text-align: center; + + .datepicker_start {grid-area: 1 / 1 / 2 / 2;} + .datepicker_due {grid-area: 1 / 2 / 2 / 3;} + .datepicker_end {grid-area: 1 / 3 / 2 / 4;} +} + + +input[type='date'] { + height: 20px; + background-color: $input-field-background; + color: $input-field-label; + padding: 0.625rem 1rem; + border: none; + border-radius: 100px; + } + +.problemsList, .assignmentsList { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.assignment { + padding: 10px; + font-weight: bold; + cursor: pointer; + :hover { + padding: 10px; + background-color: $list-item-background-hover; + border-radius: 10px; + } +} + +.problem, .fileList{ + display: flex; + width: 100%; + align-items: center; + justify-content: center; +} + +.header { + text-align: center; +} + +.editProblem { + margin-right: 10px; + background-color: $background; + border : 2px solid $primary; +} + +.deleteButton { + background-color: $background; + border : 2px solid red; +} + +@media (max-width:1000px) { + .grid { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(4, 1fr); + grid-column-gap: 0px; + grid-row-gap: 10px; + width: 100%; + } + + .assignmentsList, .form, .problemsList, .attachments {background-color: $list-item-background;} + .assignmentsList { display: none; } + .form { grid-area: 1 / 1 / 2 / 2; } + .problemsList { grid-area: 2 / 1 / 3 / 2; } + .attachments { grid-area: 3 / 1 / 4 / 2; } +} + +@media (max-width:450px) { + .datepickerContainer { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(3, 1fr); + grid-column-gap: 0px; + grid-row-gap: 30px; + width: 100%; + justify-content: center; + align-items: center; + text-align: center; + } + + .datepicker_start { grid-area: 1 / 1 / 2 / 2; } + .datepicker_due { grid-area: 2 / 1 / 3 / 2; } + .datepicker_end { grid-area: 3 / 1 / 4 / 2; } +} \ No newline at end of file diff --git a/devU-client/src/components/pages/forms/assignments/assignmentUpdatePage.tsx b/devU-client/src/components/pages/forms/assignments/assignmentUpdatePage.tsx index baf851c..f53a5f8 100644 --- a/devU-client/src/components/pages/forms/assignments/assignmentUpdatePage.tsx +++ b/devU-client/src/components/pages/forms/assignments/assignmentUpdatePage.tsx @@ -1,33 +1,33 @@ import React, { useEffect, useState } from 'react' -import { ExpressValidationError, Assignment } from 'devu-shared-modules' -import DatePicker from 'react-datepicker' +import { ExpressValidationError, Assignment, AssignmentProblem } from 'devu-shared-modules' import 'react-datepicker/dist/react-datepicker.css' import { useHistory, useParams } from 'react-router-dom' - import PageWrapper from 'components/shared/layouts/pageWrapper' - import RequestService from 'services/request.service' - import { useActionless } from 'redux/hooks' import TextField from 'components/shared/inputs/textField' -import Button from '@mui/material/Button' -import formStyles from './assignmentFormPage.scss' - +import Button from '../../../shared/inputs/button' +import styles from './assignmentUpdatePage.scss' +import DragDropFile from 'components/utils/dragDropFile' import { SET_ALERT } from 'redux/types/active.types' import { applyMessageToErrorFields, removeClassFromField } from 'utils/textField.utils' +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; -type UrlParams = { - assignmentId: string -} +type UrlParams = { assignmentId: string } const AssignmentUpdatePage = () => { const { assignmentId } = useParams() as UrlParams const { courseId } = useParams<{ courseId: string }>() const [setAlert] = useActionless(SET_ALERT) - const [startDate, setStartDate] = useState(new Date()) - const [endDate, setEndDate] = useState(new Date()) - const [dueDate, setDueDate] = useState(new Date()) + const [currentAssignmentId, setCurrentAssignmentId] = useState(parseInt(assignmentId)) + const [assignmentsList, setAssignmentsList] = useState([]) + const [assignmentProblems, setAssignmentProblems] = useState([]) + const [allAssignmentProblems, setAllAssignmentProblems] = useState>(new Map()) const [invalidFields, setInvalidFields] = useState(new Map()) + const [openModal, setOpenModal] = useState(false) + const [files, setFiles] = useState([]) const history = useHistory() const [formData, setFormData] = useState({ @@ -36,15 +36,31 @@ const AssignmentUpdatePage = () => { categoryName: '', description: '', maxFileSize: 0, - maxSubmissions: null, + maxSubmissions: 0, disableHandins: false, dueDate: '', endDate: '', startDate: '', - attachmentsHashes: [], - attachmentsFilenames: [], }) + const [assignmentProblemData, setAssignmentProblemData] = useState({ + assignmentId: currentAssignmentId, + problemName: '', + maxScore: -1, + }) + + const handleOpenModal = (problem : AssignmentProblem) => { + if(problem === assignmentProblemData) { + setOpenModal(true) + } else { + setAssignmentProblemData(problem) + } + } + const handleCloseModal = () => {setOpenModal(false)} + useEffect(() => { + if (assignmentProblemData.maxScore !== -1) { setOpenModal(true) } + }, [assignmentProblemData]) + const handleChange = (value: String, e: React.ChangeEvent) => { const key = e.target.id const newInvalidFields = removeClassFromField(invalidFields, key) @@ -52,58 +68,79 @@ const AssignmentUpdatePage = () => { setFormData(prevState => ({ ...prevState, [key]: value })) } - const handleCheckbox = (e: React.ChangeEvent) => { - setFormData(prevState => ({ ...prevState, disableHandins: e.target.checked })) - } - const handleStartDateChange = (date: Date) => { - setStartDate(date) + const handleProblemChange = (value: String, e: React.ChangeEvent) => { + const key = e.target.id + setAssignmentProblemData(prevState => ({ ...prevState, [key]: value })) } - const handleEndDateChange = (date: Date) => { - setEndDate(date) + + const handleCheckbox = (e: React.ChangeEvent) => {setFormData(prevState => ({ ...prevState, disableHandins: e.target.checked }))} + const handleStartDateChange = (e : React.ChangeEvent) => {setFormData(prevState => ({ ...prevState, startDate: e.target.value }))} + const handleEndDateChange = (e : React.ChangeEvent) => {setFormData(prevState => ({ ...prevState, endDate: e.target.value }))} + const handleDueDateChange = (e : React.ChangeEvent) => {setFormData(prevState => ({ ...prevState, dueDate: e.target.value }))} + + const handleFile = (file: File) => { + if(files.length < 5) { + setFiles([...files, file]) + } } - const handleDueDateChange = (date: Date) => { - setDueDate(date) + + const fetchAssignmentProblems = () => { + RequestService.get(`/api/course/${courseId}/assignment/${currentAssignmentId}/assignment-problems`) + .then((res) => { setAssignmentProblems(res) }) } + useEffect(() => {RequestService.get(`/api/course/${courseId}/assignments/${assignmentId}`).then((res) => { setFormData(res) })}, []) + useEffect(() => {RequestService.get(`/api/course/${courseId}/assignment/${assignmentId}/assignment-problems`).then((res) => { setAssignmentProblems(res) })}, []) + useEffect(() => {RequestService.get(`/api/course/${courseId}/assignments`).then((res) => { setAssignmentsList(res) })}, []) useEffect(() => { - RequestService.get(`/api/course/${courseId}/assignments/${assignmentId}`).then((res) => { - // const assignment: Assignment = res - // setFormData({ - // name: assignment.name, - // categoryName: assignment.categoryName, - // description: assignment.description, - // maxFileSize: assignment.maxFileSize, - // maxSubmissions: assignment.maxSubmissions, - // disableHandins: assignment.disableHandins, - // startDate: assignment.startDate, - // endDate: assignment.endDate, - // dueDate: assignment.dueDate, - // courseId: assignment.courseId, - // }) - setStartDate(new Date(res.startDate)) - setDueDate(new Date(res.dueDate)) - setEndDate(new Date(res.endDate)) - }) - }, []) + for(let i : number = 0; i < assignmentsList.length; i++) { + RequestService.get(`/api/course/${courseId}/assignment/${assignmentsList[i].id}/assignment-problems`) + .then((res) => { + setAllAssignmentProblems(prevState => { + const newMap = new Map(prevState); + newMap.set(Number(assignmentsList[i].id), res); + return newMap; + }); + }) + } + },[assignmentsList]) const handleAssignmentUpdate = () => { const finalFormData = { courseId: formData.courseId, name: formData.name, - startDate: startDate.toISOString(), - dueDate: dueDate.toISOString(), - endDate: endDate.toISOString(), + startDate: formData.startDate, + dueDate: formData.dueDate, + endDate: formData.endDate, categoryName: formData.categoryName, description: formData.description, maxFileSize: formData.maxFileSize, maxSubmissions: formData.maxSubmissions, disableHandins: formData.disableHandins, + } + const multipart = new FormData() + multipart.append('courseId', finalFormData.courseId.toString()) + multipart.append('name', finalFormData.name) + multipart.append('startDate', finalFormData.startDate) + multipart.append('dueDate', finalFormData.dueDate) + multipart.append('endDate', finalFormData.endDate) + multipart.append('categoryName', finalFormData.categoryName) + if(finalFormData.description !== null) { + multipart.append('description', finalFormData.description) + } + multipart.append('maxFileSize', finalFormData.maxFileSize.toString()) + if(finalFormData.maxSubmissions !== null) { + multipart.append('maxSubmissions', finalFormData.maxSubmissions.toString()) + } + multipart.append('disableHandins', finalFormData.disableHandins.toString()) + + for(let i = 0; i < files.length; i++) { + multipart.append('files', files[i]) } - RequestService.put(`/api/course/${courseId}/assignments/${assignmentId}`, finalFormData) + RequestService.putMultipart(`/api/course/${courseId}/assignments/${currentAssignmentId}`, multipart) .then(() => { - setAlert({ autoDelete: true, type: 'success', message: 'Assignment Updated' }) history.goBack() }) @@ -118,68 +155,201 @@ const AssignmentUpdatePage = () => { }) } + const handleProblemUpdate = () => { + const finalFormData = { + assignmentId: assignmentProblemData.assignmentId, + problemName: assignmentProblemData.problemName, + maxScore: assignmentProblemData.maxScore, + } + + RequestService.put(`/api/course/${courseId}/assignment/${currentAssignmentId}/assignment-problems/${assignmentProblemData.id}`, finalFormData) + .then(() => { + setAlert({ autoDelete: true, type: 'success', message: 'Problem Updated' }) + setOpenModal(false) + fetchAssignmentProblems() + }) + } + + const handleAssignmentChange = (e: React.MouseEvent) => { + const assignmentDetails = assignmentsList.find((assignment) => assignment.id === parseInt(e.currentTarget.id)) + if (assignmentDetails !== undefined && assignmentDetails.id !== undefined) { + setAssignmentProblems(allAssignmentProblems.get(assignmentDetails.id) || []) + setCurrentAssignmentId(assignmentDetails.id) + } + if (assignmentDetails !== undefined) { + setFormData(assignmentDetails) + } + } + + const [addProblemModal, setAddProblemModal] = useState(false) + const [addProblemForm, setAddProblemForm] = useState({ + assignmentId: currentAssignmentId, + problemName: '', + maxScore: 0, + }) + const openAddProblemModal = () => {setAddProblemModal(true)} + const handleCloseAddProblemModal = () => {setAddProblemModal(false)} + + const handleAddProblemChange = (value: String, e: React.ChangeEvent) => { + const key = e.target.id + setAddProblemForm(prevState => ({ ...prevState, [key]: value })) + } + const handleAddProblem = () => { + RequestService.post(`/api/course/${courseId}/assignment/${currentAssignmentId}/assignment-problems`, addProblemForm) + .then(() => { + setAlert({ autoDelete: true, type: 'success', message: 'Problem Added' }) + setAddProblemModal(false) + setAddProblemForm({ + assignmentId: currentAssignmentId, + problemName: '', + maxScore: 0, + }) + fetchAssignmentProblems() + }) + } + + const handleDeleteProblem = (problemId: number) => { + RequestService.delete(`/api/course/${courseId}/assignment/${currentAssignmentId}/assignment-problems/${problemId}`) + .then(() => { + setAlert({ autoDelete: true, type: 'success', message: 'Problem Deleted' }) + fetchAssignmentProblems() + }) + } + return ( + -
-
-

Assignment Detail Update

-
-
-
- - - - - - - - - -
- -
-
- - + + +

Edit Problem

+ + + + + + + + +
+ + +

Add Problem

+ + + + + + + + +
+ +

Edit Assignment

+
+
+

Assignments

+ {assignmentsList.map((assignment) => ( +
+

{assignment.name}

+
+ ))} +
+
+

Assignment Information

+
+
+ + +
-
- - + + + +
+ + + +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+ +
-
- - +
+
+
+
-
-
- - +
+

Problems

+ {assignmentProblems.map((problem, index) => ( +
+

{`Problem ${index + 1}`}

+ + +
+ ))} +
- -
- -
- +
+

Attachments

+ +
+
+

Files:

+ {files.map((file, index) => ( +
+

{`${file.name}, `}

+
+ ))} +
diff --git a/devU-client/src/components/pages/forms/courses/courseUpdatePage.tsx b/devU-client/src/components/pages/forms/courses/courseUpdatePage.tsx index 84367aa..065df76 100644 --- a/devU-client/src/components/pages/forms/courses/courseUpdatePage.tsx +++ b/devU-client/src/components/pages/forms/courses/courseUpdatePage.tsx @@ -4,14 +4,12 @@ import { useHistory, useParams } from 'react-router-dom' import PageWrapper from 'components/shared/layouts/pageWrapper' import RequestService from 'services/request.service' -// import DatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' import { ExpressValidationError } from 'devu-shared-modules' import { useActionless } from 'redux/hooks' import TextField from 'components/shared/inputs/textField' -// import Button from '@mui/material/Button' import { SET_ALERT } from 'redux/types/active.types' import { applyMessageToErrorFields, @@ -20,10 +18,25 @@ import { import formStyles from './coursesFormPage.scss' + type UrlParams = { courseId: string } +/* +copied from devU-shared>src>types>user.types.ts and edited from id? to id +to ensure number type instead of number|undefined +*/ +type User = { + id: number + externalId: string // School's unique identifier (the thing that links to the schools auth) + email: string + createdAt?: string + updatedAt?: string + preferredName?: string +} + + const CourseUpdatePage = ({ }) => { const [setAlert] = useActionless(SET_ALERT) const history = useHistory() @@ -32,8 +45,10 @@ const CourseUpdatePage = ({ }) => { number: '', semester: '', }) - const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0]) - const [endDate, setEndDate] = useState(new Date().toISOString().split("T")[0]) + const [startDate, setStartDate] = useState(new Date().toISOString()) + const [endDate, setEndDate] = useState(new Date().toISOString()) + const [studentEmail, setStudentEmail] = useState("") + const [emails, setEmails] = useState([]) const [invalidFields, setInvalidFields] = useState(new Map()) const { courseId } = useParams() as UrlParams @@ -52,12 +67,20 @@ const CourseUpdatePage = ({ }) => { }); } }, []); - const handleChange = (value: String, e: React.ChangeEvent) => { + + const handleChange = (value: string, e: React.ChangeEvent) => { const key = e.target.id const newInvalidFields = removeClassFromField(invalidFields, key) setInvalidFields(newInvalidFields) - setFormData(prevState => ({ ...prevState, [key]: value })) + + // Update form data based on input field + if (key === 'studentEmail') { + setStudentEmail(value) + } else { + setFormData(prevState => ({ ...prevState, [key]: value })) + } } + const handleStartDateChange = (event: React.ChangeEvent) => { setStartDate(event.target.value) } const handleEndDateChange = (event: React.ChangeEvent) => { setEndDate(event.target.value) } @@ -66,8 +89,8 @@ const CourseUpdatePage = ({ }) => { name: formData.name, number: formData.number, semester: formData.semester, - startDate: startDate, - endDate: endDate, + startDate: startDate + "T16:02:41.849Z", + endDate: endDate + "T16:02:41.849Z", } RequestService.put(`/api/courses/${courseId}`, finalFormData) @@ -86,22 +109,144 @@ const CourseUpdatePage = ({ }) => { }) } + // update value of file and update parsed values if file uploaded + const handleFileChange = (event: React.ChangeEvent) => { + const uploadedFile = event.target.files?.[0] || null; + if (uploadedFile) { + handleFileUpload(uploadedFile); + } + }; + + // set array of parsed emails from csv file + const handleFileUpload = (uploadedFile: File) => { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + const parsedEmails = parseCSV(text); + setEmails(parsedEmails); // Store the parsed emails in state + }; + reader.readAsText(uploadedFile); // Read the file + }; + + // return array of emails + const parseCSV = (text: string): string[] => { + const lines = text.split('\n'); + const emails: string[] = []; + const headers = lines[0].toLowerCase().split(','); + + // Find the index of the email-related fields + const emailIndex = headers.findIndex(header => + ['email', 'e-mail', 'email address', 'e-mail address'].includes(header.trim()) + ); + + if (emailIndex === -1) { + console.error("Email field not found in CSV file"); + setAlert({ autoDelete: false, type: 'error', message: "Email field not found in CSV file" }) + return []; + } + + // Extract emails + for (let i = 1; i < lines.length; i++) { + const fields = lines[i].split(','); + const email = fields[emailIndex].trim(); + if (email) { + emails.push(email); + } + } + + console.log("Parsed emails: ", emails); + return emails; + }; + + const getUserId = async (email: string) => { + // default return value 0 because userIDs start from 1 + try { + const res: User[] = await RequestService.get("/api/users/"); + const user: User | undefined = res.find((user: User) => user.email === email); + + if (user) { + return user.id; + } else { + console.log("User not found"); + return 0; + } + } catch (error) { + console.error("Error fetching users:", error); + return 0; + } + } + + const addSingleStudent = async (email: string) => { + const id = await getUserId(email) + + if (id == 0) { + setAlert({ autoDelete: false, type: 'error', message: "userID not found" }) + return + } + + const userCourseData = { + userId: id, + courseId: courseId, + role: 'student', + dropped: false + } + + try { + await RequestService.post(`/api/course/${courseId}/user-courses`, userCourseData) + setAlert({ autoDelete: true, type: 'success', message: `${email} added to course` }) + } catch (error: any) { // Use any if the error type isn't strictly defined + const message = error.message || "An unknown error occurred" + setAlert({ autoDelete: false, type: 'error', message }) + } + } + + const dropSingleStudent = async (email: string) => { + const userID = await getUserId(email) + if (!userID) { return } + + try { + await RequestService.delete(`/api/course/${courseId}/user-courses/${userID}`) + setAlert({ autoDelete: true, type: 'success', message: `${email} dropped from course` }) + } catch (error: any) { // Use any if the error type isn't strictly defined + const message = error.message || "An unknown error occurred" + setAlert({ autoDelete: false, type: 'error', message }) + } + } + const handleAddStudent = () => { - // TODO: get user id by getting email and calling /users --> search through /users --> - // RequestService.post(`/api/courses/${courseId}/users-courses/${id}:`, + console.log("emails: ", emails); + if (emails.length<1) { + // if no file inputted then addSingleStudent with email + console.log("adding single user") + addSingleStudent(studentEmail) + } else { + // if file inputted then for each email parsed from csv addSingleStudent + console.log("adding multiple users") + emails.forEach(email => { + addSingleStudent(email) + }) + } } const handleDropStudent = () => { - // get user id by getting email and calling /users --> search through /users --> - // RequestService.delete(`/api/courses/${courseId}/users-courses/${id}:`, + if (emails.length<1) { + // if no file inputted then dropSingleStudent with email + console.log("dropping single user") + dropSingleStudent(studentEmail) + } else { + // if file inputted then for each email parsed from csv dropSingleStudent + console.log("dropping multiple users") + emails.forEach(email => { + dropSingleStudent(email) + }) + } } - return (

Update Course Form

-
+

Course Details

{
- +
- +
@@ -129,10 +274,12 @@ const CourseUpdatePage = ({ }) => {

Add/Drop Students

- - -
+ + + {/* csv should be a good standard filetype */} + +
diff --git a/devU-client/src/components/pages/forms/courses/coursesFormPage.scss b/devU-client/src/components/pages/forms/courses/coursesFormPage.scss index 67877a2..27d0cf5 100644 --- a/devU-client/src/components/pages/forms/courses/coursesFormPage.scss +++ b/devU-client/src/components/pages/forms/courses/coursesFormPage.scss @@ -9,17 +9,23 @@ .form { background-color: $list-item-background; border-radius: 20px; + width: 70%; padding: 30px; - width: 50%; margin: 0 auto; } -.detailsForm { +.updateDetailsForm { @extend .form; - width: 70%; + max-width: 900px; margin: 0; } +.createDetailsForm { + @extend .form; + margin: auto; + max-width: 900px; +} + .addDropForm { @extend .form; width: 30%; @@ -80,7 +86,7 @@ input[type='file']::file-selector-button { flex-direction: column; } - .detailsForm, .addDropForm { + .updateDetailsForm, .addDropForm { width: auto; // take up full container } } diff --git a/devU-client/src/components/pages/forms/courses/coursesFormPage.tsx b/devU-client/src/components/pages/forms/courses/coursesFormPage.tsx index 3fd0642..e5814ce 100644 --- a/devU-client/src/components/pages/forms/courses/coursesFormPage.tsx +++ b/devU-client/src/components/pages/forms/courses/coursesFormPage.tsx @@ -1,53 +1,51 @@ -import React, {useState} from 'react' -import DatePicker from 'react-datepicker' -import 'react-datepicker/dist/react-datepicker.css' -import {useHistory} from 'react-router-dom' -import {ExpressValidationError} from 'devu-shared-modules' +import React, { useState } from 'react' +import { useHistory } from 'react-router-dom' +import { ExpressValidationError } from 'devu-shared-modules' import PageWrapper from 'components/shared/layouts/pageWrapper' import RequestService from 'services/request.service' -import {useActionless} from 'redux/hooks' +import { useActionless } from 'redux/hooks' import TextField from 'components/shared/inputs/textField' -import {SET_ALERT} from 'redux/types/active.types' +import { SET_ALERT } from 'redux/types/active.types' import formStyles from './coursesFormPage.scss' -import {applyMessageToErrorFields, removeClassFromField} from "../../../../utils/textField.utils"; - -import Button from '@mui/material/Button' +import { applyMessageToErrorFields, removeClassFromField } from "../../../../utils/textField.utils"; const EditCourseFormPage = () => { const [setAlert] = useActionless(SET_ALERT) const history = useHistory(); - const [formData,setFormData] = useState({ + const [formData, setFormData] = useState({ name: '', number: '', semester: '', }) - const [startDate, setStartDate] = useState(new Date()) - const [endDate, setEndDate] = useState(new Date()) + + const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0]) + const [endDate, setEndDate] = useState(new Date().toISOString().split("T")[0]) const [invalidFields, setInvalidFields] = useState(new Map()) - const handleChange = (value: String, e : React.ChangeEvent) => { + const handleChange = (value: String, e: React.ChangeEvent) => { const key = e.target.id - setFormData(prevState => ({...prevState,[key] : value})) + setFormData(prevState => ({ ...prevState, [key]: value })) const newInvalidFields = removeClassFromField(invalidFields, key) setInvalidFields(newInvalidFields) } - const handleStartDateChange = (date : Date) => {setStartDate(date)} - const handleEndDateChange = (date : Date) => {setEndDate(date)} + + const handleStartDateChange = (event: React.ChangeEvent) => { setStartDate(event.target.value) } + const handleEndDateChange = (event: React.ChangeEvent) => { setEndDate(event.target.value) } const handleSubmit = () => { const finalFormData = { - name : formData.name, - number : formData.number, - semester : formData.semester, - startDate : startDate.toISOString(), - endDate : endDate.toISOString(), + name: formData.name, + number: formData.number, + semester: formData.semester, + startDate: startDate + "T16:02:41.849Z", + endDate: endDate + "T16:02:41.849Z", } RequestService.post('/api/courses/instructor', finalFormData) @@ -63,42 +61,37 @@ const EditCourseFormPage = () => { setInvalidFields(newFields); setAlert({ autoDelete: false, type: 'error', message }) }) - .finally(() => { - }) + .finally(() => { + }) } return ( -

Course Form

-
- - - - -
-
- -
- +

Create Course

+ +
+
+ + + +
+
+ + +
+
+ + +
-
- -
- +
+
-
- -
- -
-
) diff --git a/devU-client/src/components/pages/gradebook/gradebookInstructorPage.tsx b/devU-client/src/components/pages/gradebook/gradebookInstructorPage.tsx index 44575ab..e1440dd 100644 --- a/devU-client/src/components/pages/gradebook/gradebookInstructorPage.tsx +++ b/devU-client/src/components/pages/gradebook/gradebookInstructorPage.tsx @@ -1,15 +1,16 @@ -import React, {useEffect, useState} from 'react' +import React, { useEffect, useState } from 'react' -import {Assignment, AssignmentScore, User, UserCourse} from 'devu-shared-modules' +import { Assignment, AssignmentScore, User, UserCourse } from 'devu-shared-modules' import PageWrapper from 'components/shared/layouts/pageWrapper' import LoadingOverlay from 'components/shared/loaders/loadingOverlay' +import TextField from 'components/shared/inputs/textField' import ErrorPage from '../errorPage/errorPage' import RequestService from 'services/request.service' import styles from './gradebookPage.scss' -import {useParams} from 'react-router-dom' +import { useParams } from 'react-router-dom' type TableProps = { users: User[] @@ -18,21 +19,29 @@ type TableProps = { assignmentScores: AssignmentScore[] } type RowProps = { - index: Number + index: number user: User userCourse: UserCourse assignments: Assignment[] assignmentScores: AssignmentScore[] } -const TableRow = ({index, user, userCourse, assignments, assignmentScores}: RowProps) => { +const TableRow = ({ index, user, userCourse, assignments, assignmentScores }: RowProps) => { + // style table row to alternating colors based on index odd?even + const rowClass = index % 2 === 0 ? 'evenRow' : 'oddRow'; + + // dont show row if dropped + if (userCourse.dropped) { + return (<>) + } + return ( - + {index} {user.email} - {user.externalId} + {/* {user.externalId} */} {user.preferredName} - {userCourse.dropped.toString()} + {/* {userCourse.dropped.toString()} */} {assignments.map(a => ( {assignmentScores.find(as => as.assignmentId === a.id)?.score ?? 'N/A'} ))} @@ -40,16 +49,16 @@ const TableRow = ({index, user, userCourse, assignments, assignmentScores}: RowP ) } -const GradebookTable = ({users, userCourses, assignments, assignmentScores}: TableProps) => { +const GradebookTable = ({ users, userCourses, assignments, assignmentScores }: TableProps) => { return ( - - +
#
+ - + {/* */} - + {/* */} {assignments.map((a) => { - return ( ) + return () })} {users.map((u, index) => ( { const [userCourses, setUserCourses] = useState(new Array()) //All user-course connections for the course const [assignments, setAssignments] = useState(new Array()) //All assignments in the course const [assignmentScores, setAssignmentScores] = useState(new Array()) //All assignment scores for assignments in the course - - const { courseId } = useParams<{courseId: string}>() - + + const { courseId } = useParams<{ courseId: string }>() + useEffect(() => { fetchData() - }, []) - + }, []) + const fetchData = async () => { try { const userCourses = await RequestService.get(`/api/course/${courseId}/user-courses/`) @@ -86,12 +95,12 @@ const GradebookInstructorPage = () => { const users = await RequestService.get(`/api/users/course/${courseId}`) setUsers(users) - - const assignments = await RequestService.get( `/api/course/${courseId}/assignments` ) + + const assignments = await RequestService.get(`/api/course/${courseId}/assignments`) assignments.sort((a, b) => (Date.parse(a.startDate) - Date.parse(b.startDate))) //Sort by assignment's start date setAssignments(assignments) - const assignmentScores = await RequestService.get( `/api/course/${courseId}/assignment-scores` ) + const assignmentScores = await RequestService.get(`/api/course/${courseId}/assignment-scores`) setAssignmentScores(assignmentScores) } catch (error: any) { @@ -104,13 +113,28 @@ const GradebookInstructorPage = () => { if (loading) return if (error) return + const handleStudentSearch = () => { + + } + return ( -
-

Instructor Gradebook

-
-
- */} +

Instructor Gradebook

+ {/*
*/} +
+
+

Key: ! = late, - = no submission

+
+ +
+
+ {
-

Courses

+

Courses

-

Current

+

Current

@@ -101,12 +101,12 @@ const HomePage = () => {
))} - {enrollCourses.length === 0 &&

You do not have current enrollment yet

} + {enrollCourses.length === 0 &&

You do not have current enrollment yet

}
-

Completed

+

Completed

@@ -122,12 +122,12 @@ const HomePage = () => { /> ))} - {pastCourses.length === 0 &&

No completed courses

} + {pastCourses.length === 0 &&

No completed courses

}
-

Upcoming

+

Upcoming

@@ -137,7 +137,7 @@ const HomePage = () => { ))} - {upcomingCourses.length === 0 &&

No upcoming Courses

} + {upcomingCourses.length === 0 &&

No upcoming Courses

} diff --git a/devU-client/src/components/pages/submissions/submissionDetailPage.scss b/devU-client/src/components/pages/submissions/submissionDetailPage.scss new file mode 100644 index 0000000..3bdc792 --- /dev/null +++ b/devU-client/src/components/pages/submissions/submissionDetailPage.scss @@ -0,0 +1,141 @@ +@import 'variables'; + +.heading { + text-align: center; + margin-left:250px; + +} +.scores { + padding: 20px; + } + + .submissionsLayout { + display: flex; + gap: 50px; + } + + .submissionsContainer { + flex: 1; + max-width: 250px; + padding-right: 10px; + overflow-y: auto; + } + + .submissionCard { + margin-bottom: 15px; + cursor: pointer; + } + + .submissionContent { + flex: 2; + max-height: 800px; + overflow-y: auto; + padding: 0 20px; + background-color: $background; + border: 1px solid #ddd; + color: $text-color; + border-radius: 30px; + padding:30px; + } + + pre { + color: $text-color; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 12px; + } + + .feedbackContainer{ + background-color: $background; + + } + + .scoreDisplay { + + color:$text-color; + + } + +.content_title{ + display: inline-block; + padding: 5px 10px; + background-color: $secondary; + color: $text-color; + border-radius: 5px; + font-size: 1rem; + font-weight: bold; + text-align: left; + margin-bottom: 10px; + margin-top:30px; +} + +.scrollableContent { + max-height: 350px; + overflow-y: auto; + border: 1px solid $background; + padding: 10px; + background-color: $background; + border-radius: 5px; + border: 2px solid $text-color; + padding:0px; +} + p{ + text-align: left; + margin-left: 10px; + } + + .scrollableContent::-webkit-scrollbar { + width: 12px; /* Width of the scrollbar */ + } + + .scrollableContent::-webkit-scrollbar-thumb { + background-color: $purple; + border-radius: 10px; + } + + .scrollableContent::-webkit-scrollbar-track { + background-color: #f0f0f0; + } + + .problemAnswerContainer { + margin-top: 10px; + max-height: 400px; + overflow-y: auto; + padding: 10px; + background-color: $background; + border-radius: 5px; + } + + .assignmentTable { + width: 100%; + border-collapse: collapse; + } + + .assignmentTable th, .assignmentTable td { + + padding: 8px; + text-align: left; + } + + .assignmentTable th { + background-color: $background; + font-weight: bold; + } + + .assignmentTable tr:nth-child(even) { + background-color: $background; + } + + .sub_list{ + text-align: center; + + } + @media (max-width: 768px) { + .submissionsContainer { + display:none; + } + .heading { + margin:10px; + } + +} \ No newline at end of file diff --git a/devU-client/src/components/pages/submissions/submissionDetailPage.tsx b/devU-client/src/components/pages/submissions/submissionDetailPage.tsx index c2bff14..734f513 100644 --- a/devU-client/src/components/pages/submissions/submissionDetailPage.tsx +++ b/devU-client/src/components/pages/submissions/submissionDetailPage.tsx @@ -1,15 +1,20 @@ import React, {useEffect, useState} from 'react' - import PageWrapper from 'components/shared/layouts/pageWrapper' import LoadingOverlay from 'components/shared/loaders/loadingOverlay' import ErrorPage from '../errorPage/errorPage' import RequestService from 'services/request.service' -import {Assignment, AssignmentProblem, Submission, SubmissionProblemScore, SubmissionScore} from 'devu-shared-modules' -import {Link, useParams} from 'react-router-dom' +import {Assignment, AssignmentProblem, Submission, SubmissionProblemScore,SubmissionScore} from 'devu-shared-modules' +import { useParams,/*useHistory*/} from 'react-router-dom' import Button from '../../shared/inputs/button' import TextField from '../../shared/inputs/textField' import {useActionless} from 'redux/hooks' import {SET_ALERT} from 'redux/types/active.types' +import styles from './submissionDetailPage.scss' +import 'react-datepicker/dist/react-datepicker.css' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import {CardActionArea, Typography} from '@mui/material' +import {prettyPrintDateTime} from "../../../utils/date.utils"; const SubmissionDetailPage = () => { @@ -21,10 +26,11 @@ const SubmissionDetailPage = () => { const { submissionId, assignmentId, courseId } = useParams<{submissionId: string, assignmentId: string, courseId: string}>() const [submissionScore, setSubmissionScore] = useState(null) const [submissionProblemScores, setSubmissionProblemScores] = useState(new Array()) - const [submission, setSubmission] = useState() + const [selectedSubmission, setSelectedSubmission] = useState() + //const [submission, setSubmission] = useState() const [assignmentProblems, setAssignmentProblems] = useState(new Array()) const [assignment, setAssignment] = useState() - + const [submissions, setSubmissions] = useState(new Array()) const [showManualGrade, setToggleManualGrade] = useState(false) const [formData, setFormData] = useState({ submissionId: submissionId, @@ -32,24 +38,34 @@ const SubmissionDetailPage = () => { feedback: '', releasedAt: "2024-10-05T14:48:00.00Z" }) + const fetchData = async () => { try { - const submission = await RequestService.get(`/api/course/${courseId}/assignment/${assignmentId}/submissions/${submissionId}`) - setSubmission(submission) + + //const submission = await RequestService.get(`/api/course/${courseId}/assignment/${assignmentId}/submissions/${submissionId}`) + //setSubmission(submission) const submissionScore = (await RequestService.get(`/api/course/${courseId}/assignment/${assignmentId}/submission-scores?submission=${submissionId}`)).pop() ?? null setSubmissionScore(submissionScore) - + + const selectedSubmission = await RequestService.get(`/api/course/${courseId}/assignment/${assignmentId}/submissions/${submissionId}`); + setSelectedSubmission(selectedSubmission); + const submissionProblemScores = await RequestService.get(`/api/course/${courseId}/assignment/${assignmentId}/submission-problem-scores/submission/${submissionId}`) - setSubmissionProblemScores(submissionProblemScores) + setSubmissionProblemScores(submissionProblemScores) - const assignment = await RequestService.get(`/api/course/${courseId}/assignments/${submission.assignmentId}`) + const assignment = await RequestService.get(`/api/course/${courseId}/assignments/${selectedSubmission.assignmentId}`) setAssignment(assignment) const assignmentProblems = await RequestService.get(`/api/course/${courseId}/assignment/${assignment.id}/assignment-problems`) setAssignmentProblems(assignmentProblems) + const submissionsReq = await RequestService.get(`/api/course/${courseId}/assignment/${assignmentId}/submissions/`) + submissionsReq.sort((a, b) => (Date.parse(b.createdAt ?? '') - Date.parse(a.createdAt ?? ''))) + setSubmissions(submissionsReq) + + } catch (error: any) { setError(error) } finally { @@ -79,6 +95,7 @@ const SubmissionDetailPage = () => { setAlert({ autoDelete: true, type: 'success', message: 'Submission Score Updated' }) }) + } else { // Create a new submission score @@ -90,9 +107,13 @@ const SubmissionDetailPage = () => { } } + + + if (loading) return if (error) return - + //const history = useHistory() + //var submission_form = JSON.parse(submission?.content); return( @@ -104,27 +125,85 @@ const SubmissionDetailPage = () => { )} +
+

Submissions For Assignment {assignment?.name}

+ + +
+ +
+

Submission List:

+ {submissions.map((submission, index) => ( + + + setSelectedSubmission(submission)}> + + {`Submission ${submissions.length - index}`} + {`Submitted at: ${submission.createdAt && prettyPrintDateTime(submission.createdAt)}`} + + + + ))} +
+
+ + + {selectedSubmission ? ( + <> + +
+

{submissionScore ? `Score: ${submissionScore.score}` : "Score: N/A"}

+
-

Submission Detail for {assignment?.name}

-

Submission Grades:

-
# EmailExternal IDExternal IDPreferred NameDroppedDropped{a.name}{a.name}
- {assignmentProblems.map(ap => ( - - ))} - - - {assignmentProblems.map(ap => ( - - ))} - - +
+

Feedback:

+
+
{ap.problemName} ({ap.maxScore})Total Score
{submissionProblemScores.find(sps => sps.assignmentProblemId === ap.id)?.score ?? "N/A"}{submissionScore?.score ?? "N/A"}
+ + + {assignmentProblems.map(ap => ( + + ))} + + + + + + + {assignmentProblems.map(ap => ( + + ))} + + +
{ap.problemName}Total Score
+ {submissionProblemScores.find(sps => sps.assignmentProblemId === ap.id)?.score ?? "N/A"} + {submissionScore?.score ?? "N/A"}
- View - Feedback -
+
+ {submissionScore?.feedback ? ( +

{submissionScore.feedback}

+ ) : ( +

No feedback provided for this submission.

+ )} + {submissionProblemScores.map(sps => ( +
+

Feedback for {assignmentProblems.find(ap => ap.id === sps.assignmentProblemId)?.problemName}:

+
{sps.feedback}
+
+ ))} -

Submission Content:

-
{submission?.content}
+
+

Content

+
+
{selectedSubmission.content}
+
+ + ) : ( +

Select a submission to view its content.

+ )} +
+
+
) } diff --git a/devU-client/src/components/shared/inputs/textField.tsx b/devU-client/src/components/shared/inputs/textField.tsx index 839e891..8cdda4e 100644 --- a/devU-client/src/components/shared/inputs/textField.tsx +++ b/devU-client/src/components/shared/inputs/textField.tsx @@ -77,7 +77,7 @@ const TextField = ({ "& .MuiOutlinedInput-input" : { color: theme.textColor, backgroundColor: theme.inputFieldBackground, - borderRadius: '100px', + borderRadius: '10px', // padding: '0.625rem 1rem', marginBottom: '0px' }, diff --git a/devU-client/src/components/utils/dragDropFile.scss b/devU-client/src/components/utils/dragDropFile.scss index ac3b93f..4587605 100644 --- a/devU-client/src/components/utils/dragDropFile.scss +++ b/devU-client/src/components/utils/dragDropFile.scss @@ -8,6 +8,15 @@ position: relative; } +.formFileUploadWide { + height: 16rem; + width: 80%; + margin-left: 10%; + margin-right: 10%; + text-align: center; + position: relative; +} + .inputFileUpload { display: none; } @@ -51,3 +60,4 @@ bottom: 0px; left: 0px; } + diff --git a/devU-client/src/components/utils/dragDropFile.tsx b/devU-client/src/components/utils/dragDropFile.tsx index 2c10050..b32d194 100644 --- a/devU-client/src/components/utils/dragDropFile.tsx +++ b/devU-client/src/components/utils/dragDropFile.tsx @@ -4,11 +4,17 @@ import Button from '@mui/material/Button' interface DragDropFileProps { handleFile : (file : File) => void; + className ?: string; } -function DragDropFile({handleFile} : DragDropFileProps) { +function DragDropFile({handleFile, className} : DragDropFileProps) { const [dragActive, setDragActive] = React.useState(false); const inputRef = React.useRef(null); + + // set default class name + if (className === undefined) { + className = "formFileUpload" + } // handle drag events const handleDrag = function(e : React.DragEvent) { @@ -49,7 +55,7 @@ function DragDropFile({handleFile} : DragDropFileProps) { }; return ( -
e.preventDefault()}> + e.preventDefault()}>