Skip to content

Commit c22757a

Browse files
committed
Add word of the day support
Add word of the day frontend cards Add models, views, serializers for word of the day backend infrastructure Add pytest and cypress tests Reformat various python files
1 parent e0484f5 commit c22757a

38 files changed

+3492
-882
lines changed

.github/workflows/cypress.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
strategy:
1111
matrix:
1212
python-version: [3.9]
13-
containers: [1, 2]
13+
containers: [1, 2, 3, 4]
1414
services:
1515
postgres:
1616
image: postgres:14
@@ -65,7 +65,7 @@ jobs:
6565
strategy:
6666
matrix:
6767
python-version: [3.9]
68-
containers: [1, 2]
68+
containers: [1, 2, 3, 4]
6969
services:
7070
postgres:
7171
image: postgres:14

csm_web/frontend/src/components/course/Course.tsx

+14-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CreateSectionModal } from "./CreateSectionModal";
77
import { DataExportModal } from "./DataExportModal";
88
import { SectionCard } from "./SectionCard";
99
import { WhitelistModal } from "./WhitelistModal";
10+
import { SettingsModal } from "./SettingsModal";
1011

1112
import PencilIcon from "../../../static/frontend/img/pencil.svg";
1213

@@ -23,7 +24,8 @@ const DAY_OF_WEEK_ABREVIATIONS: { [day: string]: string } = Object.freeze({
2324
const COURSE_MODAL_TYPE = Object.freeze({
2425
exportData: "csv",
2526
createSection: "mksec",
26-
whitelist: "whitelist"
27+
whitelist: "whitelist",
28+
settings: "settings"
2729
});
2830

2931
interface CourseProps {
@@ -88,6 +90,8 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps):
8890
);
8991
} else if (whichModal == COURSE_MODAL_TYPE.whitelist) {
9092
return <WhitelistModal course={course} closeModal={() => setShowModal(false)} />;
93+
} else if (whichModal == COURSE_MODAL_TYPE.settings) {
94+
return <SettingsModal courseId={Number(courseId)} closeModal={() => setShowModal(false)} />;
9195
}
9296
return null;
9397
};
@@ -178,6 +182,15 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps):
178182
>
179183
Export Data
180184
</button>
185+
<button
186+
className="csm-btn course-settings-btn"
187+
onClick={() => {
188+
setShowModal(true);
189+
setWhichModal(COURSE_MODAL_TYPE.settings);
190+
}}
191+
>
192+
Settings
193+
</button>
181194
{course.isRestricted && (
182195
<button
183196
className="csm-btn edit-whitelist-btn"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React, { useEffect, useState } from "react";
2+
3+
import LoadingSpinner from "../LoadingSpinner";
4+
5+
import { useCourses, useCourseSettingsMutation } from "../../utils/queries/courses";
6+
7+
import InfoIcon from "../../../static/frontend/img/info.svg";
8+
import { Tooltip } from "../Tooltip";
9+
import Modal from "../Modal";
10+
11+
interface SettingsModalProps {
12+
courseId: number;
13+
closeModal: () => void;
14+
}
15+
16+
/**
17+
* Modal to configure course settings.
18+
*
19+
* TODO: replace this with an entirely new page when the coordinator interface is reworked.
20+
*/
21+
export const SettingsModal = ({ courseId, closeModal }: SettingsModalProps) => {
22+
const { data: courses, isSuccess: coursesLoaded } = useCourses();
23+
24+
// limit for submission of word of the day
25+
const [wordOfTheDayLimit, setWordOfTheDayLimit] = useState<number>(0);
26+
27+
const courseSettingsMutation = useCourseSettingsMutation(courseId);
28+
29+
useEffect(() => {
30+
if (coursesLoaded) {
31+
const course = courses.find(c => c.id === courseId);
32+
const limit = course?.wordOfTheDayLimit;
33+
if (limit) {
34+
// limit is of form '[DD] [HH:[MM:]]ss[.uuuuuu]'; want to extract days
35+
const days = parseInt(limit.split(" ")[0]);
36+
if (days > 0) {
37+
// set state
38+
setWordOfTheDayLimit(days);
39+
} else {
40+
// fall back to 0 (will submit as null)
41+
setWordOfTheDayLimit(0);
42+
}
43+
} else {
44+
// fall back to 0 (will submit as null)
45+
setWordOfTheDayLimit(0);
46+
}
47+
}
48+
}, [coursesLoaded]);
49+
50+
const handleChangeWordOfTheDayLimit = (e: React.ChangeEvent<HTMLInputElement>) => {
51+
setWordOfTheDayLimit(parseInt(e.target.value));
52+
};
53+
54+
const submitSettings = () => {
55+
// if state = 0, then treat as if no limit (doesn't make sense to have 0 day limit)
56+
let formattedWordOfTheDayLimit = null;
57+
if (wordOfTheDayLimit > 0) {
58+
// format word of the day limit; state is in days
59+
formattedWordOfTheDayLimit = `${wordOfTheDayLimit} 00:00:00`;
60+
}
61+
62+
// compile all settings
63+
const settings = {
64+
wordOfTheDayLimit: formattedWordOfTheDayLimit
65+
};
66+
67+
courseSettingsMutation.mutate(settings, {
68+
onSuccess: () => {
69+
closeModal();
70+
}
71+
});
72+
};
73+
74+
let modalContent = <LoadingSpinner />;
75+
if (coursesLoaded) {
76+
modalContent = (
77+
<div className="course-settings-content">
78+
<h4 className="course-settings-title">Settings</h4>
79+
<div className="course-settings-container">
80+
<div className="course-settings-row">
81+
<div className="course-settings-label-container">
82+
<label htmlFor="wordOfTheDayLimit">Word of the day limit</label>
83+
<div className="course-settings-tooltip-container">
84+
<Tooltip placement="top" source={<InfoIcon className="icon course-settings-tooltip-info-icon" />}>
85+
<div className="course-settings-tooltip-body">
86+
Time limit in days for submitting word of the day.
87+
<br />0 corresponds to no limit.
88+
</div>
89+
</Tooltip>
90+
</div>
91+
</div>
92+
<div className="course-settings-input-container">
93+
<input
94+
id="wordOfTheDayLimit"
95+
className="course-settings-input"
96+
type="number"
97+
min="0"
98+
value={wordOfTheDayLimit}
99+
onChange={handleChangeWordOfTheDayLimit}
100+
/>
101+
<span className="course-settings-input-post">days</span>
102+
</div>
103+
</div>
104+
</div>
105+
<div className="course-settings-footer">
106+
<button className="csm-btn course-settings-submit" type="button" onClick={submitSettings}>
107+
Submit
108+
</button>
109+
</div>
110+
</div>
111+
);
112+
}
113+
114+
return <Modal closeModal={closeModal}>{modalContent}</Modal>;
115+
};

csm_web/frontend/src/components/section/MentorSection.tsx

+3-56
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import React, { useState, useEffect } from "react";
2-
import { Attendance, Mentor, RawAttendance, Spacetime } from "../../utils/types";
1+
import React from "react";
2+
import { Mentor, Spacetime } from "../../utils/types";
33
import { SectionDetail, ROLES } from "./Section";
44
import { Routes, Route } from "react-router-dom";
5-
import { groupBy } from "lodash";
65
import MentorSectionAttendance from "./MentorSectionAttendance";
76
import MentorSectionRoster from "./MentorSectionRoster";
87
import MentorSectionInfo from "./MentorSectionInfo";
9-
import { useSectionAttendances } from "../../utils/queries/sections";
108

119
interface MentorSectionProps {
1210
id: number;
@@ -20,10 +18,6 @@ interface MentorSectionProps {
2018
courseRestricted: boolean;
2119
}
2220

23-
type GroupedAttendances = {
24-
[date: string]: Attendance[];
25-
};
26-
2721
export default function MentorSection({
2822
id,
2923
course,
@@ -35,43 +29,6 @@ export default function MentorSection({
3529
userRole,
3630
mentor
3731
}: MentorSectionProps) {
38-
const { data: jsonAttendances, isSuccess: jsonAttendancesLoaded } = useSectionAttendances(id);
39-
const [attendances, setAttendances] = useState<GroupedAttendances>(undefined as never);
40-
41-
useEffect(() => {
42-
if (jsonAttendancesLoaded) {
43-
const groupedAttendances = groupBy(
44-
jsonAttendances.flatMap(({ attendances }: RawAttendance) =>
45-
attendances
46-
.map(
47-
({ id, presence, date, studentId, studentName, studentEmail }) =>
48-
({
49-
id,
50-
presence,
51-
date,
52-
student: { id: studentId, name: studentName, email: studentEmail }
53-
} as any)
54-
)
55-
.sort((att1, att2) => att1.student.name.toLowerCase().localeCompare(att2.student.name.toLowerCase()))
56-
),
57-
attendance => attendance.date
58-
) as any;
59-
setAttendances(groupedAttendances);
60-
}
61-
}, [jsonAttendances]);
62-
63-
const updateAttendance = (updatedDate: string | undefined, updatedDateAttendances: Attendance[]) => {
64-
const updatedAttendances = Object.fromEntries(
65-
Object.entries(attendances).map(([date, dateAttendances]) => [
66-
date,
67-
date == updatedDate ? [...updatedDateAttendances] : dateAttendances
68-
])
69-
);
70-
setAttendances(updatedAttendances);
71-
};
72-
73-
const attendancesLoaded = jsonAttendancesLoaded && !!attendances;
74-
7532
return (
7633
<SectionDetail
7734
course={course}
@@ -84,17 +41,7 @@ export default function MentorSection({
8441
]}
8542
>
8643
<Routes>
87-
<Route
88-
path="attendance"
89-
element={
90-
<MentorSectionAttendance
91-
attendances={attendances}
92-
sectionId={id}
93-
loaded={attendancesLoaded}
94-
updateAttendance={updateAttendance}
95-
/>
96-
}
97-
/>
44+
<Route path="attendance" element={<MentorSectionAttendance sectionId={id} />} />
9845
<Route path="roster" element={<MentorSectionRoster id={id} />} />
9946
<Route
10047
index

0 commit comments

Comments
 (0)