diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 1110304988..2068339b36 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -415,7 +415,8 @@ export const defaultWorkspaceManager: WorkspaceManagerState = { }, currentSubmission: undefined, currentQuestion: undefined, - hasUnsavedChanges: false + hasUnsavedChanges: false, + requestCounter: 0 }, playground: { ...createDefaultWorkspace('playground'), diff --git a/src/commons/workspace/WorkspaceActions.ts b/src/commons/workspace/WorkspaceActions.ts index 9c63f9186c..9883816f80 100644 --- a/src/commons/workspace/WorkspaceActions.ts +++ b/src/commons/workspace/WorkspaceActions.ts @@ -67,6 +67,7 @@ import { UPDATE_GRADING_COLUMN_VISIBILITY, UPDATE_HAS_UNSAVED_CHANGES, UPDATE_REPL_VALUE, + UPDATE_REQUEST_COUNTER, UPDATE_STEPSTOTAL, UPDATE_SUBLANGUAGE, UPDATE_SUBMISSIONS_TABLE_FILTERS, @@ -395,6 +396,11 @@ export const setIsEditorReadonly = createAction( }) ); +export const updateRequestCounter = createAction( + UPDATE_REQUEST_COUNTER, + (requestCount: number) => ({ payload: { requestCount } }) +); + export const updateSubmissionsTableFilters = createAction( UPDATE_SUBMISSIONS_TABLE_FILTERS, (filters: SubmissionsTableFilters) => ({ payload: { filters } }) diff --git a/src/commons/workspace/WorkspaceReducer.ts b/src/commons/workspace/WorkspaceReducer.ts index 16680eddbc..de402afa31 100644 --- a/src/commons/workspace/WorkspaceReducer.ts +++ b/src/commons/workspace/WorkspaceReducer.ts @@ -79,6 +79,7 @@ import { UPDATE_GRADING_COLUMN_VISIBILITY, UPDATE_HAS_UNSAVED_CHANGES, UPDATE_REPL_VALUE, + UPDATE_REQUEST_COUNTER, UPDATE_STEPSTOTAL, UPDATE_SUBLANGUAGE, UPDATE_SUBMISSIONS_TABLE_FILTERS, @@ -658,6 +659,14 @@ const oldWorkspaceReducer: Reducer = ( currentQuestion: action.payload.questionId } }; + case UPDATE_REQUEST_COUNTER: + return { + ...state, + grading: { + ...state.grading, + requestCounter: action.payload.requestCount + } + }; case SET_FOLDER_MODE: return { ...state, diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts index 00646c728c..10842e3d36 100644 --- a/src/commons/workspace/WorkspaceTypes.ts +++ b/src/commons/workspace/WorkspaceTypes.ts @@ -40,6 +40,7 @@ export const TOGGLE_EDITOR_AUTORUN = 'TOGGLE_EDITOR_AUTORUN'; export const TOGGLE_USING_SUBST = 'TOGGLE_USING_SUBST'; export const TOGGLE_USING_CSE = 'TOGGLE_USING_CSE'; export const TOGGLE_UPDATE_CSE = 'TOGGLE_UPDATE_CSE'; +export const UPDATE_REQUEST_COUNTER = 'UPDATE_REQUEST_COUNTER'; export const UPDATE_SUBMISSIONS_TABLE_FILTERS = 'UPDATE_SUBMISSIONS_TABLE_FILTERS'; export const UPDATE_GRADING_COLUMN_VISIBILITY = 'UPDATE_GRADING_COLUMN_VISIBILITY'; export const UPDATE_CURRENT_ASSESSMENT_ID = 'UPDATE_CURRENT_ASSESSMENT_ID'; @@ -82,6 +83,7 @@ type GradingWorkspaceAttr = { readonly currentSubmission?: number; readonly currentQuestion?: number; readonly hasUnsavedChanges: boolean; + readonly requestCounter: number; }; type GradingWorkspaceState = GradingWorkspaceAttr & WorkspaceState; diff --git a/src/pages/academy/grading/Grading.tsx b/src/pages/academy/grading/Grading.tsx index 8f4ae43087..aa06b8f05a 100644 --- a/src/pages/academy/grading/Grading.tsx +++ b/src/pages/academy/grading/Grading.tsx @@ -3,13 +3,14 @@ import '@tremor/react/dist/esm/tremor.css'; import { Icon as BpIcon, NonIdealState, Position, Spinner, SpinnerSize } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Button, Card, Flex, Text, Title } from '@tremor/react'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { Navigate, useParams } from 'react-router'; import { fetchGradingOverviews } from 'src/commons/application/actions/SessionActions'; import { Role } from 'src/commons/application/ApplicationTypes'; import SimpleDropdown from 'src/commons/SimpleDropdown'; -import { useSession } from 'src/commons/utils/Hooks'; +import { useSession, useTypedSelector } from 'src/commons/utils/Hooks'; +import { updateRequestCounter } from 'src/commons/workspace/WorkspaceActions'; import { numberRegExp } from 'src/features/academy/AcademyTypes'; import { exportGradingCSV, @@ -50,8 +51,12 @@ const Grading: React.FC = () => { const [showAllSubmissions, setShowAllSubmissions] = useState(false); const dispatch = useDispatch(); + const requestCounter = useTypedSelector(state => state.workspaces.grading.requestCounter); + const updateGradingOverviewsCallback = useCallback( (page: number, filterParams: Object) => { + console.log("+1 parent"); + dispatch(updateRequestCounter(requestCounter + 1)); dispatch( fetchGradingOverviews( showAllGroups, @@ -64,6 +69,10 @@ const Grading: React.FC = () => { [dispatch, showAllGroups, showAllSubmissions, pageSize] ); + useEffect(() => { + dispatch(updateRequestCounter(Math.max(0, requestCounter - 1))); + }, [gradingOverviews]); + // If submissionId or questionId is defined but not numeric, redirect back to the Grading overviews page if ( (params.submissionId && !params.submissionId?.match(numberRegExp)) || diff --git a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx index 13d1d7f79d..09728a7e7a 100644 --- a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx +++ b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx @@ -25,16 +25,18 @@ import { TableRow, TextInput } from '@tremor/react'; -import { ColDef, ICellRendererParams } from 'ag-grid-community'; +import { CellClickedEvent, ColDef, ICellRendererParams } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import { debounce } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; import GradingFlex from 'src/commons/grading/GradingFlex'; import GradingText from 'src/commons/grading/GradingText'; import { useTypedSelector } from 'src/commons/utils/Hooks'; import { updateGradingColumnVisibility, + updateRequestCounter, updateSubmissionsTableFilters } from 'src/commons/workspace/WorkspaceActions'; import { GradingColumnVisibility } from 'src/commons/workspace/WorkspaceTypes'; @@ -122,7 +124,7 @@ const GradingSubmissionTable: React.FC = ({ totalRows, pageSize, submissions, - updateEntries + updateEntries, }) => { // End of Original Code @@ -136,8 +138,22 @@ const GradingSubmissionTable: React.FC = ({ submissionStatus: string; gradingStatus: string; xp: string; - actions: string; - index: number; + actionsIndex: number; + courseID: number; + } + + interface ITableProperties { + defaultColDefs: ColDef; + pagination: boolean; + pageSize: number; + suppressPaginationPanel: boolean; + rowClass: string; + rowHeight: number; + overlayNoRowsTemplate: string; + overlayLoadingTemplate: string; + suppressRowClickSelection: boolean; + tableHeight: string; + tableMargins: string; } const defaultColumnDefs: ColDef = { @@ -146,14 +162,33 @@ const GradingSubmissionTable: React.FC = ({ sortable: true }; - const [rowData, setRowData] = useState(); + const ROW_HEIGHT: number = 60; // in px, declared here to calculate table height + + const tableProperties: ITableProperties = { + defaultColDefs: defaultColumnDefs, + pagination: true, + pageSize: pageSize, + suppressPaginationPanel: true, + rowClass: "grading-left-align grading-table-rows", + rowHeight: ROW_HEIGHT, + overlayNoRowsTemplate: "Hmm... No submissions found, did you filter them all out?", + overlayLoadingTemplate: '
', + suppressRowClickSelection: true, + tableHeight: String(ROW_HEIGHT * Math.min(pageSize, Math.max(2, submissions.length)) + 50) + "px", + tableMargins: "1rem 0 0 0", + }; + const [rowData, setRowData] = useState(); const [colDefs, setColDefs] = useState[]>(); + const [filterMode, setFilterMode] = useState(false); + + const gridRef = useRef>(null); const generateCols = (resetPage: () => void) => { + const cols: ColDef[] = []; - cols.push({ headerName: "Name", field: "assessmentName", flex: 3, cellStyle: defaultCellStyle({textAlign: "left"}), cellRendererSelector: (params: ICellRendererParams) => { + cols.push({ headerName: "Name", field: "assessmentName", flex: 3, cellStyle: defaultCellStyle({textAlign: "left", cursor: (!filterMode ? "pointer" : "")}), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: FilterableNew, @@ -162,11 +197,15 @@ const GradingSubmissionTable: React.FC = ({ id: "assessmentName", value: params.data.assessmentName, onClick: resetPage, + submissionID: params.data.actionsIndex, + courseID: params.data.courseID, + filterMode: filterMode, } } : undefined; }, headerClass: defaultHeaderClasses("grading-left-align") }); - cols.push({ headerName: "Type", field: "assessmentType", flex: 1, cellStyle: defaultCellStyle(), cellRendererSelector: (params: ICellRendererParams) => { + + cols.push({ headerName: "Type", field: "assessmentType", flex: 1, cellStyle: defaultCellStyle({cursor: (!filterMode ? "pointer" : "")}), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: FilterableNew, @@ -175,12 +214,16 @@ const GradingSubmissionTable: React.FC = ({ id: "assessmentType", value: params.data.assessmentType, onClick: resetPage, - children: [] + children: [], + submissionID: params.data.actionsIndex, + courseID: params.data.courseID, + filterMode: filterMode, } } : undefined; }, headerClass: defaultHeaderClasses() }); - cols.push({ headerName: "Student", field: "studentName", flex: 1.5, cellStyle: defaultCellStyle({textAlign: "left"}), cellRendererSelector: (params: ICellRendererParams) => { + + cols.push({ headerName: "Student", field: "studentName", flex: 1.5, cellStyle: defaultCellStyle({textAlign: "left", cursor: (!filterMode ? "pointer" : "")}), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: FilterableNew, @@ -189,11 +232,15 @@ const GradingSubmissionTable: React.FC = ({ id: "studentName", value: params.data.studentName, onClick: resetPage, + submissionID: params.data.actionsIndex, + courseID: params.data.courseID, + filterMode: filterMode, } } : undefined; }, headerClass: defaultHeaderClasses("grading-left-align") }); - cols.push({ headerName: "Username", field: "studentUsername", flex: 1, cellStyle: defaultCellStyle(), cellRendererSelector: (params: ICellRendererParams) => { + + cols.push({ headerName: "Username", field: "studentUsername", flex: 1, cellStyle: defaultCellStyle({cursor: (!filterMode ? "pointer" : "")}), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: FilterableNew, @@ -202,11 +249,15 @@ const GradingSubmissionTable: React.FC = ({ id: "studentUsername", value: params.data.studentUsername, onClick: resetPage, + submissionID: params.data.actionsIndex, + courseID: params.data.courseID, + filterMode: filterMode, } } : undefined; }, headerClass: defaultHeaderClasses() }); - cols.push({ headerName: "Group", field: "groupName", flex: 0.75, cellStyle: defaultCellStyle(), cellRendererSelector: (params: ICellRendererParams) => { + + cols.push({ headerName: "Group", field: "groupName", flex: 0.75, cellStyle: defaultCellStyle({cursor: (!filterMode ? "pointer" : "")}), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: FilterableNew, @@ -215,11 +266,15 @@ const GradingSubmissionTable: React.FC = ({ id: "groupName", value: params.data.groupName, onClick: resetPage, + submissionID: params.data.actionsIndex, + courseID: params.data.courseID, + filterMode: filterMode, } } : undefined; }, headerClass: defaultHeaderClasses() }); - cols.push({ headerName: "Progress", field: "submissionStatus", flex: 1, cellStyle: defaultCellStyle(), cellRendererSelector: (params: ICellRendererParams) => { + + cols.push({ headerName: "Progress", field: "submissionStatus", flex: 1, cellStyle: defaultCellStyle({cursor: (!filterMode ? "pointer" : "")}), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: FilterableNew, @@ -228,12 +283,16 @@ const GradingSubmissionTable: React.FC = ({ id: "submissionStatus", value: params.data.submissionStatus, onClick: resetPage, - children: [] + children: [], + submissionID: params.data.actionsIndex, + courseID: params.data.courseID, + filterMode: filterMode, } } : undefined; }, headerClass: defaultHeaderClasses() }); - cols.push({ headerName: "Grading", field: "gradingStatus", flex: 1, cellStyle: defaultCellStyle(), cellRendererSelector: (params: ICellRendererParams) => { + + cols.push({ headerName: "Grading", field: "gradingStatus", flex: 1, cellStyle: defaultCellStyle({cursor: (!filterMode ? "pointer" : "")}), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: GradingStatusBadge, @@ -243,13 +302,15 @@ const GradingSubmissionTable: React.FC = ({ } : undefined; }, headerClass: defaultHeaderClasses() }); + cols.push({ headerName: "Raw XP (+Bonus)", field: "xp", flex: 1, cellStyle: defaultCellStyle(), headerClass: defaultHeaderClasses() }); - cols.push({ headerName: "Actions", field: "actions", flex: 1, cellStyle: defaultCellStyle(), cellRendererSelector: (params: ICellRendererParams) => { + + cols.push({ headerName: "Actions", field: "actionsIndex", flex: 1, cellStyle: defaultCellStyle(), cellRendererSelector: (params: ICellRendererParams) => { return (params.data !== undefined) ? { component: GradingActions, params: { - submissionId: params.data.index, + submissionId: params.data.actionsIndex, style: {justifyContent: "center"} } } @@ -266,6 +327,7 @@ const GradingSubmissionTable: React.FC = ({ justifyContent: "center", flexDirection: "column", fontSize: "0.875rem", + borderBottom: "1px solid rgba(0, 0, 0, 0.075)", ...style, } }; @@ -274,10 +336,27 @@ const GradingSubmissionTable: React.FC = ({ return ("grading-default-headers " + (extraClass !== undefined ? extraClass : "")); }; + const showLoading = useCallback(() => { + gridRef.current!.api.showLoadingOverlay(); + }, []) + + const hideLoading = useCallback(() => { + gridRef.current!.api.hideOverlay(); + }, []) + + const cellClickedEvent = (event: CellClickedEvent) => { + if (filterMode === false && event.colDef.field !== "xp" && event.colDef.field !== "actionsIndex") { + navigate(`/courses/${courseId}/grading/${event.data.actionsIndex}`); + } + }; + // Start of Original Code const dispatch = useDispatch(); + const navigate = useNavigate(); const tableFilters = useTypedSelector(state => state.workspaces.grading.submissionsTableFilters); const columnVisibility = useTypedSelector(state => state.workspaces.grading.columnVisiblity); + const requestCounter = useTypedSelector(state => state.workspaces.grading.requestCounter); + const courseId = useTypedSelector(store => store.session.courseId); const [columnFilters, setColumnFilters] = useState([ ...tableFilters.columnFilters @@ -344,6 +423,9 @@ const GradingSubmissionTable: React.FC = ({ const handleFilterRemove = ({ id, value }: ColumnFilter) => { const newFilters = columnFilters.filter(filter => filter.id !== id && filter.value !== value); + // updateIsLoading(true); + console.log("+1"); + dispatch(updateRequestCounter(requestCounter + 1)); setColumnFilters(newFilters); resetPage(); }; @@ -383,7 +465,13 @@ const GradingSubmissionTable: React.FC = ({ // End of Original Code useEffect(() => { - setRowData(submissions.map((submission): IRow => { + + let sameData: boolean = true; + + const newData: IRow[] = submissions.map((submission, index): IRow => { + if (sameData && submission.submissionId !== rowData?.[index]?.actionsIndex) { + sameData = false; + } return { assessmentName: submission.assessmentName, assessmentType: submission.assessmentType, @@ -392,17 +480,40 @@ const GradingSubmissionTable: React.FC = ({ groupName: submission.groupName, submissionStatus: submission.submissionStatus, gradingStatus: submission.gradingStatus, - xp: submission.initialXp + " (+" + submission.xpBonus + ") / " + submission.maxXp, - actions: "", - index: submission.submissionId, + xp: submission.currentXp + " (+" + submission.xpBonus + ") / " + submission.maxXp, + actionsIndex: submission.submissionId, + courseID: courseId!, }; - })); - }, [submissions]); - + }); + + if ((rowData?.length !== 0 && newData.length === 0) || !sameData) { // First 2 conditions for edge case due to multiple rerenders + setRowData(newData); + } + + }, [submissions, gridRef.current]); + useEffect(() => { - setColDefs(generateCols(resetPage)); + if (gridRef.current?.api) { + if (requestCounter <= 0) { + hideLoading(); + } else { + showLoading(); + } + } + }, [requestCounter]); + + useEffect(() => { + setColDefs(generateCols(() => { + console.log("+1"); + dispatch(updateRequestCounter(requestCounter + 1)); + resetPage(); + })); }, [resetPage]); + useEffect(() => { + setColDefs(generateCols(() => resetPage())); + }, [filterMode]); + // Start of Original Code return ( @@ -434,15 +545,21 @@ const GradingSubmissionTable: React.FC = ({ {columnFilters.length > 0 ? 'Filters: ' - : 'No filters applied. Click on any cell to filter by its value.' + - (hiddenColumns.columns.length === 0 - ? ' Click on any column header to hide it.' - : '')}{' '} + : (filterMode === true + ? 'No filters applied. Click on any cell to filter by its value.' + + (hiddenColumns.columns.length === 0 + ? ' Click on any column header to hide it.' + : '') + : Disable Edit Mode to enable click to filter)}{' '} + + } @@ -454,16 +571,24 @@ const GradingSubmissionTable: React.FC = ({ {/* End of Original Code */} -
+
@@ -573,6 +698,9 @@ type FilterablePropsNew = { value: string; children?: React.ReactNode; onClick?: () => void; + courseID: number; + submissionID: number; + filterMode: boolean; }; const Filterable: React.FC = ({ column, value, children, onClick }) => { @@ -588,21 +716,30 @@ const Filterable: React.FC = ({ column, value, children, onClic ); }; -const FilterableNew: React.FC = ({ setColumnFilters, id, value, children, onClick }) => { +const FilterableNew: React.FC = ({ setColumnFilters, id, value, children, onClick, courseID, submissionID, filterMode }) => { const handleFilterChange = () => { setColumnFilters((prev: ColumnFiltersState) => { - return [ - ...prev, - { - id: id, - value: value - } - ]; + const alreadyExists = prev.reduce((acc, curr) => acc || (curr.id === id && curr.value === value), false); + return alreadyExists + ? [...prev] + : [ + ...prev, + { + id: id, + value: value + } + ]; }); onClick?.(); }; return ( + filterMode === false + ? + + : diff --git a/src/styles/_academy.scss b/src/styles/_academy.scss index 5b225c5075..259eca518f 100644 --- a/src/styles/_academy.scss +++ b/src/styles/_academy.scss @@ -513,9 +513,15 @@ } } +.grading-overview-unfilterable-btns, .grading-overview-filterable-btns { padding: 0 2px; text-align: inherit; + outline: none; + a { + text-decoration: none; + color: black; + } // Hides overflowed text for the buttons &, @@ -533,11 +539,13 @@ -0.875rem for icon margins -1.125rem for additional padding */ - p, &:has(> span) > span { + p, &:has(> span) > span, > a { max-width: calc(20vw - 16px - 2rem); } } +} +.grading-overview-filterable-btns { // Invisible border for backgroundless buttons &:not(:has(> div)) { text-decoration: none; @@ -545,7 +553,7 @@ &:hover { // Buttons with bg - > div > span, &:has(> span) { + > div > span, &:has(> span), > a { // Use of contrast due to unknown background color filter: contrast(0.9); } @@ -557,7 +565,9 @@ } } -.grading-overview-filterable-btns, .grading-overview-rounded-btns { +.grading-overview-unfilterable-btns, +.grading-overview-filterable-btns, +.grading-overview-rounded-btns { border-radius: 9999px; } @@ -581,6 +591,12 @@ } } +.grading-table-rows { + &.ag-row-hover { + --ag-row-hover-color: rgb(245, 245, 245); + } +} + .ag-header-cell.grading-left-align { span.ag-header-cell-text { margin: 0 auto 0 0; @@ -590,4 +606,21 @@ .ag-header-cell.hide-cols-btn { width: 32px; height: 32px; +} + +.grading-filter-btn { + padding: 7.5px 15px; + border-radius: 25px; + margin: 0 15px 0 auto; + background-color: #dbeafe; + color: #3b82f6; + + &.grading-filter-btn-on { + background-color: #f5f5f5; + color: black; + } + + &:hover { + filter: contrast(0.9); + } } \ No newline at end of file