diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 35a39af4aa..92f7182593 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -402,8 +402,7 @@ export const defaultWorkspaceManager: WorkspaceManagerState = { grading: { ...createDefaultWorkspace('grading'), submissionsTableFilters: { - columnFilters: [], - globalFilter: null + columnFilters: [] }, currentSubmission: undefined, currentQuestion: undefined, diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index baacf92381..4fd6d51b45 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -1,6 +1,10 @@ +import { + paginationToBackendParams, + ungradedToBackendParams +} from 'src/features/grading/GradingUtils'; import { action } from 'typesafe-actions'; // EDITED -import { GradingOverview, GradingQuery } from '../../../features/grading/GradingTypes'; +import { GradingOverviews, GradingQuery } from '../../../features/grading/GradingTypes'; import { Assessment, AssessmentConfiguration, @@ -102,11 +106,20 @@ export const fetchTotalXpAdmin = (courseRegId: number) => action(FETCH_TOTAL_XP_ export const fetchGrading = (submissionId: number) => action(FETCH_GRADING, submissionId); /** - * @param filterToGroup - param when set to true, only shows submissions under the group + * @param filterToGroup - param that when set to true, only shows submissions under the group * of the grader + * @param gradedFilter - backend params to filter to ungraded + * @param pageParams - param that contains offset and pageSize, informing backend about how + * many entries, starting from what offset, to get + * @param filterParams - param that contains columnFilters converted into JSON for + * processing into query parameters */ -export const fetchGradingOverviews = (filterToGroup = true) => - action(FETCH_GRADING_OVERVIEWS, filterToGroup); +export const fetchGradingOverviews = ( + filterToGroup = true, + gradedFilter = ungradedToBackendParams(false), + pageParams = paginationToBackendParams(0, 10), + filterParams = {} +) => action(FETCH_GRADING_OVERVIEWS, { filterToGroup, gradedFilter, pageParams, filterParams }); export const login = (providerId: string) => action(LOGIN, providerId); @@ -202,7 +215,7 @@ export const updateTotalXp = (totalXp: number) => action(UPDATE_TOTAL_XP, totalX export const updateAssessment = (assessment: Assessment) => action(UPDATE_ASSESSMENT, assessment); -export const updateGradingOverviews = (overviews: GradingOverview[]) => +export const updateGradingOverviews = (overviews: GradingOverviews) => action(UPDATE_GRADING_OVERVIEWS, overviews); /** diff --git a/src/commons/application/actions/__tests__/SessionActions.ts b/src/commons/application/actions/__tests__/SessionActions.ts index acb2cc35c4..ad878862f5 100644 --- a/src/commons/application/actions/__tests__/SessionActions.ts +++ b/src/commons/application/actions/__tests__/SessionActions.ts @@ -1,6 +1,10 @@ import { Chapter, Variant } from 'js-slang/dist/types'; +import { + paginationToBackendParams, + ungradedToBackendParams +} from 'src/features/grading/GradingUtils'; -import { GradingOverview, GradingQuery } from '../../../../features/grading/GradingTypes'; +import { GradingOverviews, GradingQuery } from '../../../../features/grading/GradingTypes'; import { Assessment, AssessmentOverview } from '../../../assessment/AssessmentTypes'; import { Notification } from '../../../notificationBadge/NotificationBadgeTypes'; import { GameState, Role, Story } from '../../ApplicationTypes'; @@ -150,16 +154,29 @@ test('fetchGradingOverviews generates correct default action object', () => { const action = fetchGradingOverviews(); expect(action).toEqual({ type: FETCH_GRADING_OVERVIEWS, - payload: true + payload: { + filterToGroup: true, + gradedFilter: ungradedToBackendParams(false), + pageParams: paginationToBackendParams(0, 10), + filterParams: {} + } }); }); test('fetchGradingOverviews generates correct action object', () => { const filterToGroup = false; - const action = fetchGradingOverviews(filterToGroup); + const gradedFilter = ungradedToBackendParams(true); + const pageParams = { offset: 123, pageSize: 456 }; + const filterParams = { abc: 'xxx', def: 'yyy' }; + const action = fetchGradingOverviews(filterToGroup, gradedFilter, pageParams, filterParams); expect(action).toEqual({ type: FETCH_GRADING_OVERVIEWS, - payload: filterToGroup + payload: { + filterToGroup: filterToGroup, + gradedFilter: gradedFilter, + pageParams: pageParams, + filterParams: filterParams + } }); }); @@ -510,28 +527,31 @@ test('updateAssessment generates correct action object', () => { }); test('updateGradingOverviews generates correct action object', () => { - const overviews: GradingOverview[] = [ - { - assessmentId: 1, - assessmentNumber: 'M1A', - assessmentName: 'test assessment', - assessmentType: 'Contests', - initialXp: 0, - xpBonus: 100, - xpAdjustment: 50, - currentXp: 50, - maxXp: 500, - studentId: 100, - studentName: 'test student', - studentUsername: 'E0123456', - submissionId: 1, - submissionStatus: 'attempting', - groupName: 'group', - gradingStatus: 'excluded', - questionCount: 6, - gradedCount: 0 - } - ]; + const overviews: GradingOverviews = { + count: 1, + data: [ + { + assessmentId: 1, + assessmentNumber: 'M1A', + assessmentName: 'test assessment', + assessmentType: 'Contests', + initialXp: 0, + xpBonus: 100, + xpAdjustment: 50, + currentXp: 50, + maxXp: 500, + studentId: 100, + studentName: 'test student', + studentUsername: 'E0123456', + submissionId: 1, + submissionStatus: 'attempting', + groupName: 'group', + gradingStatus: 'excluded', + questionCount: 6, + gradedCount: 0 + } + ] + }; const action = updateGradingOverviews(overviews); expect(action).toEqual({ diff --git a/src/commons/application/reducers/__tests__/SessionReducer.ts b/src/commons/application/reducers/__tests__/SessionReducer.ts index 9db52c5aae..bdd39ae661 100644 --- a/src/commons/application/reducers/__tests__/SessionReducer.ts +++ b/src/commons/application/reducers/__tests__/SessionReducer.ts @@ -556,7 +556,10 @@ test('UPDATE_GRADING_OVERVIEWS works correctly in inserting grading overviews', test('UPDATE_GRADING_OVERVIEWS works correctly in updating grading overviews', () => { const newDefaultSession = { ...defaultSession, - gradingOverviews: gradingOverviewTest1 + gradingOverviews: { + count: gradingOverviewTest1.length, + data: gradingOverviewTest1 + } }; const gradingOverviewsPayload = [...gradingOverviewTest2, ...gradingOverviewTest1]; const action = { diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index e1e7b1cce0..27c9af3e63 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -1,7 +1,7 @@ import { Octokit } from '@octokit/rest'; import { Chapter, Variant } from 'js-slang/dist/types'; -import { GradingOverview, GradingQuery } from '../../../features/grading/GradingTypes'; +import { GradingOverviews, GradingQuery } from '../../../features/grading/GradingTypes'; import { Device, DeviceSession } from '../../../features/remoteExecution/RemoteExecutionTypes'; import { Assessment, @@ -114,7 +114,7 @@ export type SessionState = { readonly assessmentOverviews?: AssessmentOverview[]; readonly assessments: Map; - readonly gradingOverviews?: GradingOverview[]; + readonly gradingOverviews?: GradingOverviews; readonly gradings: Map; readonly notifications: Notification[]; readonly googleUser?: string; diff --git a/src/commons/assessment/AssessmentTypes.ts b/src/commons/assessment/AssessmentTypes.ts index a4cdb9f43b..febf1c0c88 100644 --- a/src/commons/assessment/AssessmentTypes.ts +++ b/src/commons/assessment/AssessmentTypes.ts @@ -46,8 +46,8 @@ export enum QuestionTypes { export type QuestionType = keyof typeof QuestionTypes; /* -W* Used to display information regarding an assessment in the UI. -* + * Used to display information regarding an assessment in the UI. + * * @property closeAt an ISO 8601 compliant date string specifiying when * the assessment closes * @property openAt an ISO 8601 compliant date string specifiying when diff --git a/src/commons/mocks/BackendMocks.ts b/src/commons/mocks/BackendMocks.ts index 1a8c5c4110..c9d602699e 100644 --- a/src/commons/mocks/BackendMocks.ts +++ b/src/commons/mocks/BackendMocks.ts @@ -3,7 +3,7 @@ import { call, put, select, takeEvery } from 'redux-saga/effects'; import { FETCH_GROUP_GRADING_SUMMARY } from '../../features/dashboard/DashboardTypes'; import { - GradingOverview, + GradingOverviews, GradingQuery, GradingQuestion } from '../../features/grading/GradingTypes'; @@ -166,12 +166,12 @@ export function* mockBackendSaga(): SagaIterator { FETCH_GRADING_OVERVIEWS, function* (action: ReturnType): any { const accessToken = yield select((state: OverallState) => state.session.accessToken); - const filterToGroup = action.payload; + const { filterToGroup, pageParams, filterParams } = action.payload; const gradingOverviews = yield call(() => - mockFetchGradingOverview(accessToken, filterToGroup) + mockFetchGradingOverview(accessToken, filterToGroup, pageParams, filterParams) ); if (gradingOverviews !== null) { - yield put(actions.updateGradingOverviews([...gradingOverviews])); + yield put(actions.updateGradingOverviews(gradingOverviews)); } } ); @@ -189,10 +189,14 @@ export function* mockBackendSaga(): SagaIterator { UNSUBMIT_SUBMISSION, function* (action: ReturnType) { const { submissionId } = action.payload; - const overviews: GradingOverview[] = yield select( - (state: OverallState) => state.session.gradingOverviews || [] + const overviews: GradingOverviews = yield select( + (state: OverallState) => + state.session.gradingOverviews || { + count: 0, + data: [] + } ); - const index = overviews.findIndex( + const index = overviews.data.findIndex( overview => overview.submissionId === submissionId && overview.submissionStatus === 'submitted' ); @@ -200,14 +204,14 @@ export function* mockBackendSaga(): SagaIterator { yield call(showWarningMessage, '400: Bad Request'); return; } - const newOverviews = (overviews as GradingOverview[]).map(overview => { + const newOverviews = overviews.data.map(overview => { if (overview.submissionId === submissionId) { return { ...overview, submissionStatus: 'attempted' }; } return overview; }); yield call(showSuccessMessage, 'Unsubmit successful!', 1000); - yield put(actions.updateGradingOverviews(newOverviews)); + yield put(actions.updateGradingOverviews({ ...overviews, data: newOverviews })); } ); diff --git a/src/commons/mocks/GradingMocks.ts b/src/commons/mocks/GradingMocks.ts index d44729e292..958ce4cddc 100644 --- a/src/commons/mocks/GradingMocks.ts +++ b/src/commons/mocks/GradingMocks.ts @@ -79,10 +79,14 @@ export const mockGradingOverviews: GradingOverview[] = [ * * @param accessToken a valid access token for the cadet backend. * @param group a boolean if true, only fetches submissions from the grader's group + * @param pageParams contains pagination details on offset and page index. + * @param backendParams contains filters to set conditions in SQL query. */ export const mockFetchGradingOverview = ( accessToken: string, - group: boolean + group: boolean, + pageParams: { offset: number; pageSize: number }, + backendParams: Object ): GradingOverview[] | null => { // mocks backend role fetching const permittedRoles: Role[] = [Role.Admin, Role.Staff]; diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index e69259495d..55bb55990a 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -11,6 +11,7 @@ import { } from '../../features/dashboard/DashboardTypes'; import { GradingOverview, + GradingOverviews, GradingQuery, GradingQuestion } from '../../features/grading/GradingTypes'; @@ -414,12 +415,15 @@ function* BackendSaga(): SagaIterator { function* (action: ReturnType) { const tokens: Tokens = yield selectTokens(); - const filterToGroup = action.payload; + const { filterToGroup, gradedFilter, pageParams, filterParams } = action.payload; - const gradingOverviews: GradingOverview[] | null = yield call( + const gradingOverviews: GradingOverviews | null = yield call( getGradingOverviews, tokens, - filterToGroup + filterToGroup, + gradedFilter, + pageParams, + filterParams ); if (gradingOverviews) { yield put(actions.updateGradingOverviews(gradingOverviews)); @@ -452,7 +456,7 @@ function* BackendSaga(): SagaIterator { } const overviews: GradingOverview[] = yield select( - (state: OverallState) => state.session.gradingOverviews || [] + (state: OverallState) => state.session.gradingOverviews?.data || [] ); const newOverviews = overviews.map(overview => { if (overview.submissionId === submissionId) { @@ -461,8 +465,14 @@ function* BackendSaga(): SagaIterator { return overview; }); + const totalPossibleEntries = yield select( + (state: OverallState) => state.session.gradingOverviews?.count + ); + yield call(showSuccessMessage, 'Unsubmit successful', 1000); - yield put(actions.updateGradingOverviews(newOverviews)); + yield put( + actions.updateGradingOverviews({ count: totalPossibleEntries, data: newOverviews }) + ); } ); diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 0af9b25392..69f4938dfb 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -11,6 +11,7 @@ import { GradingSummary } from '../../features/dashboard/DashboardTypes'; import { GradingAnswer, GradingOverview, + GradingOverviews, GradingQuery, GradingQuestion } from '../../features/grading/GradingTypes'; @@ -604,53 +605,64 @@ export const postAssessment = async (id: number, tokens: Tokens): Promise => { - const resp = await request(`${courseId()}/admin/grading?group=${group}`, 'GET', { + group: boolean, + graded: Record | undefined, + pageParams: Record, + filterParams: Record +): Promise => { + // gradedQuery placed behind filterQuery to override progress filter if any + const params = new URLSearchParams({ ...pageParams, ...filterParams, ...graded }); + params.append('group', `${group}`); + + const resp = await request(`${courseId()}/admin/grading?${params.toString()}`, 'GET', { ...tokens }); if (!resp) { return null; // invalid accessToken _and_ refreshToken } const gradingOverviews = await resp.json(); - return gradingOverviews - .map((overview: any) => { - const gradingOverview: GradingOverview = { - assessmentId: overview.assessment.id, - assessmentNumber: overview.assessment.assessmentNumber, - assessmentName: overview.assessment.title, - assessmentType: overview.assessment.type, - studentId: overview.student.id, - studentUsername: overview.student.username, - studentName: overview.student.name, - submissionId: overview.id, - submissionStatus: overview.status, - groupName: overview.student.groupName, - groupLeaderId: overview.student.groupLeaderId, - // Grading Status - gradingStatus: 'none', - questionCount: overview.assessment.questionCount, - gradedCount: overview.gradedCount, - // XP - initialXp: overview.xp, - xpAdjustment: overview.xpAdjustment, - currentXp: overview.xp + overview.xpAdjustment, - maxXp: overview.assessment.maxXp, - xpBonus: overview.xpBonus - }; - gradingOverview.gradingStatus = computeGradingStatus( - overview.assessment.isManuallyGraded, - gradingOverview.submissionStatus, - gradingOverview.gradedCount, - gradingOverview.questionCount - ); - return gradingOverview; - }) - .sort((subX: GradingOverview, subY: GradingOverview) => - subX.assessmentId !== subY.assessmentId - ? subY.assessmentId - subX.assessmentId - : subY.submissionId - subX.submissionId - ); + + return { + count: gradingOverviews.count, + data: gradingOverviews.data + .map((overview: any) => { + const gradingOverview: GradingOverview = { + assessmentId: overview.assessment.id, + assessmentNumber: overview.assessment.assessmentNumber, + assessmentName: overview.assessment.title, + assessmentType: overview.assessment.type, + studentId: overview.student.id, + studentUsername: overview.student.username, + studentName: overview.student.name, + submissionId: overview.id, + submissionStatus: overview.status, + groupName: overview.student.groupName, + groupLeaderId: overview.student.groupLeaderId, + // Grading Status + gradingStatus: 'none', + questionCount: overview.assessment.questionCount, + gradedCount: overview.gradedCount, + // XP + initialXp: overview.xp, + xpAdjustment: overview.xpAdjustment, + currentXp: overview.xp + overview.xpAdjustment, + maxXp: overview.assessment.maxXp, + xpBonus: overview.xpBonus + }; + gradingOverview.gradingStatus = computeGradingStatus( + overview.assessment.isManuallyGraded, + gradingOverview.submissionStatus, + gradingOverview.gradedCount, + gradingOverview.questionCount + ); + return gradingOverview; + }) + .sort((subX: GradingOverview, subY: GradingOverview) => + subX.assessmentId !== subY.assessmentId + ? subY.assessmentId - subX.assessmentId + : subY.submissionId - subX.submissionId + ) + }; }; /** @@ -699,7 +711,6 @@ export const getGrading = async ( result.grade.grader = gradingQuestion.grade.grader; result.grade.gradedAt = gradingQuestion.grade.gradedAt; } - return result; }); diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts index 81998af787..1295bf0608 100644 --- a/src/commons/workspace/WorkspaceTypes.ts +++ b/src/commons/workspace/WorkspaceTypes.ts @@ -165,5 +165,4 @@ export type DebuggerContext = { export type SubmissionsTableFilters = { columnFilters: { id: string; value: unknown }[]; - globalFilter: string | null; }; diff --git a/src/commons/workspace/__tests__/WorkspaceActions.ts b/src/commons/workspace/__tests__/WorkspaceActions.ts index 1e10a0345a..7e76bbf03a 100644 --- a/src/commons/workspace/__tests__/WorkspaceActions.ts +++ b/src/commons/workspace/__tests__/WorkspaceActions.ts @@ -558,14 +558,12 @@ test('updateSubmissionsTableFilters generates correct action object', () => { value: 'Missions' } ]; - const globalFilter = 'runes'; - const action = updateSubmissionsTableFilters({ columnFilters, globalFilter }); + const action = updateSubmissionsTableFilters({ columnFilters }); expect(action).toEqual({ type: UPDATE_SUBMISSIONS_TABLE_FILTERS, payload: { filters: { - columnFilters, - globalFilter + columnFilters } } }); diff --git a/src/features/grading/GradingTypes.ts b/src/features/grading/GradingTypes.ts index 14799b6f51..aa99ea8bc6 100644 --- a/src/features/grading/GradingTypes.ts +++ b/src/features/grading/GradingTypes.ts @@ -34,6 +34,11 @@ export type GradingOverview = { gradedCount: number; }; +export type GradingOverviews = { + count: number; // To support server-side pagination + data: GradingOverview[]; +}; + export type GradingOverviewWithNotifications = { notifications: Notification[]; } & GradingOverview; diff --git a/src/features/grading/GradingUtils.ts b/src/features/grading/GradingUtils.ts index e3cfb9c8a1..f7871dd425 100644 --- a/src/features/grading/GradingUtils.ts +++ b/src/features/grading/GradingUtils.ts @@ -1,7 +1,9 @@ +import { ColumnFilter } from '@tanstack/react-table'; import { GradingStatuses } from 'src/commons/assessment/AssessmentTypes'; import { GradingOverview } from './GradingTypes'; +// TODO: Unused. Marked for deletion. export const isSubmissionUngraded = (s: GradingOverview): boolean => { const isSubmitted = s.submissionStatus === 'submitted'; const isNotGraded = @@ -69,3 +71,40 @@ export const exportGradingCSV = (gradingOverviews: GradingOverview[] | undefined win.URL.revokeObjectURL(url); }, 0); }; + +// Cleanup work: change all references to column properties in backend saga to backend name to reduce +// un-needed hardcode conversion, ensuring that places that reference it are updated. A two-way conversion +// function would be good to implement in GradingUtils. +export const convertFilterToBackendParams = (column: ColumnFilter) => { + switch (column.id) { + case 'assessmentName': + return { title: column.value }; + case 'assessmentType': + return { type: column.value }; + case 'studentName': + return { name: column.value }; + case 'studentUsername': + return { username: column.value }; + case 'submissionStatus': + return { status: column.value }; + case 'groupName': + return { groupName: column.value }; + default: + return {}; + } +}; + +export const paginationToBackendParams = (page: number, pageSize: number) => { + return { offset: page * pageSize, pageSize: pageSize }; +}; + +export const ungradedToBackendParams = (showAll: boolean) => { + if (showAll) { + return {}; + } + return { + status: 'submitted', + isManuallyGraded: true, + notFullyGraded: true + }; +}; diff --git a/src/pages/academy/grading/Grading.tsx b/src/pages/academy/grading/Grading.tsx index 3d4fcf13c9..8f4ae43087 100644 --- a/src/pages/academy/grading/Grading.tsx +++ b/src/pages/academy/grading/Grading.tsx @@ -3,7 +3,7 @@ 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, { useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; import { Navigate, useParams } from 'react-router'; import { fetchGradingOverviews } from 'src/commons/application/actions/SessionActions'; @@ -11,7 +11,11 @@ import { Role } from 'src/commons/application/ApplicationTypes'; import SimpleDropdown from 'src/commons/SimpleDropdown'; import { useSession } from 'src/commons/utils/Hooks'; import { numberRegExp } from 'src/features/academy/AcademyTypes'; -import { exportGradingCSV, isSubmissionUngraded } from 'src/features/grading/GradingUtils'; +import { + exportGradingCSV, + paginationToBackendParams, + ungradedToBackendParams +} from 'src/features/grading/GradingUtils'; import ContentDisplay from '../../../commons/ContentDisplay'; import { convertParamToInt } from '../../../commons/utils/ParamParseHelper'; @@ -28,6 +32,13 @@ const showOptions = [ { value: true, label: 'all' } ]; +const pageSizeOptions = [ + { value: 10, label: '10' }, + { value: 15, label: '15' }, + { value: 25, label: '25' }, + { value: 50, label: '50' } +]; + const Grading: React.FC = () => { const { courseId, gradingOverviews, role, group } = useSession(); const params = useParams<{ submissionId: string; questionId: string }>(); @@ -35,13 +46,24 @@ const Grading: React.FC = () => { const isAdmin = role === Role.Admin; const [showAllGroups, setShowAllGroups] = useState(isAdmin || group === null); - const dispatch = useDispatch(); - useEffect(() => { - dispatch(fetchGradingOverviews(!showAllGroups)); - }, [dispatch, role, showAllGroups]); - + const [pageSize, setPageSize] = useState(10); const [showAllSubmissions, setShowAllSubmissions] = useState(false); + const dispatch = useDispatch(); + const updateGradingOverviewsCallback = useCallback( + (page: number, filterParams: Object) => { + dispatch( + fetchGradingOverviews( + showAllGroups, + ungradedToBackendParams(showAllSubmissions), + paginationToBackendParams(page, pageSize), + filterParams + ) + ); + }, + [dispatch, showAllGroups, showAllSubmissions, pageSize] + ); + // If submissionId or questionId is defined but not numeric, redirect back to the Grading overviews page if ( (params.submissionId && !params.submissionId?.match(numberRegExp)) || @@ -69,14 +91,15 @@ const Grading: React.FC = () => { ); const submissions = - gradingOverviews?.map(e => + gradingOverviews?.data?.map(e => !e.studentName ? { ...e, studentName: '(user has yet to log in)' } : e ) ?? []; return ( dispatch(fetchGradingOverviews(showAllGroups))} display={ - gradingOverviews === undefined ? ( + gradingOverviews?.data === undefined ? ( loadingDisplay ) : ( @@ -87,7 +110,7 @@ const Grading: React.FC = () => { variant="light" size="xs" icon={() => } - onClick={() => exportGradingCSV(gradingOverviews)} + onClick={() => exportGradingCSV(gradingOverviews.data)} > Export to CSV @@ -110,9 +133,21 @@ const Grading: React.FC = () => { popoverProps={{ position: Position.BOTTOM }} buttonProps={{ minimal: true, rightIcon: 'caret-down' }} /> + showing + + entries per page. showAllSubmissions || isSubmissionUngraded(s))} + totalRows={gradingOverviews.count} + pageSize={pageSize} + submissions={submissions} + updateEntries={updateGradingOverviewsCallback} /> ) diff --git a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx index 99ca07aecb..79e428091d 100644 --- a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx +++ b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx @@ -27,11 +27,13 @@ import { Text, TextInput } from '@tremor/react'; -import { useEffect, useState } from 'react'; +import { debounce } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useTypedSelector } from 'src/commons/utils/Hooks'; import { updateSubmissionsTableFilters } from 'src/commons/workspace/WorkspaceActions'; import { GradingOverview } from 'src/features/grading/GradingTypes'; +import { convertFilterToBackendParams } from 'src/features/grading/GradingUtils'; import GradingActions from './GradingActions'; import { AssessmentTypeBadge, GradingStatusBadge, SubmissionStatusBadge } from './GradingBadges'; @@ -39,46 +41,42 @@ import GradingSubmissionFilters from './GradingSubmissionFilters'; const columnHelper = createColumnHelper(); -const columns = [ +const makeColumns = (handleClick: () => void) => [ columnHelper.accessor('assessmentName', { header: 'Name', - cell: info => + cell: info => }), columnHelper.accessor('assessmentType', { header: 'Type', cell: info => ( - + ) }), columnHelper.accessor('studentName', { header: 'Student', - cell: info => + cell: info => }), columnHelper.accessor('studentUsername', { header: 'Username', - cell: info => + cell: info => }), columnHelper.accessor('groupName', { header: 'Group', - cell: info => + cell: info => }), columnHelper.accessor('submissionStatus', { header: 'Progress', cell: info => ( - + ) }), columnHelper.accessor('gradingStatus', { header: 'Grading', - cell: info => ( - - - - ) + cell: info => }), columnHelper.accessor(({ currentXp, xpBonus, maxXp }) => ({ currentXp, xpBonus, maxXp }), { header: 'Raw XP (+Bonus)', @@ -107,27 +105,61 @@ const columns = [ ]; type GradingSubmissionTableProps = { + totalRows: number; + pageSize: number; submissions: GradingOverview[]; + updateEntries: (page: number, filterParams: Object) => void; }; -const GradingSubmissionTable: React.FC = ({ submissions }) => { +const GradingSubmissionTable: React.FC = ({ + totalRows, + pageSize, + submissions, + updateEntries +}) => { const dispatch = useDispatch(); const tableFilters = useTypedSelector(state => state.workspaces.grading.submissionsTableFilters); const [columnFilters, setColumnFilters] = useState([ ...tableFilters.columnFilters ]); - const [globalFilter, setGlobalFilter] = useState(tableFilters.globalFilter); + /** The value to be shown in the search bar */ + const [searchQuery, setSearchQuery] = useState(''); + /** The actual value sent to the backend */ + const [searchValue, setSearchValue] = useState(''); + const debouncedSetSearchValue = useMemo(() => debounce(setSearchValue, 300), []); + const handleSearchQueryUpdate: React.ChangeEventHandler = e => { + setSearchQuery(e.target.value); + debouncedSetSearchValue(e.target.value); + }; + + const [page, setPage] = useState(0); + const maxPage = useMemo(() => Math.ceil(totalRows / pageSize) - 1, [totalRows, pageSize]); + const resetPage = useCallback(() => setPage(0), [setPage]); + + // Converts the columnFilters array into backend query parameters. + const backendFilterParams = useMemo(() => { + const filters: Array<{ [key: string]: any }> = [ + { id: 'assessmentName', value: searchValue }, + ...columnFilters + ].map(convertFilterToBackendParams); + + const params: Record = {}; + filters.forEach(e => { + Object.keys(e).forEach(key => { + params[key] = e[key]; + }); + }); + return params; + }, [columnFilters, searchValue]); + + const columns = useMemo(() => makeColumns(resetPage), [resetPage]); const table = useReactTable({ data: submissions, columns, - state: { - columnFilters, - globalFilter - }, + state: { columnFilters }, onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel() @@ -139,13 +171,16 @@ const GradingSubmissionTable: React.FC = ({ submiss }; useEffect(() => { - dispatch( - updateSubmissionsTableFilters({ - columnFilters, - globalFilter - }) - ); - }, [columnFilters, globalFilter, dispatch]); + dispatch(updateSubmissionsTableFilters({ columnFilters })); + }, [columnFilters, dispatch]); + + useEffect(() => { + resetPage(); + }, [updateEntries, resetPage]); + + useEffect(() => { + updateEntries(page, backendFilterParams); + }, [updateEntries, page, backendFilterParams]); return ( <> @@ -165,9 +200,9 @@ const GradingSubmissionTable: React.FC = ({ submiss } - placeholder="Search for any value here..." - value={globalFilter ?? ''} - onChange={e => setGlobalFilter(e.target.value)} + placeholder="Search by assessment name" + value={searchQuery} + onChange={handleSearchQueryUpdate} /> @@ -198,22 +233,36 @@ const GradingSubmissionTable: React.FC = ({ submiss
+
@@ -229,11 +278,13 @@ type FilterableProps = { column: Column; value: string; children?: React.ReactNode; + onClick?: () => void; }; -const Filterable: React.FC = ({ column, value, children }) => { +const Filterable: React.FC = ({ column, value, children, onClick }) => { const handleFilterChange = () => { column.setFilterValue(value); + onClick?.(); }; return (