From 49b0dca93b1e90a777fdc16815303fb4334c5173 Mon Sep 17 00:00:00 2001 From: Alec Li Date: Sat, 27 Jan 2024 16:25:11 -0800 Subject: [PATCH] Restyle export page, fix various bugs Add automatic preview refresh, restyle and add tooltip Rearrange export page sections into one page Fix section id query bugs, rework cases where no courses are selected Add additional fields to export types, fix download data with no courses Add statuses for export page --- .../src/components/data_export/DataExport.tsx | 40 +++-- .../data_export/DataExportTypes.tsx | 16 +- .../src/components/data_export/ExportPage.tsx | 144 ++++++++++-------- .../components/data_export/ExportSelector.tsx | 48 +++--- csm_web/frontend/src/css/data-export.scss | 69 ++++++--- csm_web/frontend/src/utils/queries/export.tsx | 6 + csm_web/scheduler/views/export.py | 89 +++++++++-- 7 files changed, 275 insertions(+), 137 deletions(-) diff --git a/csm_web/frontend/src/components/data_export/DataExport.tsx b/csm_web/frontend/src/components/data_export/DataExport.tsx index 0e4a3d25..5cadff82 100644 --- a/csm_web/frontend/src/components/data_export/DataExport.tsx +++ b/csm_web/frontend/src/components/data_export/DataExport.tsx @@ -1,22 +1,44 @@ import React, { useState } from "react"; +import { useProfiles } from "../../utils/queries/base"; +import { Role } from "../../utils/types"; +import LoadingSpinner from "../LoadingSpinner"; import { ExportType } from "./DataExportTypes"; import { ExportPage } from "./ExportPage"; import { ExportSelector } from "./ExportSelector"; export const DataExport = () => { const [dataExportType, setDataExportType] = useState(null); + const { data: profiles, isSuccess: profilesLoaded, isError: profilesError } = useProfiles(); + + if (profilesError) { + return Error loading user profiles.; + } else if (!profilesLoaded) { + return ; + } else if (profilesLoaded && !profiles.some(profile => profile.role === Role.COORDINATOR)) { + return Permission denied; you are not a coordinator for any course.; + } return (
- {dataExportType === null ? ( - { - setDataExportType(exportType); - }} - /> - ) : ( - setDataExportType(null)} /> - )} +
+

Export Data

