diff --git a/.eslintrc.json b/.eslintrc.json index 1aefcbe..88f55cf 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -163,13 +163,7 @@ ] } ], - "react/sort-prop-types": [ - "error", - { - "callbacksLast": true, - "requiredFirst": false - } - ], + "react/jsx-props-no-spreading": "off", "button-label-required/button-label-required": ["error"] }, "settings": { diff --git a/package.json b/package.json index 15d590b..3d10acf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "la-feedback-system", - "version": "1.7.0", + "version": "2.0.0", "private": true, "license": "Apache-2.0", "description": "A website to gather feedback from students after interactions with LAs", @@ -10,6 +10,7 @@ "easymde": "^2.11.0", "react": "^16.13.1", "react-bootstrap": "^1.3.0", + "react-bootstrap-typeahead": "^5.1.4", "react-dom": "^16.13.1", "react-markdown": "^4.3.1", "react-modal": "^3.11.2", @@ -26,6 +27,7 @@ "@types/nodemailer-smtp-transport": "^2.7.4", "@types/react": "^16.9.41", "@types/react-bootstrap": "^1.0.1", + "@types/react-bootstrap-typeahead": "^5.1.1", "@types/react-dom": "^16.9.8", "@types/react-modal": "^3.10.6", "@types/react-transition-group": "^4.4.0", @@ -52,7 +54,7 @@ "ts-node": "^8.10.2", "tsconfig-paths-webpack-plugin": "^3.3.0", "typescript": "^3.9.7", - "webpack": "^4.42.0", + "webpack": "^4.44.2", "webpack-cli": "^3.3.12" }, "scripts": { diff --git a/public/admin.php b/public/admin.php index 236fb65..9f17056 100644 --- a/public/admin.php +++ b/public/admin.php @@ -128,14 +128,22 @@ function get_ratings($la_username) { 'LEFT JOIN interactions i on feedback.interaction_key = i.interaction_key WHERE ' . 'feedback.interaction_key IN (SELECT interaction_key FROM interactions WHERE la_username_key = ' . '(SELECT username_key FROM cse_usernames WHERE username = ?)) ORDER BY rating DESC;'); - $ps->bind_param('s', $la_username); - $ps->execute(); - $result = $ps->get_result(); $returnVal = []; - while ($row = $result->fetch_assoc()) { - array_push($returnVal, $row); + if ($ps) { + $ps->bind_param('s', $la_username); + $ps->execute(); + if ($ps->error) { + error_log($ps->error); + } else { + $result = $ps->get_result(); + while ($row = $result->fetch_assoc()) { + array_push($returnVal, $row); + } + $ps->close(); + } + } else { + error_log("Failed to build PS to get ratings for $la_username"); } - $ps->close(); $conn->close(); return $returnVal; } diff --git a/public/announcements.php b/public/announcements.php index 23ee306..b4dacda 100644 --- a/public/announcements.php +++ b/public/announcements.php @@ -109,7 +109,7 @@ function get_announcements($course = 'all') { clear_announcements(); } else if (isset($obj) && isset($obj->{'body'})) { set_announcements($obj->{'course'}, $obj->{'body'}, $obj->{'class'}); -} else { +} else if (isset($obj->{'course'})) { $response = get_announcements($obj->{'course'}); } echo json_encode($response); \ No newline at end of file diff --git a/public/data/lib/vader/example-results.txt b/public/data/lib/vader/example-results.txt deleted file mode 100644 index dc49570..0000000 --- a/public/data/lib/vader/example-results.txt +++ /dev/null @@ -1,35 +0,0 @@ -# --- output for the above example code --- -VADER is smart, handsome, and funny. - {'neg': 0.0, 'neu': 0.254, 'pos': 0.746, 'compound': 0.8316} -VADER is smart, handsome, and funny! - {'neg': 0.0, 'neu': 0.248, 'pos': 0.752, 'compound': 0.8439} -VADER is very smart, handsome, and funny. - {'neg': 0.0, 'neu': 0.299, 'pos': 0.701, 'compound': 0.8545} -VADER is VERY SMART, handsome, and FUNNY. - {'neg': 0.0, 'neu': 0.246, 'pos': 0.754, 'compound': 0.9227} -VADER is VERY SMART, handsome, and FUNNY!!! - {'neg': 0.0, 'neu': 0.233, 'pos': 0.767, 'compound': 0.9342} -VADER is VERY SMART, really handsome, and INCREDIBLY FUNNY!!! - {'neg': 0.0, 'neu': 0.294, 'pos': 0.706, 'compound': 0.9469} -The book was good. - {'neg': 0.0, 'neu': 0.508, 'pos': 0.492, 'compound': 0.4404} -The book was kind of good. - {'neg': 0.0, 'neu': 0.657, 'pos': 0.343, 'compound': 0.3832} -The plot was good, but the characters are uncompelling and the dialog is not great. - {'neg': 0.327, 'neu': 0.579, 'pos': 0.094, 'compound': -0.7042} -A really bad, horrible book. - {'neg': 0.791, 'neu': 0.209, 'pos': 0.0, 'compound': -0.8211} -At least it isn't a horrible book. - {'neg': 0.0, 'neu': 0.637, 'pos': 0.363, 'compound': 0.431} -:) and :D - {'neg': 0.0, 'neu': 0.124, 'pos': 0.876, 'compound': 0.7925} - - {'neg': 0.0, 'neu': 0.0, 'pos': 0.0, 'compound': 0.0} -Today sux - {'neg': 0.714, 'neu': 0.286, 'pos': 0.0, 'compound': -0.3612} -Today sux! - {'neg': 0.736, 'neu': 0.264, 'pos': 0.0, 'compound': -0.4199} -Today SUX! - {'neg': 0.779, 'neu': 0.221, 'pos': 0.0, 'compound': -0.5461} -Today kinda sux! But I'll get by, lol - {'neg': 0.195, 'neu': 0.531, 'pos': 0.274, 'compound': 0.2228} \ No newline at end of file diff --git a/public/data/tableSetup.sql b/public/data/tableSetup.sql index 53bfccc..a13b54b 100755 --- a/public/data/tableSetup.sql +++ b/public/data/tableSetup.sql @@ -6,6 +6,7 @@ drop table if exists feedback; drop table if exists interactions; +drop table if exists logins; drop table if exists cse_usernames; drop table if exists announcements; drop view if exists course_interactions; @@ -19,14 +20,15 @@ drop view if exists interaction_type_readable; CREATE TABLE cse_usernames ( - username_key int auto_increment unique primary key, - username varchar(20) not null, - name varchar(70), - course varchar(10), - constraint cse_usernames_username_key_uindex - unique (username_key), - constraint cse_usernames_username_uindex - unique (username) + username_key int auto_increment unique primary key, + + # Not unique due to potential for students to appear multiple times in the future + username varchar(20), + canvas_username varchar(20), + + name varchar(70), + course varchar(10), + email varchar(100) ); CREATE TABLE interactions @@ -54,9 +56,7 @@ CREATE TABLE logins login_key int auto_increment unique primary key, la_username_key int not null, time_of_interaction timestamp default current_timestamp() not null, - constraint interactions_interaction_key_uindex - unique (login_key), - constraint interactions_la_fk + constraint interactions_la_login_fk foreign key (la_username_key) references cse_usernames (username_key) on delete cascade ); diff --git a/public/getStudents.php b/public/getStudents.php new file mode 100644 index 0000000..d3ea7c5 --- /dev/null +++ b/public/getStudents.php @@ -0,0 +1,36 @@ +prepare('SELECT username_key AS id, name, course, canvas_username FROM cse_usernames;'); + $ps->execute(); + $result = $ps->get_result(); + $returnVal = []; + while ($row = $result->fetch_assoc()) { + array_push($returnVal, $row); + } + $ps->close(); + $conn->close(); + return $returnVal; +} + +$response = [ + 'students' => get_students(), +]; +echo json_encode($response); \ No newline at end of file diff --git a/public/index.html b/public/index.html index b9b4387..e82e2d6 100644 --- a/public/index.html +++ b/public/index.html @@ -68,5 +68,6 @@ + diff --git a/public/sendEmail.php b/public/sendEmail.php index e9227a7..7965ce9 100644 --- a/public/sendEmail.php +++ b/public/sendEmail.php @@ -16,7 +16,7 @@ // Call with a POST call with a JSON body as follows: //{ -// studentCSE: string, +// studentID: string, // laCSE: string, // course: string, // interactionType: string | null @@ -37,7 +37,8 @@ function send_email($obj, $interaction_id) { $subject = shell_exec('grep "" form.php | sed "s/\s*<\/*title>//gi"'); $body = shell_exec('cat ./data/emailBody.html | sed "s/INTERACTION_ID/' . $interaction_id . '/gi" | sed "s/LA_NAME/' . $name . '/gi"'); - if ($body && mail($obj->{'studentCSE'} . '@cse.unl.edu', $subject, $body, $headers)) { + $address = get_email($obj->{'studentID'}); + if ($body && $address && mail($address, $subject, $body, $headers)) { update_interaction_for_feedback($interaction_id); header('Status: 200 OK'); @@ -56,15 +57,12 @@ function send_email($obj, $interaction_id) { } $obj = json_decode(file_get_contents('php://input')); -if (isset($obj) && isset($obj->{'laCSE'}) && isset($obj->{'studentCSE'}) && isset($obj->{'course'}) - && $obj->{'studentCSE'} !== $obj->{'laCSE'}) { - $student_username = str_replace('@cse.unl.edu', '',$obj->{'studentCSE'}); - $interaction_id = add_interaction($obj->{'laCSE'}, $student_username, $obj->{'course'}, $obj->{'interactionType'}); +if (isset($obj) && isset($obj->{'laCSE'}) && isset($obj->{'studentID'}) && isset($obj->{'course'})) { + $interaction_id = add_interaction($obj->{'laCSE'}, $obj->{'studentID'}, $obj->{'course'}, $obj->{'interactionType'}); if ($interaction_id !== null && $interaction_id > 0 && - ($obj->{'interactionType'} === 'cohort meeting' || has_been_a_week($obj->{'laCSE'}) || - mt_rand() / mt_getrandmax() < FEEDBACK_RATE) && - !received_email_today($obj->{'studentCSE'})) { + (has_been_a_week($obj->{'laCSE'}) || mt_rand() / mt_getrandmax() < FEEDBACK_RATE) && + !received_email_today($obj->{'studentID'})) { send_email($obj, $interaction_id); } else { echo json_encode([ diff --git a/public/sqlManager.php b/public/sqlManager.php index 500f943..94741e4 100644 --- a/public/sqlManager.php +++ b/public/sqlManager.php @@ -157,32 +157,35 @@ function add_cse($username) { return null; } -function add_interaction($la_cse, $student_cse, $course, $interaction_type) { +function add_interaction($la_cse, $student_id, $course, $interaction_type) { $la_id = get_username_id($la_cse); - $student_id = get_username_id($student_cse); + if ($la_id === $student_id) return null; $conn = get_connection(); if ($conn !== null && is_int($la_id) && is_int($student_id)) { $conn->begin_transaction(); $ps = $conn->prepare("INSERT INTO interactions (la_username_key, student_username_key, course, " . - "interaction_type) VALUE ((SELECT username_key FROM cse_usernames WHERE username=?), " . - "(SELECT username_key FROM cse_usernames WHERE username=?), ?, ?);"); + "interaction_type) VALUE (?, ?, ?, ?);"); if ($ps) { - $ps->bind_param("ssss", $la_cse, $student_cse, $course, $interaction_type); + $ps->bind_param("siss", $la_id, $student_id, $course, $interaction_type); $ps->execute(); - $conn->commit(); - $returnVal = $ps->insert_id; + if ($ps->error) { + error_log($ps->error); + } else { + $conn->commit(); + $returnVal = $ps->insert_id; - $ps->close(); - $conn->close(); + $ps->close(); + $conn->close(); - return $returnVal; + return $returnVal; + } } else { - $conn->close(); - error_log("Failed to build prepped statement to add interaction between $la_cse and $student_cse"); + error_log("Failed to build prepped statement to add interaction between $la_cse and $student_id"); } } else { - error_log('Failed to add interaction for { la: ' . $la_cse . ', student: ' . $student_cse . ' }'); + error_log('Failed to add interaction for { la: ' . $la_cse . ', student: ' . $student_id . ' }'); } + $conn->close(); return null; } @@ -233,14 +236,14 @@ function has_been_a_week($username) { return false; } -function received_email_today($student_cse) { +function received_email_today($student_id) { $conn = get_connection(); - if ($conn !== null && $student_cse !== null) { + if ($conn !== null && $student_id !== null) { $ps = $conn->prepare("SELECT time_of_interaction AS time FROM interactions WHERE seeking_feedback = 1 " . - "AND student_username_key = (SELECT username_key FROM cse_usernames WHERE username=?) " . + "AND student_username_key = ? " . "ORDER BY time_of_interaction DESC LIMIT 1;"); if ($ps) { - $ps->bind_param("s", $student_cse); + $ps->bind_param("s", $student_id); $ps->execute(); $result = $ps->get_result()->fetch_assoc()['time']; @@ -259,7 +262,7 @@ function received_email_today($student_cse) { return false; } } else { - error_log("Failed to build prepped statement for checking if $student_cse received an email"); + error_log("Failed to build prepped statement for checking if $student_id received an email"); $ps->close(); $conn->close(); return false; @@ -280,19 +283,17 @@ function get_username_id($username) { return null; } + $id = -1; $ps->bind_param("s", $username); $ps->execute(); - if ($ps->num_rows() > 0) { - $ps->bind_result($id); - $ps->fetch(); - $ps->close(); - $conn->close(); - return $id; - } else { - $ps->close(); - $conn->close(); - return add_cse($username); + $ps->bind_result($id); + $ps->fetch(); + if ($ps->num_rows() == -1) { + $id = add_cse($username); } + $ps->close(); + $conn->close(); + return $id; } return null; } @@ -314,3 +315,29 @@ function get_course_counts() { return []; } } + +function get_email($student_id) { + if ($student_id === null) return null; + + $conn = get_connection(); + $result = null; + if ($conn !== null) { + $ps = $conn->prepare("SELECT IFNULL(email, CONCAT(username, '@cse.unl.edu')) AS 'email' " . + "FROM cse_usernames WHERE username_key=?;"); + if ($ps) { + $ps->bind_param("i", $student_id); + $ps->execute(); + $assoc = $ps->get_result()->fetch_assoc(); + if ($assoc) { + $result = $assoc['email']; + } else if ($ps->error) { + error_log($ps->error); + } + $ps->close(); + } else { + error_log("Failed to build prepped statement for getting email for $student_id"); + } + } + $conn->close(); + return $result; +} diff --git a/public/uploadStudents.php b/public/uploadStudents.php new file mode 100644 index 0000000..d7c0be3 --- /dev/null +++ b/public/uploadStudents.php @@ -0,0 +1,36 @@ +<?php +include_once 'sqlManager.php'; +// Call and give the path to the file with student info as the only argument +// This file is expected to be formatted with every line conforming to the following spec: +// "course;name;canvasID;email" + +if ($argc === 0) { + echo "Include a file name as an argument"; + return; +} + +$file_contents = explode("\n", file_get_contents($argv[1])); + +$values = ""; + +foreach ($file_contents as $line) { + $line = trim($line); + if (strlen($line) > 0) { + if (strlen($values) > 0) $values .= ', '; + $values .= "('" . join("','", explode(';', $line)) . "')"; + } +} + +$conn = get_connection(); +$conn->begin_transaction(); +$ps = $conn->prepare("INSERT INTO cse_usernames (course, name, canvas_username, email) VALUES $values;"); +if ($ps) { + $ps->execute(); + if ($ps->error) error_log($ps->error); + $conn->commit(); + $ps->close(); +} else { + error_log("Failed to build prepped statement to add values"); + error_log("INSERT INTO cse_usernames (username, name, course, email) VALUES $values;"); +} +$conn->close(); \ No newline at end of file diff --git a/src/CHANGELOG.json b/src/CHANGELOG.json index f20390c..6ff07d2 100644 --- a/src/CHANGELOG.json +++ b/src/CHANGELOG.json @@ -1,5 +1,6 @@ { "changes": [ + "## 2.0.0\n- Auto-suggest students registered in the course\n- Use a typeahead input field for students\n- Prevent logging student interactions with a student that doesn't exist.", "## 1.7.0\n- Add interaction counter", "## 1.6.1\n- Allow admins to download feedback as CSV", "## 1.6.0\n- Add announcement system", diff --git a/src/components/FeedbackHeader.tsx b/src/components/FeedbackHeader.tsx index 4f0dbc3..886147b 100644 --- a/src/components/FeedbackHeader.tsx +++ b/src/components/FeedbackHeader.tsx @@ -43,7 +43,7 @@ const FeedbackHeader = ({ style }: Props) => { <p style={{ marginLeft: 0 }}> This web interface allows LAs to receive anonymous feedback on their performance from students. Select the course you worked with and - enter the Student's CSE username + enter the Student's name </p> </> )} diff --git a/src/components/FeedbackTimeText.tsx b/src/components/FeedbackTimeText.tsx index 96b9905..ec9d318 100644 --- a/src/components/FeedbackTimeText.tsx +++ b/src/components/FeedbackTimeText.tsx @@ -25,7 +25,8 @@ const FeedbackTimeText = () => { const minutes = Math.floor(time / 1000 / 60); const seconds = (time / 1000) % 60; const numberOfInteractions = interactions.ratings.reduce( - (acc: number, la: InteractionRecord) => acc + la.fCount, + (acc: number, { fCount }: InteractionRecord) => + Number.isInteger(fCount) ? acc + fCount : acc, 0 ); timeText = `Average time to give feedback for ${numberOfInteractions} interaction${ @@ -33,9 +34,9 @@ const FeedbackTimeText = () => { }: ${minutes} minute${minutes !== 1 ? 's' : ''}${ seconds > 1 ? ` and ${seconds.toPrecision(3)} seconds` : '' }`; + return <p>{timeText}</p>; } - - return <>{timeText && <p>{timeText}</p>}</>; + return null; }; export default FeedbackTimeText; diff --git a/src/components/SentimentText.tsx b/src/components/SentimentText.tsx index 6b0b835..a40df48 100644 --- a/src/components/SentimentText.tsx +++ b/src/components/SentimentText.tsx @@ -21,7 +21,7 @@ const SentimentText = () => { return ( <> - {sentiment && sentiment > 0 && ( + {sentiment !== null && sentiment > 0 && ( <p>{sentiment.toFixed(2)}% average sentiment</p> )} </> diff --git a/src/components/StudentSelectionTypeahead.tsx b/src/components/StudentSelectionTypeahead.tsx new file mode 100644 index 0000000..6c8f01e --- /dev/null +++ b/src/components/StudentSelectionTypeahead.tsx @@ -0,0 +1,110 @@ +import React, { Fragment, ReactNode, useMemo } from 'react'; + +import { + Highlighter, + Menu, + MenuItem, + Token, + TokenProps, + Typeahead, + TypeaheadMenuProps, + TypeaheadProps, + TypeaheadResult, + TypeaheadState, +} from 'react-bootstrap-typeahead'; + +import shallow from 'zustand/shallow'; + +import { Student } from '../redux/modules/Types'; +import { groupBy } from '../statics/Utils'; +import Redux, { AppReduxState } from '../redux/modules'; + +type Props = Omit<TypeaheadProps<Student>, 'options'> & { + course?: string | null; +}; + +const StudentSelectionTypeahead = ({ + course: filterCourse, + ...rest +}: Props) => { + const { students } = Redux( + (state: AppReduxState) => ({ + students: state.students, + }), + shallow + ); + + const options = useMemo(() => { + if (filterCourse != null && filterCourse !== 'choose') { + const courseTrimmed = filterCourse.trim().toLowerCase(); + return students.filter( + (student) => + student.course && student.course.toLowerCase().includes(courseTrimmed) + ); + } + return students; + }, [filterCourse, students]); + + const renderMenu = ( + results: TypeaheadResult<Student>[], + menuProps: TypeaheadMenuProps<Student>, + state: TypeaheadState<Student> + ) => { + let index = -1; + const studentsByCourse = groupBy<Student>(results, 'course'); + const courses = Object.keys(studentsByCourse) + .sort() + .map((course) => ( + <Fragment key={course}> + {index !== -1 && <Menu.Divider />} + <Menu.Header>{course}</Menu.Header> + {studentsByCourse[course].map((student) => { + index += 1; + return ( + <MenuItem key={index} option={student} position={index}> + <Highlighter search={state.text}>{student.name}</Highlighter> + <div> + <small>{`Canvas Username: ${student.canvas_username}`}</small> + </div> + </MenuItem> + ); + })} + </Fragment> + )); + + return <Menu {...menuProps}>{courses}</Menu>; + }; + + const renderToken = ( + selectedItem: Student, + { onRemove }: TokenProps, + index: number + ): ReactNode => ( + <Token key={index} onRemove={onRemove}> + {`${selectedItem.name} (${selectedItem.canvas_username})`} + </Token> + ); + + return ( + <Typeahead<Student> + {...rest} + labelKey="name" + multiple + renderMenu={renderMenu} + renderToken={renderToken} + options={options} + clearButton + minLength={2} + flip + highlightOnlyResult + caseSensitive={false} + allowNew={false} + /> + ); +}; + +StudentSelectionTypeahead.defaultProps = { + course: undefined, +}; + +export default StudentSelectionTypeahead; diff --git a/src/redux/actions/ClearAnnouncements.tsx b/src/redux/actions/ClearAnnouncements.tsx index 2bb695e..0577bec 100644 --- a/src/redux/actions/ClearAnnouncements.tsx +++ b/src/redux/actions/ClearAnnouncements.tsx @@ -35,7 +35,7 @@ const clearAnnouncements = async (): Promise<void> => { }), shallow ); - setResponse(error); + setResponse({ class: 'danger', content: error }); }); }; diff --git a/src/redux/actions/GetAnnouncements.tsx b/src/redux/actions/GetAnnouncements.tsx index 9cb2884..8905494 100644 --- a/src/redux/actions/GetAnnouncements.tsx +++ b/src/redux/actions/GetAnnouncements.tsx @@ -40,7 +40,7 @@ const getAnnouncements = async ( }; } }) - .catch((error) => setResponse(error)); + .catch((error) => setResponse({ class: 'danger', content: error })); return responseObj; }; diff --git a/src/redux/actions/GetCounts.ts b/src/redux/actions/GetCounts.ts index dd88006..a983542 100644 --- a/src/redux/actions/GetCounts.ts +++ b/src/redux/actions/GetCounts.ts @@ -28,7 +28,9 @@ const getCounts = async (): Promise<CourseCount[]> => { .then((json: CountResponse[]) => { courses = json; }) - .catch((error) => api.getState().setResponse(error)); + .catch((error) => + api.getState().setResponse({ class: 'danger', content: error }) + ); return courses.map((avg: CountResponse) => ({ ...avg, count: Number.parseFloat(avg.count), diff --git a/src/redux/actions/GetInteractionBreakdowns.ts b/src/redux/actions/GetInteractionBreakdowns.ts index 363920f..ddb7f1e 100644 --- a/src/redux/actions/GetInteractionBreakdowns.ts +++ b/src/redux/actions/GetInteractionBreakdowns.ts @@ -31,7 +31,9 @@ const getBreakdowns = async (): Promise<InteractionBreakdown[]> => { .then((json: InteractionBreakdownResponse[]) => { breakdowns = json; }) - .catch((error) => api.getState().setResponse(error)); + .catch((error) => + api.getState().setResponse({ class: 'danger', content: error }) + ); return breakdowns.map((int) => ({ ...int, name: int.name ?? int.username, diff --git a/src/redux/actions/GetInteractionTimes.ts b/src/redux/actions/GetInteractionTimes.ts index 1090cce..fa5155a 100644 --- a/src/redux/actions/GetInteractionTimes.ts +++ b/src/redux/actions/GetInteractionTimes.ts @@ -33,7 +33,9 @@ const getInteractionTimes = async (): Promise<InteractionTime[]> => { course: time.course, })); }) - .catch((error) => api.getState().setResponse(error)); + .catch((error) => + api.getState().setResponse({ class: 'danger', content: error }) + ); return times; }; diff --git a/src/redux/actions/GetInteractions.ts b/src/redux/actions/GetInteractions.ts index 131e31f..b6b46fd 100644 --- a/src/redux/actions/GetInteractions.ts +++ b/src/redux/actions/GetInteractions.ts @@ -16,7 +16,7 @@ type InteractionResponseRecord = { avg: string; count: string; course: string; - fCount: string; + feedbackCount: string; username: string; name?: string; }; @@ -63,12 +63,9 @@ const getInteractions = async ( const intHolder: InteractionResponseSummary = json; interactions.isAdmin = intHolder.isAdmin; interactions.time = - intHolder.time === null || intHolder.time.startsWith('-') - ? 0 - : Number.parseFloat(intHolder.time); + intHolder.time === null ? 0 : Number.parseFloat(intHolder.time); interactions.outstanding = - intHolder.outstanding === null || - intHolder.outstanding.startsWith('-') + intHolder.outstanding === null ? 0 : Number.parseFloat(intHolder.outstanding); interactions.sentiment = @@ -81,12 +78,12 @@ const getInteractions = async ( avg: Number.parseFloat(rating.avg), course: rating.course ?? '---', count: Number.parseInt(rating.count, 10), - fCount: Number.parseInt(rating.fCount, 10), + fCount: Number.parseInt(rating.feedbackCount, 10), })) .filter((int) => int.count > 0); } }) - .catch((error) => setResponse(error)); + .catch((error) => setResponse({ class: 'danger', content: error })); } return interactions; }; diff --git a/src/redux/actions/GetRatings.ts b/src/redux/actions/GetRatings.ts index 9a744e6..1f10389 100644 --- a/src/redux/actions/GetRatings.ts +++ b/src/redux/actions/GetRatings.ts @@ -37,7 +37,7 @@ const GetRatings = async ( rating: Number.parseFloat(rating.rating), })); }) - .catch((error) => setResponse(error)); + .catch((error) => setResponse({ class: 'danger', content: error })); return ratings; }; diff --git a/src/redux/actions/GetStudents.ts b/src/redux/actions/GetStudents.ts new file mode 100644 index 0000000..f97d30e --- /dev/null +++ b/src/redux/actions/GetStudents.ts @@ -0,0 +1,38 @@ +/*------------------------------------------------------------------------------ + - Copyright (c) 2020. + - + - File created by Hundter Biede for the UNL CSE Learning Assistant Program + -----------------------------------------------------------------------------*/ + +import { api } from 'redux/modules'; +import { Student } from 'redux/modules/Types'; +import ServiceInterface from 'statics/ServiceInterface'; + +const getStudents = async (): Promise<Student[]> => { + let students: Student[] = []; + + const requestOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }; + + await fetch(`${ServiceInterface.getPath()}/getStudents.php`, requestOptions) + .then((response: Response) => response.json()) + .then(({ students: studentResponse }: { students: Student[] }) => { + students = studentResponse; + if (studentResponse.length === 0) { + api.getState().setResponse({ + class: 'danger', + content: 'No students registered for any courses', + }); + } + }) + .catch((error) => + api.getState().setResponse({ class: 'danger', content: error }) + ); + return students; +}; + +export default getStudents; diff --git a/src/redux/actions/GetUsername.ts b/src/redux/actions/GetUsername.ts index 0f5452b..f31ae94 100644 --- a/src/redux/actions/GetUsername.ts +++ b/src/redux/actions/GetUsername.ts @@ -11,6 +11,11 @@ import { api, AppReduxState } from 'redux/modules'; import ServiceInterface from 'statics/ServiceInterface'; const getUsername = async (set: SetState<AppReduxState>): Promise<void> => { + if (window.location.host === 'localhost') { + set(() => ({ username: 'dev' })); + return; + } + const ticketService = `${ServiceInterface.getPath()}/ticketAccessor.php`; const ticket = new URLSearchParams(window.location.search).get('ticket'); if (ticket === null) { @@ -33,7 +38,7 @@ const getUsername = async (set: SetState<AppReduxState>): Promise<void> => { } }) .catch((error) => { - api.getState().setResponse(error); + api.getState().setResponse({ class: 'danger', content: error }); }); }; diff --git a/src/redux/actions/SendEmail.ts b/src/redux/actions/SendEmail.ts index b80e624..82aa413 100644 --- a/src/redux/actions/SendEmail.ts +++ b/src/redux/actions/SendEmail.ts @@ -8,13 +8,13 @@ import { api } from 'redux/modules'; import ServiceInterface from 'statics/ServiceInterface'; const SendEmail = ( - studentCSE: string | null, + studentID: number, course: string | null = null, multiples = false, interactionType: string | null = null ) => { const { setResponse } = api.getState(); - if (studentCSE === null) { + if (studentID === null) { setResponse({ class: 'danger', content: 'Must set a username', @@ -22,7 +22,7 @@ const SendEmail = ( return; } - ServiceInterface.sendEmail(studentCSE, course, interactionType) + ServiceInterface.sendEmail(studentID, course, interactionType) .then((response) => { if (response === '0' || response === 0) { setResponse({ diff --git a/src/redux/actions/SetAnnouncements.tsx b/src/redux/actions/SetAnnouncements.tsx index 39f1396..84a1857 100644 --- a/src/redux/actions/SetAnnouncements.tsx +++ b/src/redux/actions/SetAnnouncements.tsx @@ -37,7 +37,7 @@ const setAnnouncements = async (props: AnnouncementProps): Promise<number> => { }), shallow ); - setResponse(error); + setResponse({ class: 'danger', content: error }); response = 1; }); return response; diff --git a/src/redux/actions/index.ts b/src/redux/actions/index.ts index 237f0b4..bfb8b4c 100644 --- a/src/redux/actions/index.ts +++ b/src/redux/actions/index.ts @@ -12,6 +12,7 @@ export { default as GetInteractionBreakdowns } from './GetInteractionBreakdowns' export { default as GetInteractionTimes } from './GetInteractionTimes'; export { default as GetInteractions } from './GetInteractions'; export { default as GetRatings } from './GetRatings'; +export { default as GetStudents } from './GetStudents'; export { default as GetUsername } from './GetUsername'; export { default as NameRest } from './NameRest'; export { default as SetAnnouncements } from './SetAnnouncements'; diff --git a/src/redux/modules/Types.ts b/src/redux/modules/Types.ts index e68f4a7..1b975f9 100644 --- a/src/redux/modules/Types.ts +++ b/src/redux/modules/Types.ts @@ -51,3 +51,10 @@ export type InteractionBreakdown = { }; export const DEFAULT_COURSE_NAME = '---'; + +export type Student = { + canvas_username: string; + course: string; + id: number; + name: string; +}; diff --git a/src/redux/modules/index.ts b/src/redux/modules/index.ts index eb64519..11753cd 100644 --- a/src/redux/modules/index.ts +++ b/src/redux/modules/index.ts @@ -9,6 +9,7 @@ import create from 'zustand'; import SendEmail from 'redux/actions/SendEmail'; import { + ClearAnnouncements, CourseRest, GetAnnouncements, GetCounts, @@ -16,20 +17,21 @@ import { GetInteractionTimes, GetInteractions, GetRatings, + GetStudents, GetUsername, NameRest, SetAnnouncements, - ClearAnnouncements, } from 'redux/actions'; import { CourseCount, InteractionBreakdown, + InteractionTime, ResponseMessage, SetCourseArgs, SetNameArgs, SetSelectedUsernameArgs, - InteractionTime, + Student, } from 'redux/modules/Types'; import ServiceInterface from 'statics/ServiceInterface'; @@ -47,6 +49,7 @@ export type AppReduxState = { getInteractions: () => void; getName: () => void; getRatings: () => void; + getStudents: () => void; getTimes: () => Promise<InteractionTime[]>; incrementSessionInteractions: (student: string) => void; interactions: InteractionSummary; @@ -58,7 +61,7 @@ export type AppReduxState = { response: ResponseMessage | null; selectedUsername: string; sendEmail: ( - studentCSE: string | null, + studentID: number, course?: string | null, multiples?: boolean, interactionType?: string | null @@ -71,6 +74,7 @@ export type AppReduxState = { setResponse: (res: ResponseMessage | null) => void; setSelectedUsername: (args: SetSelectedUsernameArgs) => void; startUp: () => void; + students: Student[]; username: string; }; @@ -83,6 +87,7 @@ export const [useStore, api] = create<AppReduxState>((set, get) => ({ get().getCourse(); get().getInteractions(); get().getAnnouncements(); + get().getStudents(); }); }, name: '', @@ -169,6 +174,12 @@ export const [useStore, api] = create<AppReduxState>((set, get) => ({ }, setAnnouncements: SetAnnouncements, clearAnnouncements: ClearAnnouncements, + students: [], + getStudents: () => { + GetStudents().then((result) => { + set(() => ({ students: result })); + }); + }, })); export default useStore; diff --git a/src/screens/FeedbackForm.tsx b/src/screens/FeedbackForm.tsx index fc4e3ca..9d17cd1 100644 --- a/src/screens/FeedbackForm.tsx +++ b/src/screens/FeedbackForm.tsx @@ -18,28 +18,26 @@ import Collapse from 'react-bootstrap/Collapse'; import Form from 'react-bootstrap/Form'; import FormGroup from 'react-bootstrap/FormGroup'; import Row from 'react-bootstrap/Row'; -import Tooltip from 'react-bootstrap/Tooltip'; import shallow from 'zustand/shallow'; -import OverlayTrigger from 'react-bootstrap/esm/OverlayTrigger'; - import Redux, { api, AppReduxState } from 'redux/modules'; import { COURSES } from 'statics/Types'; +import { Student } from '../redux/modules/Types'; +import StudentSelectionTypeahead from '../components/StudentSelectionTypeahead'; + const LA_USERNAME_ID = 'la_username'; const COURSE_ID = 'course'; -const STUDENT_CSE_ID = 'student_cse_login'; +const STUDENT_ID = 'student_login'; const INTERACTION_TYPE_ID = 'interaction_type'; const LA_LABEL = 'LA CSE Username'; const COURSE_LABEL = 'Course'; -const STUDENT_LABEL = 'Student CSE Username'; +const STUDENT_LABEL = 'Student'; const INTERACTION_TYPE_LABEL = 'Interaction Type'; -const BANNED_USERNAMES = [...COURSES, '155h', '156h', 'i', "don't", 'know']; - type Props = { style?: CSSProperties; }; @@ -73,12 +71,22 @@ const FeedbackForm = ({ style }: Props) => { const [usernameRecord, setUsernameRecord] = useState<string>( isAdmin ? selectedUsername : username ); - const [studentCSE, setStudentCSE] = useState<string>(''); + const [disabled, setDisabled] = useState(false); const [courseRecord, setCourseRecord] = useState<string | null>(course); + const [students, setStudents] = useState<Student[]>([]); + const setStudentCallback = (newStudents: Student[]) => { + if ( + !courseRecord || + (courseRecord === 'choose' && newStudents.length > 0) + ) { + setCourseRecord(newStudents[0].course); + } + setStudents(newStudents); + setDisabled(false); + }; const [interactionTypeRecord, setInteractionTypeRecord] = useState< string | null >(course); - const [disabled, setDisabled] = useState(false); useEffect(() => { api.subscribe( @@ -104,9 +112,6 @@ const FeedbackForm = ({ style }: Props) => { case COURSE_ID: setCourseRecord(value === 'choose' ? null : value); break; - case STUDENT_CSE_ID: - setStudentCSE(value); - break; case INTERACTION_TYPE_ID: setInteractionTypeRecord(value); break; @@ -123,7 +128,7 @@ const FeedbackForm = ({ style }: Props) => { setResponse(null); setDisabled(true); - const studentUserValid = studentCSE && studentCSE.trim().length > 0; + const studentUserValid = students && students.length > 0; const courseIsValid = courseRecord && courseRecord.trim().length > 0 && @@ -135,41 +140,23 @@ const FeedbackForm = ({ style }: Props) => { const laUserValid = usernameRecord && usernameRecord.trim().length > 0 && - usernameRecord !== studentCSE; + !students.some((student) => student.canvas_username === usernameRecord); if (studentUserValid && courseIsValid && intTypeIsValid && laUserValid) { if (isAdmin) { setSelectedUsername({ username: usernameRecord }); } - // Send to all students listed (Based on regex expression /[,\s|&+;]/) - const students = Array.from(new Set(studentCSE?.split(/[,|&+;]/) ?? [])) - .map((student) => student.trim()) - .filter((student) => student.length > 0); - - if ( - students.some( - (student) => - BANNED_USERNAMES.includes(student.toLowerCase()) || - student.includes('(') || - student.includes(')') || - student.includes(' ') - ) - ) { - setResponse({ class: 'danger', content: 'Invalid username(s)' }); - } else { - students.forEach((student: string) => { - incrementSessionInteractions(student); - sendEmail( - student, - courseRecord, - students.length > 1, - interactionTypeRecord - ); - }); - - setStudentCSE(''); - } + students.forEach((student: Student) => { + incrementSessionInteractions(student.canvas_username); + sendEmail( + student.id, + student.course, + students.length > 1, + interactionTypeRecord + ); + }); + setStudents([]); } else { let issue = 'A field'; if (!studentUserValid) { @@ -177,10 +164,7 @@ const FeedbackForm = ({ style }: Props) => { } else if (!courseIsValid) { issue = 'The course username'; } else if (!laUserValid) { - issue = - usernameRecord === studentCSE - ? 'You cannot Self-interact' - : "The LA's username"; + issue = "The LA's username"; } else if (!intTypeIsValid) { issue = 'The interaction type'; } @@ -196,7 +180,7 @@ const FeedbackForm = ({ style }: Props) => { }, [ setResponse, - studentCSE, + students, courseRecord, interactionTypeRecord, usernameRecord, @@ -216,12 +200,12 @@ const FeedbackForm = ({ style }: Props) => { [sessionInteractions] ); const expandedSessionCountText = useMemo(() => { - const students = Object.keys(sessionInteractions).sort((a, b) => + const studentsHelped = Object.keys(sessionInteractions).sort((a, b) => a.localeCompare(b) ); - return `Student${students.length > 0 ? 's' : ''} helped:\n${students.join( - ', ' - )}`; + return `Student${ + studentsHelped.length > 0 ? 's' : '' + } helped:\n${studentsHelped.join(', ')}`; }, [sessionInteractions]); const toggleSessionCollapsable = useCallback( () => setSessionTextOpen(!isSessionTextOpen), @@ -290,31 +274,26 @@ const FeedbackForm = ({ style }: Props) => { </div> </FormGroup> - <FormGroup as={Row} controlId={STUDENT_CSE_ID}> + <FormGroup as={Row} controlId={STUDENT_ID}> <Form.Label className="col-sm-5">{STUDENT_LABEL}</Form.Label> - <OverlayTrigger - placement="right" - overlay={ - <Tooltip id="username-tooltip"> - Submit multiple usernames with a comma separated list - </Tooltip> - } - > - <div className="col-sm-7"> - <Form.Control - type="text" - role="textbox" - placeholder={STUDENT_LABEL} - aria-placeholder={STUDENT_LABEL} - value={studentCSE ?? undefined} - onChange={handleChange} - required - aria-required - aria-haspopup - autoComplete="false" - /> - </div> - </OverlayTrigger> + <div className="col-sm-7"> + <StudentSelectionTypeahead + id={STUDENT_ID} + placeholder={STUDENT_LABEL} + selected={students} + onChange={setStudentCallback} + course={courseRecord} + inputProps={{ + type: 'text', + role: 'textbox', + 'aria-placeholder': STUDENT_LABEL, + required: true, + 'aria-required': true, + 'aria-haspopup': true, + autoComplete: 'false', + }} + /> + </div> </FormGroup> <FormGroup as={Row} controlId={INTERACTION_TYPE_ID}> diff --git a/src/statics/ServiceInterface.ts b/src/statics/ServiceInterface.ts index e626eb3..16e9d4c 100644 --- a/src/statics/ServiceInterface.ts +++ b/src/statics/ServiceInterface.ts @@ -12,29 +12,20 @@ const CSE_CAS_SERVICE = 'https://cse-apps.unl.edu/cas'; class ServiceInterface { static sendEmail = async ( - studentCSE: string, + studentID: number, course: string | null = null, interactionType: string | null = null ): Promise<string | number | null> => { const { course: defaultCourse, setResponse } = api.getState(); const laCSE = ServiceInterface.getActiveUser(); - if (laCSE === studentCSE) { - setResponse({ - class: 'danger', - content: 'No self-interaction', - }); - } else if ( - laCSE !== null && - laCSE.trim().length > 0 && - laCSE !== 'INVALID' - ) { + if (laCSE !== null && laCSE.trim().length > 0 && laCSE !== 'INVALID') { const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - studentCSE, + studentID, laCSE, course: course === null || course.trim().length === 0 @@ -50,12 +41,12 @@ class ServiceInterface { status = body.message; }); return status; - } else { - setResponse({ - class: 'danger', - content: 'Must set a username', - }); } + setResponse({ + class: 'danger', + content: 'Must set a username', + }); + return null; }; diff --git a/src/statics/Utils.ts b/src/statics/Utils.ts new file mode 100644 index 0000000..5a9e0b9 --- /dev/null +++ b/src/statics/Utils.ts @@ -0,0 +1,9 @@ +export const groupBy = <T extends Record<string | number, string | number>>( + objs: T[], + key: keyof T +): Record<T[keyof T], T[]> => { + return objs.reduce((acc: Record<T[keyof T], T[]>, val) => { + (acc[val[key]] = acc[val[key]] || []).push(val); + return acc; + }, {} as Record<T[keyof T], T[]>); +};