|
1 | | -import { CLASS_NAME, EXERCISES, STUDENTS } from "@config"; |
2 | | -import { useCallback, useMemo, useRef } from "react"; |
3 | | -import { |
4 | | - useGetStudentExercisesQuery, |
5 | | - type StudentExercise, |
6 | | -} from "src/api/queries/get_student_exercises"; |
7 | | -import { |
8 | | - useGetExercisesQuery, |
9 | | - type Exercise, |
10 | | -} from "./api/queries/get_exercises"; |
11 | | -import { useGetStudentsQuery, type Student } from "./api/queries/get_students"; |
| 1 | +import { useCallback, useRef } from "react"; |
| 2 | + |
| 3 | +import { DashboardHeader } from "@/features/student-progress-dashboard/components/DashboardHeader"; |
| 4 | +import { DownloadCsvButton } from "@/features/student-progress-dashboard/components/DownloadCsvButton"; |
| 5 | +import { ProgressTable } from "@/features/student-progress-dashboard/components/ProgressTable"; |
| 6 | +import { useCsvDownload } from "@/features/student-progress-dashboard/hooks/useCsvDownload"; |
| 7 | +import { useFilteredExercises } from "@/features/student-progress-dashboard/hooks/useFilteredExercises"; |
| 8 | +import { useFilteredStudents } from "@/features/student-progress-dashboard/hooks/useFilteredStudents"; |
| 9 | +import { useGetExercisesQuery } from "@/features/student-progress-dashboard/hooks/useGetExercisesQuery"; |
| 10 | +import { useGetStudentsQuery } from "@/features/student-progress-dashboard/hooks/useGetStudentsQuery"; |
| 11 | +import type { OnRowComputed, ProgressRow } from "@/features/student-progress-dashboard/types"; |
12 | 12 |
|
13 | 13 | function App() { |
14 | 14 | const { data: allExercises, isLoading: isExercisesLoading } = |
15 | 15 | useGetExercisesQuery(); |
16 | 16 |
|
17 | | - const filteredExercises = useMemo(() => { |
18 | | - if (allExercises == null || isExercisesLoading) return []; |
19 | | - const exercisesSet = new Set(EXERCISES); |
20 | | - const exercises = |
21 | | - EXERCISES.length === 0 |
22 | | - ? allExercises |
23 | | - : allExercises.filter((exercise) => |
24 | | - exercisesSet.has(exercise.exercise_name), |
25 | | - ); |
26 | | - return exercises.sort((a, b) => |
27 | | - a.exercise_name.localeCompare(b.exercise_name), |
28 | | - ); |
29 | | - }, [allExercises, isExercisesLoading]); |
30 | | - |
| 17 | + const filteredExercises = useFilteredExercises(allExercises, isExercisesLoading); |
| 18 | + |
31 | 19 | const { data: allStudents, isLoading: isStudentsLoading } = |
32 | 20 | useGetStudentsQuery(); |
33 | 21 |
|
34 | | - const filteredStudents = useMemo(() => { |
35 | | - if (allStudents == null || isStudentsLoading) return []; |
36 | | - const studentsSet = new Set(STUDENTS); |
37 | | - return allStudents.filter((student) => studentsSet.has(student.username)); |
38 | | - }, [allStudents, isStudentsLoading]); |
39 | | - |
40 | | - const tableDataRef = useRef< |
41 | | - { username: string; statuses: Record<string, string | undefined> }[] |
42 | | - >([]); |
| 22 | + const filteredStudents = useFilteredStudents(allStudents, isStudentsLoading); |
43 | 23 |
|
44 | | - const handleRowComputed = useCallback( |
45 | | - (row: { |
46 | | - username: string; |
47 | | - statuses: Record<string, string | undefined>; |
48 | | - }) => { |
49 | | - // Replace or add row by username |
50 | | - tableDataRef.current = [ |
51 | | - ...tableDataRef.current.filter((r) => r.username !== row.username), |
52 | | - row, |
53 | | - ]; |
54 | | - }, |
55 | | - [], |
56 | | - ); |
| 24 | + const tableDataRef = useRef<ProgressRow[]>([]); |
57 | 25 |
|
58 | | - const downloadCSV = useCallback(() => { |
59 | | - if (!tableDataRef.current.length) return; |
| 26 | + const { downloadCsv } = useCsvDownload({ |
| 27 | + exercises: filteredExercises, |
| 28 | + getRows: () => tableDataRef.current |
| 29 | + }); |
60 | 30 |
|
61 | | - const headers = [ |
62 | | - "Github Username", |
63 | | - ...filteredExercises.map((e) => e.exercise_name), |
| 31 | + const handleRowComputed = useCallback<OnRowComputed>((row) => { |
| 32 | + // Replace or add row by username |
| 33 | + tableDataRef.current = [ |
| 34 | + ...tableDataRef.current.filter((r) => r.username !== row.username), |
| 35 | + row, |
64 | 36 | ]; |
65 | | - const rows = tableDataRef.current.map((row) => [ |
66 | | - row.username, |
67 | | - ...filteredExercises.map((ex) => row.statuses[ex.exercise_name] ?? ""), |
68 | | - ]); |
69 | | - |
70 | | - const csvContent = |
71 | | - headers.join(",") + "\n" + rows.map((r) => r.join(",")).join("\n"); |
72 | | - |
73 | | - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); |
74 | | - const url = URL.createObjectURL(blob); |
75 | | - const link = document.createElement("a"); |
76 | | - link.href = url; |
77 | | - const timestamp = Math.floor(Date.now() / 1000); |
78 | | - link.setAttribute("download", `progress_${timestamp}.csv`); |
79 | | - document.body.appendChild(link); |
80 | | - link.click(); |
81 | | - document.body.removeChild(link); |
82 | | - }, [filteredExercises]); |
| 37 | + }, []); |
83 | 38 |
|
84 | 39 | return ( |
85 | 40 | <div className="w-[80%] mx-auto my-12"> |
86 | 41 | <div className="flex flex-row justify-between mb-4"> |
87 | | - <h1 className="font-bold text-3xl"> |
88 | | - {`${CLASS_NAME != null ? CLASS_NAME + " " : ""}`}Progress Dashboard |
89 | | - </h1> |
90 | | - <button className="border-2 px-4 py-2 rounded-lg" onClick={downloadCSV}> |
91 | | - Download as .csv |
92 | | - </button> |
| 42 | + <DashboardHeader/> |
| 43 | + <DownloadCsvButton onClick={downloadCsv} /> |
93 | 44 | </div> |
94 | 45 |
|
95 | | - <div className="relative overflow-x-auto"> |
96 | | - <table className="w-full text-sm text-left rtl:text-right text-gray-500"> |
97 | | - <thead className="text-xs text-gray-700 uppercase bg-gray-50"> |
98 | | - <tr> |
99 | | - <th scope="col" className="px-6 py-3"> |
100 | | - Github Username |
101 | | - </th> |
102 | | - {filteredExercises.map((exercise) => ( |
103 | | - <th key={exercise.exercise_name} className="px-6 py-3"> |
104 | | - {exercise.exercise_name} |
105 | | - </th> |
106 | | - ))} |
107 | | - </tr> |
108 | | - </thead> |
109 | | - <tbody> |
110 | | - {allStudents != null && |
111 | | - allExercises != null && |
112 | | - filteredStudents.map((student) => ( |
113 | | - <StudentProgressRow |
114 | | - key={student.id} |
115 | | - student={student} |
116 | | - allStudents={allStudents} |
117 | | - allExercises={allExercises} |
118 | | - filteredExercises={filteredExercises} |
119 | | - onRowComputed={handleRowComputed} |
120 | | - /> |
121 | | - ))} |
122 | | - </tbody> |
123 | | - </table> |
124 | | - </div> |
125 | | - </div> |
126 | | - ); |
127 | | -} |
128 | | - |
129 | | -function StudentProgressRow({ |
130 | | - student, |
131 | | - allStudents, |
132 | | - allExercises, |
133 | | - filteredExercises, |
134 | | - onRowComputed, |
135 | | -}: { |
136 | | - student: Student; |
137 | | - allStudents: Student[]; |
138 | | - allExercises: Exercise[]; |
139 | | - filteredExercises: Exercise[]; |
140 | | - onRowComputed: (row: { |
141 | | - username: string; |
142 | | - statuses: Record<string, string | undefined>; |
143 | | - }) => void; |
144 | | -}) { |
145 | | - const { data: studentProgress, isLoading: isStudentProgressLoading } = |
146 | | - useGetStudentExercisesQuery(student.id, allStudents, allExercises); |
147 | | - |
148 | | - const latestStatus = useMemo(() => { |
149 | | - if (studentProgress == null || isStudentProgressLoading) { |
150 | | - return new Map<string, StudentExercise>(); |
151 | | - } |
152 | | - const result = new Map<string, StudentExercise>(); |
153 | | - studentProgress.forEach((exercises, exerciseName: string) => { |
154 | | - result.set(exerciseName, exercises.at(-1)!); |
155 | | - }); |
156 | | - return result; |
157 | | - }, [isStudentProgressLoading, studentProgress]); |
158 | | - |
159 | | - const getEmoji = useCallback((status?: string | null) => { |
160 | | - switch (status) { |
161 | | - case "SUCCESSFUL": |
162 | | - case "Completed": |
163 | | - return "✅"; |
164 | | - case "UNSUCCESSFUL": |
165 | | - case "Incomplete": |
166 | | - return "❌"; |
167 | | - case "ERROR": |
168 | | - case "Error": |
169 | | - return "⚠️"; |
170 | | - default: |
171 | | - return ""; |
172 | | - } |
173 | | - }, []); |
174 | | - |
175 | | - useMemo(() => { |
176 | | - if (!isStudentProgressLoading) { |
177 | | - const statuses: Record<string, string | undefined> = {}; |
178 | | - filteredExercises.forEach((ex) => { |
179 | | - statuses[ex.exercise_name] = |
180 | | - latestStatus.get(ex.exercise_name)?.exerciseProgress?.status ?? ""; |
181 | | - }); |
182 | | - onRowComputed({ username: student.username, statuses }); |
183 | | - } |
184 | | - }, [ |
185 | | - student.username, |
186 | | - latestStatus, |
187 | | - filteredExercises, |
188 | | - onRowComputed, |
189 | | - isStudentProgressLoading, |
190 | | - ]); |
| 46 | + <ProgressTable |
| 47 | + allStudents={allStudents} |
| 48 | + allExercises={allExercises} |
| 49 | + filteredStudents={filteredStudents} |
| 50 | + filteredExercises={filteredExercises} |
| 51 | + onRowComputed={handleRowComputed} |
| 52 | + /> |
191 | 53 |
|
192 | | - return ( |
193 | | - <tr className="bg-white border-b border-gray-200"> |
194 | | - <td className="px-6 py-3">{student.username}</td> |
195 | | - {filteredExercises.map((exercise) => { |
196 | | - const rawStatus = |
197 | | - latestStatus.get(exercise.exercise_name)?.exerciseProgress?.status ?? |
198 | | - ""; |
199 | | - return ( |
200 | | - <td key={exercise.exercise_name} className="px-6 py-3"> |
201 | | - {getEmoji(rawStatus)} |
202 | | - </td> |
203 | | - ); |
204 | | - })} |
205 | | - </tr> |
| 54 | + </div> |
206 | 55 | ); |
207 | 56 | } |
208 | 57 |
|
|
0 commit comments