+
+
+
+ { + setDataExportType(exportType); + }} + /> +
+
+ {dataExportType === null ? ( +
Select export type to start.
+ ) : ( + + )} +
+
); }; diff --git a/csm_web/frontend/src/components/data_export/DataExportTypes.tsx b/csm_web/frontend/src/components/data_export/DataExportTypes.tsx index 2562ef74..d117cd21 100644 --- a/csm_web/frontend/src/components/data_export/DataExportTypes.tsx +++ b/csm_web/frontend/src/components/data_export/DataExportTypes.tsx @@ -12,10 +12,10 @@ export enum ExportType { * Object for displaying export types in the UI */ export const EXPORT_TYPE_DATA = new Map([ - [ExportType.STUDENT_DATA, "Student data"], - [ExportType.ATTENDANCE_DATA, "Attendance data"], - [ExportType.SECTION_DATA, "Section data"], - [ExportType.COURSE_DATA, "Course data"] + [ExportType.STUDENT_DATA, "Student"], + [ExportType.ATTENDANCE_DATA, "Attendance"], + [ExportType.SECTION_DATA, "Section"], + [ExportType.COURSE_DATA, "Course"] ]); export const EXPORT_COLUMNS: { @@ -27,14 +27,18 @@ export const EXPORT_COLUMNS: { [ExportType.ATTENDANCE_DATA]: { required: { student_email: "Student email", - student_name: "Student name" + student_name: "Student name", + attendance_data: "Attendance data" }, optional: { course_name: "Course name", active: "Active", section_id: "Section ID", mentor_name: "Mentor name", - mentor_email: "Mentor email" + mentor_email: "Mentor email", + num_present: "Present attendance count", + num_excused: "Excused absence count", + num_unexcused: "Unexcused absence count" } }, [ExportType.COURSE_DATA]: { diff --git a/csm_web/frontend/src/components/data_export/ExportPage.tsx b/csm_web/frontend/src/components/data_export/ExportPage.tsx index f1c14e49..808b6c85 100644 --- a/csm_web/frontend/src/components/data_export/ExportPage.tsx +++ b/csm_web/frontend/src/components/data_export/ExportPage.tsx @@ -4,16 +4,16 @@ import { useDataExportMutation, useDataExportPreviewMutation } from "../../utils import { Role } from "../../utils/types"; import { AutoGrid } from "../AutoGrid"; import LoadingSpinner from "../LoadingSpinner"; +import { Tooltip } from "../Tooltip"; import { ExportType, EXPORT_COLUMNS } from "./DataExportTypes"; import RefreshIcon from "../../../static/frontend/img/refresh.svg"; interface ExportPageProps { dataExportType: ExportType; - onBack: () => void; } -export const ExportPage = ({ dataExportType, onBack }: ExportPageProps) => { +export const ExportPage = ({ dataExportType }: ExportPageProps) => { const { data: profiles, isSuccess: profilesLoaded, isError: profilesError } = useProfiles(); const [includedCourses, setIncludedCourses] = useState([]); const [includedFields, setIncludedFields] = useState( @@ -29,6 +29,10 @@ export const ExportPage = ({ dataExportType, onBack }: ExportPageProps) => { } }, [profilesLoaded, profiles]); + useEffect(() => { + setIncludedFields(Array.from(Object.keys(EXPORT_COLUMNS[dataExportType].optional))); + }, [dataExportType]); + if (profilesError) { return

Profiles not found

; } else if (!profilesLoaded) { @@ -111,6 +115,11 @@ export const ExportPage = ({ dataExportType, onBack }: ExportPageProps) => { * Download the data; open a new page with the data */ const downloadData = () => { + if (includedCourses.length === 0) { + // no data to download + return; + } + dataExportMutation.mutate({ courses: includedCourses, fields: includedFields, @@ -120,19 +129,13 @@ export const ExportPage = ({ dataExportType, onBack }: ExportPageProps) => { return (
-
-

Export Data

- -
{courseSelection}
{columnSelection}
- +
-
@@ -146,7 +149,6 @@ interface ExportPagePreviewProps { courses: number[]; fields: string[]; exportType: ExportType; - preview: number; } /** @@ -157,11 +159,12 @@ const ExportPagePreview = ({ exportType, courses, fields }: ExportPagePreviewPro const [preview, setPreview] = useState(10); const [data, setData] = useState([]); - const refreshPreview = () => { - if (courses.length == 0) { - return; - } + // automatically refresh on change + useEffect(() => { + refreshPreview(); + }, [courses, fields, preview]); + const refreshPreview = () => { dataExportPreviewMutation.mutate( { courses: courses, @@ -182,54 +185,73 @@ const ExportPagePreview = ({ exportType, courses, fields }: ExportPagePreviewPro setPreview(selectedValue); }; + let dataTable = null; + + if (data?.length > 1) { + // if has header and at least one row of content, display it + dataTable = ( + + + + {data?.length > 0 && + data[0].map((cell, cellIdx) => ( + + ))} + + + + {data.map( + (row, rowIdx) => + rowIdx > 0 && ( + + {row.map((cell, cellIdx) => ( + + ))} + + ) + )} + {data?.length >= preview && ( + + + + )} + +
+ {cell} +
+ {cell} +
+ (More rows clipped) +
+ ); + } else { + // not enough data + dataTable = Preview query returned no data.; + } + return (
-

Preview

-
- Rows: - - -
-
- - - - {data?.length > 0 && - data[0].map((cell, cellIdx) => ( - - ))} - - - - {data.map( - (row, rowIdx) => - rowIdx > 0 && ( - - {row.map((cell, cellIdx) => ( - - ))} - - ) - )} - {data?.length >= preview && ( - - - - )} - -
- {cell} -
- {cell} -
- (More rows clipped) -
+

Preview

+
+
+ Rows: + +
+ } + placement="right" + > + Refresh + +
+
+
{dataTable}
); diff --git a/csm_web/frontend/src/components/data_export/ExportSelector.tsx b/csm_web/frontend/src/components/data_export/ExportSelector.tsx index 56cd5c6a..e05e215c 100644 --- a/csm_web/frontend/src/components/data_export/ExportSelector.tsx +++ b/csm_web/frontend/src/components/data_export/ExportSelector.tsx @@ -5,47 +5,35 @@ import { ExportType, EXPORT_TYPE_DATA } from "./DataExportTypes"; import "../../css/data-export.scss"; interface ExportSelectorProps { - onContinue: (exportType: ExportType) => void; + onSelect: (exportType: ExportType) => void; } /** * Component for selecting the courses to include in the export, * along with the export data config selection. */ -export const ExportSelector = ({ onContinue }: ExportSelectorProps) => { - const [dataExportType, setDataExportType] = useState(ExportType.ATTENDANCE_DATA); +export const ExportSelector = ({ onSelect }: ExportSelectorProps) => { + const [dataExportType, setDataExportType] = useState(null); - const handleContinue = () => { - onContinue(dataExportType); + const handleSelect = (exportType: ExportType) => { + onSelect(exportType); + setDataExportType(exportType); }; return (
-
-

Select Export Data

-
-
- {Array.from(EXPORT_TYPE_DATA.entries()) - .sort() - .map(([exportType, description]) => ( - - ))} -
-
-
-
-
- Continue -
+
+ {Array.from(EXPORT_TYPE_DATA.entries()) + .sort() + .map(([exportType, description]) => ( +
handleSelect(exportType)} + > + {description} +
+ ))}
); diff --git a/csm_web/frontend/src/css/data-export.scss b/csm_web/frontend/src/css/data-export.scss index d457fdf5..cf7dcb04 100644 --- a/csm_web/frontend/src/css/data-export.scss +++ b/csm_web/frontend/src/css/data-export.scss @@ -4,38 +4,40 @@ .data-export-container { display: flex; flex-direction: column; - gap: 40px; align-items: stretch; justify-content: center; + + padding-right: 16px; } -.export-selector-container { +.data-export-body { display: flex; - flex-direction: column; + flex-direction: row; - gap: 50px; + gap: 40px; } -.export-selector-footer { - display: flex; - align-items: center; - justify-content: center; +.data-export-sidebar { + min-width: 150px; } -.export-selector-section, -.export-page-section { +.data-export-content { + flex: 1; +} + +.export-selector-container { display: flex; flex-direction: column; - justify-content: center; + gap: 50px; + + padding-left: 24px; + margin-top: 16px; } -.export-selector-data-type-container { +.export-selector-footer { display: flex; - flex-direction: column; align-items: center; justify-content: center; - - padding-top: 15px; } .export-page-sidebar-container { @@ -52,7 +54,7 @@ .export-selector-data-type-options { display: flex; flex-direction: column; - gap: 8px; + gap: 24px; align-items: flex-start; justify-content: center; @@ -64,18 +66,27 @@ max-height: 25%; } -.export-selector-data-type-label, -.export-page-input-label { +.export-selector-data-type-label { display: block; width: 100%; + + font-size: 1.1rem; + white-space: nowrap; + + cursor: pointer; user-select: none; + + &.active { + color: $csm-green-darkened; + } } .export-page-container { display: flex; flex-direction: column; gap: 16px; + align-items: stretch; } .export-page-config { @@ -123,6 +134,11 @@ gap: 4px; } +.export-page-preview-title { + // make margin smaller + margin-bottom: 8px; +} + .export-preview-header { display: flex; flex-direction: row; @@ -145,11 +161,24 @@ color: #222; } +.export-preview-refresh-tooltip-container { + position: relative; + + .tooltip-body { + // offset tooltip slightly + margin-left: 8px; + } +} + .export-preview-table-container { - max-width: 90vw; + max-width: 80vw; overflow-x: auto; } +.export-page-preview-wrapper { + padding: 0 12px; +} + .export-preview-table { border-collapse: collapse; } @@ -171,5 +200,5 @@ color: #888; column-span: all; - text-align: center; + text-align: left; } diff --git a/csm_web/frontend/src/utils/queries/export.tsx b/csm_web/frontend/src/utils/queries/export.tsx index a57e01ff..1407d468 100644 --- a/csm_web/frontend/src/utils/queries/export.tsx +++ b/csm_web/frontend/src/utils/queries/export.tsx @@ -23,6 +23,12 @@ export const useDataExportPreviewMutation = (): UseMutationResult< > => { const mutationResult = useMutation( async (body: DataExportPreviewMutationRequest) => { + if (body.courses.length === 0) { + // if no courses specified, then return an empty table; + // no request is needed + return [[]]; + } + const response = await fetchNormalized( "/export", new URLSearchParams({ diff --git a/csm_web/scheduler/views/export.py b/csm_web/scheduler/views/export.py index 1dc599d8..ea055df2 100644 --- a/csm_web/scheduler/views/export.py +++ b/csm_web/scheduler/views/export.py @@ -102,6 +102,7 @@ def get_section_times_dict(courses: List[int], section_ids: Iterable[int]): # filter for courses mentor__course__id__in=courses ).annotate( + _num_spacetimes=Count("spacetimes"), _location=ArrayAgg("spacetimes__location"), _start_time=ArrayAgg("spacetimes__start_time"), _duration=ArrayAgg("spacetimes__duration"), @@ -121,14 +122,17 @@ def get_section_times_dict(courses: List[int], section_ids: Iterable[int]): "_start_time", "_duration", "_day", + "_num_spacetimes", ) # format values in a dictionary for efficient lookup return {d["id"]: d for d in section_time_values} -def format_section_times(section_info: dict): +def format_section_times(section_info: dict) -> list[str]: """ Format a dictionary of section time info. + + Returns a list of formatted section spacetimes. """ # format the section times locations = section_info["_location"] @@ -144,9 +148,8 @@ def format_section_times(section_info: dict): ) end_formatted = end_datetime.time().strftime("%I:%M %p") time_list.append(f"{loc}, {day} {start_formatted}-{end_formatted}") - formatted_times = "; ".join(time_list) - return formatted_times + return time_list def prepare_csv( @@ -218,6 +221,9 @@ def prepare_attendance_data( - section_id - mentor_email - mentor_name + - num_present + - num_excused + - num_unexcused """ student_queryset = Student.objects.filter(course__id__in=courses).annotate( full_name=Concat( @@ -255,6 +261,24 @@ def prepare_attendance_data( ) export_fields.append("mentor_name") export_headers.append("Mentor name") + if "num_present" in fields: + student_queryset = student_queryset.annotate( + num_present=Count("attendance", filter=Q(attendance__presence="PR")) + ) + export_fields.append("num_present") + export_headers.append("Present count") + if "num_unexcused" in fields: + student_queryset = student_queryset.annotate( + num_unexcused=Count("attendance", filter=Q(attendance__presence="UN")) + ) + export_fields.append("num_unexcused") + export_headers.append("Unexcused count") + if "num_excused" in fields: + student_queryset = student_queryset.annotate( + num_excused=Count("attendance", filter=Q(attendance__presence="EX")) + ) + export_fields.append("num_excused") + export_headers.append("Excused count") if preview is not None and preview > 0: # limit queryset @@ -426,16 +450,32 @@ def prepare_section_data( # limit queryset section_queryset = section_queryset[:preview] - # query database for values - values = section_queryset.values(*export_fields) + # query database for values; always fetch id + values = section_queryset.values("id", *export_fields) section_time_dict = {} + max_spacetime_count = 0 if "section_times" in fields: used_ids = set(d["id"] for d in values) section_time_dict = get_section_times_dict(courses, used_ids) - export_fields.append("section_times") - export_headers.append("Section times") + # get the maximum number of section spacetimes + if len(section_time_dict) > 0: + max_spacetime_count = max( + d["_num_spacetimes"] for d in section_time_dict.values() + ) + + # these appends are only for the csv writer + if max_spacetime_count > 1: + for spacetime_idx in range(1, max_spacetime_count + 1): + export_fields.append(f"section_times_{spacetime_idx}") + export_headers.append(f"Section times ({spacetime_idx})") + else: + # if there is zero or one spacetime, the header doesn't need to differentiate + # between indices; we still keep the index in the raw field, + # to simplify the code in writing to the csv + export_fields.append("section_times_1") + export_headers.append("Section times") csv_writer, get_formatted_row = create_csv_dict_writer(export_fields) @@ -451,7 +491,13 @@ def prepare_section_data( # fetch section info from auxiliary query section_info = section_time_dict[row["id"]] formatted_times = format_section_times(section_info) - final_row["section_times"] = formatted_times + + # write formatted spacetimes in separate columns + for spacetime_idx in range(max_spacetime_count): + cur_formatted = "" # default to empty string to pad extras + if spacetime_idx < len(formatted_times): + cur_formatted = formatted_times[spacetime_idx] + final_row[f"section_times_{spacetime_idx + 1}"] = cur_formatted csv_writer.writerow(final_row) yield get_formatted_row() @@ -553,6 +599,7 @@ def prepare_student_data( # default empty dict (not used if section_times is not specified) section_time_dict = {} + max_spacetime_count = 0 if "section_times" in fields: # A second aggregate query on a different related field # causes two OUTER JOIN operations in the SQL; this means that @@ -567,8 +614,23 @@ def prepare_student_data( used_ids = set(d["section__id"] for d in values) section_time_dict = get_section_times_dict(courses, used_ids) - export_fields.append("section_times") - export_headers.append("Section times") + # get the maximum number of section spacetimes + if len(section_time_dict) > 0: + max_spacetime_count = max( + d["_num_spacetimes"] for d in section_time_dict.values() + ) + + # these appends are only for the csv writer + if max_spacetime_count > 1: + for spacetime_idx in range(max_spacetime_count): + export_fields.append(f"section_times_{spacetime_idx + 1}") + export_headers.append(f"Section times ({spacetime_idx + 1})") + else: + # if there is zero or one spacetime, the header doesn't need to differentiate + # between indices; we still keep the index in the raw field, + # to simplify the code in writing to the csv + export_fields.append("section_times_1") + export_headers.append("Section times") csv_writer, get_formatted_row = create_csv_dict_writer(export_fields) @@ -584,7 +646,12 @@ def prepare_student_data( # fetch section info from auxiliary query section_info = section_time_dict[row["section__id"]] formatted_times = format_section_times(section_info) - final_row["section_times"] = formatted_times + # write formatted spacetimes in separate columns + for spacetime_idx in range(max_spacetime_count): + cur_formatted = "" # default to empty string to pad extras + if spacetime_idx < len(formatted_times): + cur_formatted = formatted_times[spacetime_idx] + final_row[f"section_times_{spacetime_idx + 1}"] = cur_formatted csv_writer.writerow(final_row) yield get_formatted_row()