From cfc32e95863983ffbe42f0aa6199a672db6799bb Mon Sep 17 00:00:00 2001 From: Poh Jun Kang <2poh.junkang@gmail.com> Date: Mon, 19 Aug 2024 17:23:05 +0800 Subject: [PATCH] Grading Overview: TS/Tremor -> AG/Blueprint Migration (#2893) * added filterable columns in grading overview * some cleanup code * moved some grading overview FE components from tremor to blueprint * missing code from previous commit * halfway done for porting tanstack/tremor to ag grid/blueprint * more changes to grading table - animation whilst loading, filter/edit mode, bugfixes * code refactoring * filterable columns, backend sorting shell, and some partial removal of tanstack and tremor table * more component migrations, refactoring and preparation for backend sort * multi -> single sorting, moved over all ts/tremor components to ag/bp, removal of old code * fix table cutoff on small horizontal resolution and missing hover effects * refresh button for table * fixed richard's comments (mostly), and added a fix for josh's comment and PR * fixed edge case of attempted/submitted on filter from prev commit * mock files, change josh's pr 2nd issue to remove non-submitted filters on selecting ungraded, some refactoring * fixed wrong username and text overflow * eslint * prettier checks * backend mock changes * minor adjustments and cleanups * minor ui adjustments for better mobile compatability * preparation for P2 merge * eslint prettier * compile erros * compile error and eslint * prettier checks * minor ui adjustments * Revert change back to raw strings * typescript v5 fixes and richard's comments * more typescript v5 fixes * prettier * some refactoring and bug fixing * null value error in empty cell & wider actions col * added submitted to unpublished allowed filters * Fix format * Fix compile error post-merge * Refactor GradingFlex * Add React import * Remove unnecessary type annotations * Extract default styles outside component * Remove unused props * Refactor GradingText * Add React import * Use `classnames` utility * Use `Classes` object instead of raw string CSS API * Move constant default styles out of component * Remove unused props * Update BackendSaga.ts * Use strict inequality * Use `Object.entries` to iterate over both key and value * Refactor GradingFilterable.tsx * Refactor GradingActions * Add React import * Use `useSession` over `useTypedSelector` * Use Blueprint's `Tooltip` component over `htmlTitle` * Remove unnecessary `={true}` in props * Simplify conditionals with `&&` * Simplify conditions in conditionals For better readability. * Refactor conditions For readability. * Refactor GradingColumnCustomHeaders.tsx * Add React import * Use `classNames` utility instead of `String` * Remove unused prop export * Convert Props interface to type * Remove unnecessary type annotations and arguments * Convert conditional to `&&` * Refactor GradingColumnFilters.tsx * Add React import * Refactor GradingBadges.tsx * Add React import * Improve typing of badge colors * Use optional chaining where possible * Remove unused export * Add missing React import Also renamed a type to `Props` as it is unexported. * Fix imports and format post merge * hw review changes and child key error fix * fixed randomly broken grading table headers and merge conflicts * prettier * Reformat post-lint updates * Add TODO * Remove unused CSS class * Scope most styles to CSS modules * Migrate more styles to CSS modules * Simplify to use new actions format * Migrate more classes to CSS modules * Refactor grading badge styles to separate module file * Remove hardcoded CSS namespace * Make fix less hacky * Remove unnecessary default with enum type * Refactor column builder to separate file Use dependency injection to avoid needing a hook. * Remove unnecessary space * Improve readability * Remove unused param * Remove unnecessary `={true}` * Add TODO * Refactor grading badge * Simplify props * Remove unneded export --------- Co-authored-by: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> --- src/commons/application/ApplicationTypes.ts | 12 +- .../application/actions/SessionActions.ts | 14 +- .../actions/__tests__/SessionActions.ts | 17 +- src/commons/grading/GradingFlex.tsx | 33 + src/commons/grading/GradingText.tsx | 28 + src/commons/mocks/BackendMocks.ts | 23 +- src/commons/mocks/GradingMocks.ts | 3 +- src/commons/sagas/BackendSaga.ts | 25 +- src/commons/sagas/RequestsSaga.ts | 87 ++- src/commons/workspace/WorkspaceActions.ts | 9 +- src/commons/workspace/WorkspaceReducer.ts | 12 + src/commons/workspace/WorkspaceTypes.ts | 9 +- src/features/grading/GradingTypes.ts | 98 +++ src/features/grading/GradingUtils.ts | 14 +- src/pages/academy/grading/Grading.tsx | 136 +++- .../grading/subcomponents/GradingActions.tsx | 105 +-- .../grading/subcomponents/GradingBadges.tsx | 154 ++++- .../GradingColumnCustomHeaders.tsx | 62 ++ .../subcomponents/GradingColumnFilters.tsx | 26 + .../subcomponents/GradingFilterable.tsx | 26 + .../GradingSubmissionFilters.tsx | 12 +- .../subcomponents/GradingSubmissionsTable.tsx | 619 +++++++++++------- .../gradingSubmissionsTableUtils.tsx | 166 +++++ .../subcomponents/TeamFormationBadges.tsx | 9 +- src/styles/Grading.module.scss | 160 +++++ src/styles/GradingBadges.module.scss | 58 ++ src/styles/_academy.scss | 155 +++-- 27 files changed, 1607 insertions(+), 465 deletions(-) create mode 100644 src/commons/grading/GradingFlex.tsx create mode 100644 src/commons/grading/GradingText.tsx create mode 100644 src/pages/academy/grading/subcomponents/GradingColumnCustomHeaders.tsx create mode 100644 src/pages/academy/grading/subcomponents/GradingColumnFilters.tsx create mode 100644 src/pages/academy/grading/subcomponents/GradingFilterable.tsx create mode 100644 src/pages/academy/grading/subcomponents/gradingSubmissionsTableUtils.tsx create mode 100644 src/styles/Grading.module.scss create mode 100644 src/styles/GradingBadges.module.scss diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index df8c5d7ab7..e0a54c99ac 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -5,6 +5,7 @@ import { DashboardState } from '../../features/dashboard/DashboardTypes'; import { PlaygroundState } from '../../features/playground/PlaygroundTypes'; import { PlaybackStatus, RecordingStatus } from '../../features/sourceRecorder/SourceRecorderTypes'; import { StoriesEnvState, StoriesState } from '../../features/stories/StoriesTypes'; +import { freshSortState } from '../../pages/academy/grading/subcomponents/GradingSubmissionsTable'; import { WORKSPACE_BASE_PATHS } from '../../pages/fileSystem/createInBrowserFileSystem'; import { FileSystemState } from '../fileSystem/FileSystemTypes'; import { SideContentManagerState, SideContentState } from '../sideContent/SideContentTypes'; @@ -441,7 +442,16 @@ export const defaultWorkspaceManager: WorkspaceManagerState = { }, currentSubmission: undefined, currentQuestion: undefined, - hasUnsavedChanges: false + hasUnsavedChanges: false, + // TODO: The below should be a separate state + // instead of using the grading workspace state + columnVisiblity: [], + requestCounter: 0, + allColsSortStates: { + currentState: freshSortState, + sortBy: '' + }, + hasLoadedBefore: false }, playground: { ...createDefaultWorkspace('playground'), diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 5d7cdb63c1..1fc5590ce5 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -3,9 +3,14 @@ import { paginationToBackendParams, unpublishedToBackendParams } from 'src/features/grading/GradingUtils'; +import { freshSortState } from 'src/pages/academy/grading/subcomponents/GradingSubmissionsTable'; import { OptionType } from 'src/pages/academy/teamFormation/subcomponents/TeamFormationForm'; -import { GradingOverviews, GradingQuery } from '../../../features/grading/GradingTypes'; +import { + AllColsSortStates, + GradingOverviews, + GradingQuery +} from '../../../features/grading/GradingTypes'; import { TeamFormationOverview } from '../../../features/teamFormation/TeamFormationTypes'; import { Assessment, @@ -52,13 +57,16 @@ const SessionActions = createActions('session', { * many entries, starting from what offset, to get * @param filterParams - param that contains columnFilters converted into JSON for * processing into query parameters + * @param allColsSortStates - param that contains the sort states of all columns and + * the col it should be sorted by */ fetchGradingOverviews: ( filterToGroup = true, publishedFilter = unpublishedToBackendParams(false), pageParams = paginationToBackendParams(0, 10), - filterParams = {} - ) => ({ filterToGroup, publishedFilter, pageParams, filterParams }), + filterParams = {}, + allColsSortStates: AllColsSortStates = { currentState: freshSortState, sortBy: '' } + ) => ({ filterToGroup, publishedFilter, pageParams, filterParams, allColsSortStates }), fetchTeamFormationOverviews: (filterToGroup = true) => filterToGroup, fetchStudents: () => ({}), login: (providerId: string) => providerId, diff --git a/src/commons/application/actions/__tests__/SessionActions.ts b/src/commons/application/actions/__tests__/SessionActions.ts index 7afe96e44d..199cd32b56 100644 --- a/src/commons/application/actions/__tests__/SessionActions.ts +++ b/src/commons/application/actions/__tests__/SessionActions.ts @@ -4,8 +4,13 @@ import { paginationToBackendParams, unpublishedToBackendParams } from 'src/features/grading/GradingUtils'; +import { freshSortState } from 'src/pages/academy/grading/subcomponents/GradingSubmissionsTable'; -import { GradingOverviews, GradingQuery } from '../../../../features/grading/GradingTypes'; +import { + ColumnFields, + GradingOverviews, + GradingQuery +} from '../../../../features/grading/GradingTypes'; import { TeamFormationOverview } from '../../../../features/teamFormation/TeamFormationTypes'; import { Assessment, @@ -89,7 +94,8 @@ test('fetchGradingOverviews generates correct default action object', () => { filterToGroup: true, publishedFilter: unpublishedToBackendParams(false), pageParams: paginationToBackendParams(0, 10), - filterParams: {} + filterParams: {}, + allColsSortStates: { currentState: freshSortState, sortBy: '' } } }); }); @@ -99,11 +105,13 @@ test('fetchGradingOverviews generates correct action object', () => { const publishedFilter = unpublishedToBackendParams(true); const pageParams = { offset: 123, pageSize: 456 }; const filterParams = { abc: 'xxx', def: 'yyy' }; + const allColsSortStates = { currentState: freshSortState, sortBy: ColumnFields.assessmentName }; const action = SessionActions.fetchGradingOverviews( filterToGroup, publishedFilter, pageParams, - filterParams + filterParams, + allColsSortStates ); expect(action).toEqual({ type: SessionActions.fetchGradingOverviews.type, @@ -111,7 +119,8 @@ test('fetchGradingOverviews generates correct action object', () => { filterToGroup: filterToGroup, publishedFilter: publishedFilter, pageParams: pageParams, - filterParams: filterParams + filterParams: filterParams, + allColsSortStates: allColsSortStates } }); }); diff --git a/src/commons/grading/GradingFlex.tsx b/src/commons/grading/GradingFlex.tsx new file mode 100644 index 0000000000..9a7f0f4dfc --- /dev/null +++ b/src/commons/grading/GradingFlex.tsx @@ -0,0 +1,33 @@ +import { Property } from 'csstype'; +import React from 'react'; + +const defaultStyles: React.CSSProperties = { + display: 'flex' +}; + +type Props = { + justifyContent?: Property.JustifyContent; + alignItems?: Property.AlignItems; + flexDirection?: Property.FlexDirection; + children?: React.ReactNode; + style?: React.CSSProperties; + className?: string; +}; + +const GradingFlex: React.FC = ({ + justifyContent, + alignItems, + flexDirection, + children, + style, + className +}) => { + const styles: React.CSSProperties = { ...style, justifyContent, alignItems, flexDirection }; + return ( +
+ {children} +
+ ); +}; + +export default GradingFlex; diff --git a/src/commons/grading/GradingText.tsx b/src/commons/grading/GradingText.tsx new file mode 100644 index 0000000000..a96d7bb75a --- /dev/null +++ b/src/commons/grading/GradingText.tsx @@ -0,0 +1,28 @@ +import { Classes, Text } from '@blueprintjs/core'; +import classNames from 'classnames'; +import React from 'react'; + +const defaultStyles: React.CSSProperties = { + width: 'max-content', + margin: 'auto 0' +}; + +type Props = { + children?: React.ReactNode; + style?: React.CSSProperties; + isSecondaryText?: boolean; + className?: string; +}; + +const GradingText: React.FC = ({ children, style, isSecondaryText, className }) => { + return ( + + {children} + + ); +}; + +export default GradingText; diff --git a/src/commons/mocks/BackendMocks.ts b/src/commons/mocks/BackendMocks.ts index 171f8f98e6..d50a365835 100644 --- a/src/commons/mocks/BackendMocks.ts +++ b/src/commons/mocks/BackendMocks.ts @@ -5,7 +5,8 @@ import DashboardActions from 'src/features/dashboard/DashboardActions'; import { GradingOverviews, GradingQuery, - GradingQuestion + GradingQuestion, + SortStates } from '../../features/grading/GradingTypes'; import SessionActions from '../application/actions/SessionActions'; import { @@ -166,9 +167,25 @@ export function* mockBackendSaga(): SagaIterator { SessionActions.fetchGradingOverviews.type, function* (action: ReturnType): any { const accessToken = yield select((state: OverallState) => state.session.accessToken); - const { filterToGroup, pageParams, filterParams } = action.payload; + const { filterToGroup, pageParams, filterParams, allColsSortStates } = action.payload; + const sortedBy = { + sortBy: allColsSortStates.sortBy, + sortDirection: '' + }; + + Object.keys(allColsSortStates.currentState).forEach(key => { + if (allColsSortStates.sortBy === key && key) { + if (allColsSortStates.currentState[key] !== SortStates.NONE) { + sortedBy.sortDirection = allColsSortStates.currentState[key]; + } else { + sortedBy.sortBy = ''; + sortedBy.sortDirection = ''; + } + } + }); + const gradingOverviews = yield call(() => - mockFetchGradingOverview(accessToken, filterToGroup, pageParams, filterParams) + mockFetchGradingOverview(accessToken, filterToGroup, pageParams, filterParams, sortedBy) ); if (gradingOverviews !== null) { yield put(actions.updateGradingOverviews(gradingOverviews)); diff --git a/src/commons/mocks/GradingMocks.ts b/src/commons/mocks/GradingMocks.ts index 6927686ba5..5817ff8b93 100644 --- a/src/commons/mocks/GradingMocks.ts +++ b/src/commons/mocks/GradingMocks.ts @@ -100,7 +100,8 @@ export const mockFetchGradingOverview = ( accessToken: string, group: boolean, pageParams: { offset: number; pageSize: number }, - backendParams: object + backendParams: object, + sortedBy: { sortBy: string; sortDirection: string } ): 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 eb76230b80..b9c57609b8 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -16,7 +16,8 @@ import { GradingOverview, GradingOverviews, GradingQuery, - GradingQuestion + GradingQuestion, + SortStates } from '../../features/grading/GradingTypes'; import { SourcecastData } from '../../features/sourceRecorder/SourceRecorderTypes'; import SourcereelActions from '../../features/sourceRecorder/sourcereel/SourcereelActions'; @@ -358,7 +359,24 @@ const newBackendSagaOne = combineSagaHandlers(sagaActions, { return; } - const { filterToGroup, publishedFilter, pageParams, filterParams } = action.payload; + const { filterToGroup, publishedFilter, pageParams, filterParams, allColsSortStates } = + action.payload; + + const sortedBy = { + sortBy: allColsSortStates.sortBy, + sortDirection: '' + }; + + Object.entries(allColsSortStates.currentState).forEach(([key, value]) => { + if (allColsSortStates.sortBy === key && key !== '') { + if (value !== SortStates.NONE) { + sortedBy.sortDirection = value; + } else { + sortedBy.sortBy = ''; + sortedBy.sortDirection = ''; + } + } + }); const gradingOverviews: GradingOverviews | null = yield call( getGradingOverviews, @@ -366,7 +384,8 @@ const newBackendSagaOne = combineSagaHandlers(sagaActions, { filterToGroup, publishedFilter, pageParams, - filterParams + filterParams, + sortedBy ); if (gradingOverviews) { yield put(actions.updateGradingOverviews(gradingOverviews)); diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index d7fdc62302..7cca18b608 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -625,10 +625,11 @@ export const getGradingOverviews = async ( group: boolean, graded: Record | undefined, pageParams: Record, - filterParams: Record + filterParams: Record, + sortedBy: Record ): Promise => { // gradedQuery placed behind filterQuery to override progress filter if any - const params = new URLSearchParams({ ...pageParams, ...filterParams, ...graded }); + const params = new URLSearchParams({ ...pageParams, ...filterParams, ...graded, ...sortedBy }); params.append('group', `${group}`); const resp = await request(`${courseId()}/admin/grading?${params.toString()}`, 'GET', { @@ -641,50 +642,44 @@ export const getGradingOverviews = async ( 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 ? overview.student.id : -1, - studentName: overview.student ? overview.student.name : undefined, - studentNames: overview.team - ? overview.team.team_members.map((member: { name: any }) => member.name) - : undefined, - studentUsername: overview.student ? overview.student.username : undefined, - studentUsernames: overview.team - ? overview.team.team_members.map((member: { username: any }) => member.username) - : undefined, - submissionId: overview.id, - submissionStatus: overview.status, - groupName: overview.student ? overview.student.groupName : '-', - groupLeaderId: overview.student ? overview.student.groupLeaderId : undefined, - isGradingPublished: overview.isGradingPublished, - progress: backendParamsToProgressStatus( - overview.assessment.isManuallyGraded, - overview.isGradingPublished, - overview.status, - overview.gradedCount, - overview.assessment.questionCount - ), - 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 - }; - return gradingOverview; - }) - .sort((subX: GradingOverview, subY: GradingOverview) => - subX.assessmentId !== subY.assessmentId - ? subY.assessmentId - subX.assessmentId - : subY.submissionId - subX.submissionId - ) + 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 ? overview.student.id : -1, + studentName: overview.student ? overview.student.name : undefined, + studentNames: overview.team + ? overview.team.team_members.map((member: { name: any }) => member.name) + : undefined, + studentUsername: overview.student ? overview.student.username : undefined, + studentUsernames: overview.team + ? overview.team.team_members.map((member: { username: any }) => member.username) + : undefined, + submissionId: overview.id, + submissionStatus: overview.status, + groupName: overview.student ? overview.student.groupName : '-', + groupLeaderId: overview.student ? overview.student.groupLeaderId : undefined, + isGradingPublished: overview.isGradingPublished, + progress: backendParamsToProgressStatus( + overview.assessment.isManuallyGraded, + overview.isGradingPublished, + overview.status, + overview.gradedCount, + overview.assessment.questionCount + ), + 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 + }; + return gradingOverview; + }) }; }; diff --git a/src/commons/workspace/WorkspaceActions.ts b/src/commons/workspace/WorkspaceActions.ts index 8da5e4dd54..a59fd7b27a 100644 --- a/src/commons/workspace/WorkspaceActions.ts +++ b/src/commons/workspace/WorkspaceActions.ts @@ -2,6 +2,7 @@ import { createAction } from '@reduxjs/toolkit'; import { Context, Result } from 'js-slang'; import { Chapter, Variant } from 'js-slang/dist/types'; +import { AllColsSortStates, GradingColumnVisibility } from '../../features/grading/GradingTypes'; import { SALanguage } from '../application/ApplicationTypes'; import { ExternalLibraryName } from '../application/types/ExternalTypes'; import { Library } from '../assessment/AssessmentTypes'; @@ -264,7 +265,13 @@ const newActions = createActions('workspace', { updateChangePointSteps: (changepointSteps: number[], workspaceLocation: WorkspaceLocation) => ({ changepointSteps, workspaceLocation - }) + }), + // For grading table + increaseRequestCounter: 0, + decreaseRequestCounter: 0, + setGradingHasLoadedBefore: () => true, + updateAllColsSortStates: (sortStates: AllColsSortStates) => ({ sortStates }), + updateGradingColumnVisibility: (filters: GradingColumnVisibility) => ({ filters }) }); export const updateLastDebuggerResult = createAction( diff --git a/src/commons/workspace/WorkspaceReducer.ts b/src/commons/workspace/WorkspaceReducer.ts index 37ac4d37ce..99f06ae264 100644 --- a/src/commons/workspace/WorkspaceReducer.ts +++ b/src/commons/workspace/WorkspaceReducer.ts @@ -295,10 +295,19 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => { } }; }) + .addCase(WorkspaceActions.increaseRequestCounter, state => { + state.grading.requestCounter += 1; + }) + .addCase(WorkspaceActions.decreaseRequestCounter, state => { + state.grading.requestCounter = Math.max(0, state.grading.requestCounter - 1); + }) .addCase(setEditorSessionId, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); state[workspaceLocation].editorSessionId = action.payload.editorSessionId; }) + .addCase(WorkspaceActions.setGradingHasLoadedBefore, (state, action) => { + state.grading.hasLoadedBefore = action.payload; + }) .addCase(setSessionDetails, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); state[workspaceLocation].sessionDetails = action.payload.sessionDetails; @@ -318,6 +327,9 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => { .addCase(WorkspaceActions.updateSubmissionsTableFilters, (state, action) => { state.grading.submissionsTableFilters = action.payload.filters; }) + .addCase(WorkspaceActions.updateAllColsSortStates, (state, action) => { + state.grading.allColsSortStates = action.payload.sortStates; + }) .addCase(WorkspaceActions.updateCurrentAssessmentId, (state, action) => { state.assessment.currentAssessment = action.payload.assessmentId; state.assessment.currentQuestion = action.payload.questionId; diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts index aa20bed986..c280cf44f5 100644 --- a/src/commons/workspace/WorkspaceTypes.ts +++ b/src/commons/workspace/WorkspaceTypes.ts @@ -1,5 +1,6 @@ import { Context, Result } from 'js-slang'; +import { AllColsSortStates, GradingColumnVisibility } from '../../features/grading/GradingTypes'; import { SourcecastWorkspaceState } from '../../features/sourceRecorder/sourcecast/SourcecastTypes'; import { SourcereelWorkspaceState } from '../../features/sourceRecorder/sourcereel/SourcereelTypes'; import { InterpreterOutput } from '../application/ApplicationTypes'; @@ -25,10 +26,16 @@ type AssessmentWorkspaceAttr = { type AssessmentWorkspaceState = AssessmentWorkspaceAttr & WorkspaceState; type GradingWorkspaceAttr = { - readonly submissionsTableFilters: SubmissionsTableFilters; readonly currentSubmission?: number; readonly currentQuestion?: number; readonly hasUnsavedChanges: boolean; + // TODO: The below should be a separate state + // instead of using the grading workspace state + readonly submissionsTableFilters: SubmissionsTableFilters; + readonly columnVisiblity: GradingColumnVisibility; + readonly requestCounter: number; + readonly allColsSortStates: AllColsSortStates; + readonly hasLoadedBefore: boolean; }; type GradingWorkspaceState = GradingWorkspaceAttr & WorkspaceState; diff --git a/src/features/grading/GradingTypes.ts b/src/features/grading/GradingTypes.ts index f978eb1ce9..bb9f4c667c 100644 --- a/src/features/grading/GradingTypes.ts +++ b/src/features/grading/GradingTypes.ts @@ -1,3 +1,5 @@ +import { ColDef } from 'ag-grid-community'; + import { AssessmentStatus, AssessmentType, @@ -9,6 +11,25 @@ import { } from '../../commons/assessment/AssessmentTypes'; import { Notification } from '../../commons/notificationBadge/NotificationBadgeTypes'; +export enum ColumnFields { + assessmentName = 'assessmentName', + assessmentType = 'assessmentType', + studentName = 'studentName', + studentUsername = 'studentUsername', + groupName = 'groupName', + progressStatus = 'progressStatus', + xp = 'xp', + actionsIndex = 'actionsIndex' +} + +export type ColumnFieldsKeys = keyof typeof ColumnFields; + +export enum SortStates { + ASC = 'sort-asc', + DESC = 'sort-desc', + NONE = 'sort' +} + /** * Information on a Grading, for a particular student submission * for a particular assessment. Used for display in the UI. @@ -53,6 +74,20 @@ export type GradingOverviewWithNotifications = { */ export type GradingAnswer = GradingQuestion[]; +export type AllColsSortStates = { + currentState: SortStateProperties; + sortBy: ColumnFieldsKeys | ''; +}; + +export type ColumnFiltersState = ColumnFilter[]; + +export type ColumnFilter = { + id: string; + value: unknown; +}; + +export type GradingColumnVisibility = ColumnFieldsKeys[]; + export type GradingAssessment = { coverPicture: string; id: number; @@ -69,6 +104,69 @@ export type GradingQuery = { assessment: GradingAssessment; }; +export type GradingSubmissionTableProps = { + showAllSubmissions: boolean; + totalRows: number; + pageSize: number; + submissions: GradingOverview[]; + updateEntries: (page: number, filterParams: object) => void; +}; + +export enum ColumnName { + assessmentName = 'Name', + assessmentType = 'Type', + studentName = 'Student(s)', + studentUsername = 'Username(s)', + groupName = 'Group', + progressStatus = 'Progress', + xp = 'Raw XP (+Bonus)', + actionsIndex = 'Actions' +} + +export type ColumnNameKeys = keyof typeof ColumnName; + +export type SortStateProperties = { + assessmentName: SortStates; + assessmentType: SortStates; + studentName: SortStates; + studentUsername: SortStates; + groupName: SortStates; + progressStatus: SortStates; + xp: SortStates; + actionsIndex: SortStates; +}; + +export type SortStatePropertiesTypes = keyof SortStateProperties; + +export type IGradingTableRow = { + assessmentName: string; + assessmentType: string; + studentName: string; + studentUsername: string; + groupName: string; + progressStatus: ProgressStatus; + xp: string; + actionsIndex: number; // actions needs a column, but only submission ID data, so it stores submission ID + courseID: number; +}; + +export type IGradingTableProperties = { + customComponents: any; + defaultColDefs: ColDef; + headerHeight: number; + overlayLoadingTemplate: string; + overlayNoRowsTemplate: string; + pageSize: number; + pagination: boolean; + rowClass: string; + rowHeight: number; + suppressMenuHide: boolean; + suppressPaginationPanel: boolean; + suppressRowClickSelection: boolean; + tableHeight: string; + tableMargins: string; +}; + /** * Encapsulates information regarding grading a * particular question in a submission. diff --git a/src/features/grading/GradingUtils.ts b/src/features/grading/GradingUtils.ts index 37b66eb077..d8c5488576 100644 --- a/src/features/grading/GradingUtils.ts +++ b/src/features/grading/GradingUtils.ts @@ -6,7 +6,7 @@ import { ProgressStatuses } from 'src/commons/assessment/AssessmentTypes'; -import { GradingOverview } from './GradingTypes'; +import { ColumnFields, GradingOverview } from './GradingTypes'; export const exportGradingCSV = (gradingOverviews: GradingOverview[] | undefined) => { if (!gradingOverviews) return; @@ -71,17 +71,17 @@ export const exportGradingCSV = (gradingOverviews: GradingOverview[] | undefined // TODO: Two-way conversion function for frontend-backend parameter conversion export const convertFilterToBackendParams = (column: ColumnFilter) => { switch (column.id) { - case 'assessmentName': + case ColumnFields.assessmentName: return { title: column.value }; - case 'assessmentType': + case ColumnFields.assessmentType: return { type: column.value }; - case 'studentName': + case ColumnFields.studentName: return { name: column.value }; - case 'studentUsername': + case ColumnFields.studentUsername: return { username: column.value }; - case 'progress': + case ColumnFields.progressStatus: return progressStatusToBackendParams(column.value as ProgressStatus); - case 'groupName': + case ColumnFields.groupName: return { groupName: column.value }; default: return {}; diff --git a/src/pages/academy/grading/Grading.tsx b/src/pages/academy/grading/Grading.tsx index 339ce45cec..c8924c14bc 100644 --- a/src/pages/academy/grading/Grading.tsx +++ b/src/pages/academy/grading/Grading.tsx @@ -1,16 +1,17 @@ -import '@tremor/react/dist/esm/tremor.css'; - -import { Icon as BpIcon, NonIdealState, Position, Spinner, SpinnerSize } from '@blueprintjs/core'; +import { Button, Icon, 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 SessionActions from 'src/commons/application/actions/SessionActions'; import { Role } from 'src/commons/application/ApplicationTypes'; +import GradingFlex from 'src/commons/grading/GradingFlex'; +import GradingText from 'src/commons/grading/GradingText'; import SimpleDropdown from 'src/commons/SimpleDropdown'; -import { useSession } from 'src/commons/utils/Hooks'; +import { useSession, useTypedSelector } from 'src/commons/utils/Hooks'; +import WorkspaceActions from 'src/commons/workspace/WorkspaceActions'; import { numberRegExp } from 'src/features/academy/AcademyTypes'; +import { GradingOverview } from 'src/features/grading/GradingTypes'; import { exportGradingCSV, paginationToBackendParams, @@ -48,22 +49,73 @@ const Grading: React.FC = () => { const [pageSize, setPageSize] = useState(10); const [showAllSubmissions, setShowAllSubmissions] = useState(false); + const [refreshQueryData, setRefreshQueryData] = useState({ page: 1, filterParams: {} }); + const [refreshQueried, setRefreshQueried] = useState(false); // for callback (immediately becomes false) + const [animateRefresh, setAnimateRefresh] = useState(false); // for animation (becomes false on animation end) + const [submissions, setSubmissions] = useState([]); const dispatch = useDispatch(); + const allColsSortStates = useTypedSelector(state => state.workspaces.grading.allColsSortStates); + const hasLoadedBefore = useTypedSelector(state => state.workspaces.grading.hasLoadedBefore); + const updateGradingOverviewsCallback = useCallback( (page: number, filterParams: object) => { + setRefreshQueryData({ page, filterParams }); + dispatch(WorkspaceActions.setGradingHasLoadedBefore()); + dispatch(WorkspaceActions.increaseRequestCounter()); dispatch( SessionActions.fetchGradingOverviews( !showAllGroups, unpublishedToBackendParams(showAllSubmissions), paginationToBackendParams(page, pageSize), - filterParams + filterParams, + allColsSortStates ) ); }, - [dispatch, showAllGroups, showAllSubmissions, pageSize] + [dispatch, showAllGroups, showAllSubmissions, pageSize, allColsSortStates] ); + useEffect(() => { + if (refreshQueried) { + dispatch(WorkspaceActions.increaseRequestCounter()); + dispatch( + SessionActions.fetchGradingOverviews( + showAllGroups, + unpublishedToBackendParams(showAllSubmissions), + paginationToBackendParams(refreshQueryData.page, pageSize), + refreshQueryData.filterParams, + allColsSortStates + ) + ); + setRefreshQueried(false); + } + }, [ + dispatch, + showAllGroups, + showAllSubmissions, + pageSize, + allColsSortStates, + refreshQueried, + refreshQueryData + ]); + + useEffect(() => { + setSubmissions( + gradingOverviews?.data?.map(e => + !e.studentName + ? { + ...e, + studentName: Array.isArray(e.studentNames) + ? e.studentNames.join(', ') + : e.studentNames + } + : e + ) ?? [] + ); + dispatch(WorkspaceActions.decreaseRequestCounter()); + }, [gradingOverviews, dispatch]); + // If submissionId or questionId is defined but not numeric, redirect back to the Grading overviews page if ( (params.submissionId && !params.submissionId?.match(numberRegExp)) || @@ -90,39 +142,38 @@ const Grading: React.FC = () => { /> ); - const submissions = - gradingOverviews?.data?.map(e => - !e.studentName - ? { - ...e, - studentName: Array.isArray(e.studentNames) ? e.studentNames.join(', ') : e.studentNames - } - : e - ) ?? []; - return ( dispatch(SessionActions.fetchGradingOverviews(showAllGroups))} + loadContentDispatch={() => { + if (!hasLoadedBefore) { + dispatch(SessionActions.fetchGradingOverviews(showAllGroups)); + } + }} display={ gradingOverviews?.data === undefined ? ( loadingDisplay ) : ( - - - - Submissions + + + + + Submissions + - - - - Viewing + + + + Viewing { popoverProps={{ position: Position.BOTTOM }} buttonProps={{ minimal: true, rightIcon: 'caret-down' }} /> - submissions from + submissions from { popoverProps={{ position: Position.BOTTOM }} buttonProps={{ minimal: true, rightIcon: 'caret-down' }} /> - showing + showing { popoverProps={{ position: Position.BOTTOM }} buttonProps={{ minimal: true, rightIcon: 'caret-down' }} /> - entries per page. - + entries per page. + + - + ) } - fullWidth={true} + fullWidth /> ); }; diff --git a/src/pages/academy/grading/subcomponents/GradingActions.tsx b/src/pages/academy/grading/subcomponents/GradingActions.tsx index a42d67f134..4c6c47ce81 100644 --- a/src/pages/academy/grading/subcomponents/GradingActions.tsx +++ b/src/pages/academy/grading/subcomponents/GradingActions.tsx @@ -1,21 +1,24 @@ -import { Button, Icon as BpIcon } from '@blueprintjs/core'; +import { Button, Icon, Tooltip } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { Flex, Icon } from '@tremor/react'; +import React from 'react'; import { useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; import SessionActions from 'src/commons/application/actions/SessionActions'; import { ProgressStatus, ProgressStatuses } from 'src/commons/assessment/AssessmentTypes'; +import GradingFlex from 'src/commons/grading/GradingFlex'; import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; -import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { useSession } from 'src/commons/utils/Hooks'; type Props = { submissionId: number; + style?: React.CSSProperties; progress: ProgressStatus; + filterMode: boolean; }; -const GradingActions: React.FC = ({ submissionId, progress }) => { +const GradingActions: React.FC = ({ submissionId, style, progress, filterMode }) => { const dispatch = useDispatch(); - const courseId = useTypedSelector(store => store.session.courseId); + const { courseId } = useSession(); const handleReautogradeClick = async () => { const confirm = await showSimpleConfirmDialog({ @@ -66,46 +69,68 @@ const GradingActions: React.FC = ({ submissionId, progress }) => { } }; + const isGraded = progress === ProgressStatuses.graded; + const isSubmitted = progress === ProgressStatuses.submitted; + const isPublished = progress === ProgressStatuses.published; + return ( - - - } variant="light" /> - + + {filterMode && ( + + + + + + + + )} - + {(isGraded || isSubmitted) && ( + + )} - + {(isGraded || isSubmitted) && ( + + )} - + )} - + )} + ); }; diff --git a/src/pages/academy/grading/subcomponents/GradingBadges.tsx b/src/pages/academy/grading/subcomponents/GradingBadges.tsx index b10dcf290f..53d157afbb 100644 --- a/src/pages/academy/grading/subcomponents/GradingBadges.tsx +++ b/src/pages/academy/grading/subcomponents/GradingBadges.tsx @@ -1,28 +1,102 @@ import { Icon } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { ColumnFilter } from '@tanstack/react-table'; -import { Badge } from '@tremor/react'; +import classNames from 'classnames'; +import React from 'react'; import { ProgressStatus, ProgressStatuses } from 'src/commons/assessment/AssessmentTypes'; +import { ColumnFilter } from 'src/features/grading/GradingTypes'; +import classes from 'src/styles/Grading.module.scss'; +import badgeClasses from 'src/styles/GradingBadges.module.scss'; + +declare const sizeValues: readonly ['xs', 'sm', 'md', 'lg', 'xl']; +declare type Size = (typeof sizeValues)[number]; + +type BadgeProps = { + text: string; + /** First color is bg, second is text. Refer to {typeof AVAILABLE_COLORS} */ + color?: readonly [string, string]; + size?: Size; + icon?: React.ReactNode; +}; + +const Badge: React.FC = props => { + return ( +
+ {props.icon} + {props.text} +
+ ); +}; + +// First color is bg, second is text (text is more saturated/darker). Colors are referenced from tailwind css. +const AVAILABLE_COLORS = { + indigo: ['#818cf8', '#4f46e5'], + emerald: ['#6ee7b7', '#059669'], + sky: ['#7dd3fc', '#0284c7'], + green: ['#4ade80', '#15803d'], + yellow: ['#fde047', '#ca8a04'], + red: ['#f87171', '#b91c1c'], + gray: ['#9ca3af', '#374151'], + purple: ['#c084fc', '#7e22ce'], + blue: ['#93c5fd', '#2563eb'] +} as const; const BADGE_COLORS = Object.freeze({ + // assessment types + missions: AVAILABLE_COLORS.indigo, + quests: AVAILABLE_COLORS.emerald, + paths: AVAILABLE_COLORS.sky, + + // submission status + [ProgressStatuses.autograded]: AVAILABLE_COLORS.purple, + [ProgressStatuses.not_attempted]: AVAILABLE_COLORS.gray, + [ProgressStatuses.attempting]: AVAILABLE_COLORS.red, + [ProgressStatuses.attempted]: AVAILABLE_COLORS.red, + + // grading status + [ProgressStatuses.submitted]: AVAILABLE_COLORS.yellow, + [ProgressStatuses.graded]: AVAILABLE_COLORS.green, + [ProgressStatuses.published]: AVAILABLE_COLORS.blue +}); + +// For supporting tables that still use Tremor & Tanstack (e.g TeamFormationBadges since they copied the old tanstack grading code) +// TO BE REMOVED AFTER THEY MIGRATE TO BLUEPRINTJS/AGGRID +const BADGE_COLORS_LEGACY = Object.freeze({ // assessment types missions: 'indigo', quests: 'emerald', paths: 'sky', - // ProgressStatus + // submission status [ProgressStatuses.autograded]: 'purple', [ProgressStatuses.not_attempted]: 'gray', [ProgressStatuses.attempting]: 'red', [ProgressStatuses.attempted]: 'red', + + // grading status [ProgressStatuses.submitted]: 'yellow', [ProgressStatuses.graded]: 'green', [ProgressStatuses.published]: 'blue' }); -export function getBadgeColorFromLabel(label: string) { +function getBadgeColorFromLabel(label: string) { const maybeKey = label.toLowerCase() as keyof typeof BADGE_COLORS; - return BADGE_COLORS[maybeKey] || 'gray'; + return BADGE_COLORS[maybeKey] || AVAILABLE_COLORS.gray; +} + +// For supporting tables that still use Tremor & Tanstack (e.g TeamFormationBadges since they copied the old tanstack grading code) +// TO BE REMOVED AFTER THEY MIGRATE TO BLUEPRINTJS/AGGRID +export function getBadgeColorFromLabelLegacy(label: string) { + const maybeKey = label.toLowerCase() as keyof typeof BADGE_COLORS_LEGACY; + return BADGE_COLORS_LEGACY[maybeKey] || 'gray'; } type AssessmentTypeBadgeProps = { @@ -40,29 +114,27 @@ const AssessmentTypeBadge: React.FC = ({ type, size = ); }; -type ProgressStatusBadgeProps = { - progress: ProgressStatus; +type ColumnFilterBadgeProps = { + filter: string; + onRemove: (toRemove: string) => void; + filtersName: string; }; -const ProgressStatusBadge: React.FC = ({ progress }) => { - const statusText = progress.charAt(0).toUpperCase() + progress.slice(1); - const badgeIcon = () => ( - +const ColumnFilterBadge: React.FC = ({ filter, onRemove, filtersName }) => { + return ( + ); - return ; }; type FilterBadgeProps = { @@ -76,16 +148,42 @@ const FilterBadge: React.FC = ({ filter, onRemove }) => { return ( ); }; -export { AssessmentTypeBadge, FilterBadge, ProgressStatusBadge }; +type ProgressStatusBadgeProps = { + progress: ProgressStatus; +}; + +const ProgressStatusBadge: React.FC = ({ progress }) => { + const statusText = progress.charAt(0).toUpperCase() + progress.slice(1); + const badgeIcon = ( + + ); + return ; +}; + +export { AssessmentTypeBadge, ColumnFilterBadge, FilterBadge, ProgressStatusBadge }; diff --git a/src/pages/academy/grading/subcomponents/GradingColumnCustomHeaders.tsx b/src/pages/academy/grading/subcomponents/GradingColumnCustomHeaders.tsx new file mode 100644 index 0000000000..5182ca4798 --- /dev/null +++ b/src/pages/academy/grading/subcomponents/GradingColumnCustomHeaders.tsx @@ -0,0 +1,62 @@ +import { Icon } from '@blueprintjs/core'; +import { CustomHeaderProps } from 'ag-grid-react'; +import classNames from 'classnames'; +import React, { useEffect, useState } from 'react'; +import GradingFlex from 'src/commons/grading/GradingFlex'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { SortStates } from 'src/features/grading/GradingTypes'; +import classes from 'src/styles/Grading.module.scss'; + +import { getNextSortState } from './GradingSubmissionsTable'; + +type Props = CustomHeaderProps & { + hideColumn: (id: string) => void; + updateSortState: (id: string, sortState: SortStates) => void; + disabledSortCols: string[]; +}; + +const GradingColumnCustomHeaders: React.FC = props => { + // The values correspond to the available icons in the BlueprintJS library. "sort" means unsorted. + const [sortState, setSortState] = useState(SortStates.NONE); + const colsSortState = useTypedSelector(state => state.workspaces.grading.allColsSortStates); + + const nextSortState = () => { + setSortState(prev => getNextSortState(prev)); + props.updateSortState(props.column.getColId(), getNextSortState(sortState)); + }; + + useEffect(() => { + if (colsSortState.sortBy !== props.column.getColId()) { + setSortState(SortStates.NONE); + } + }, [colsSortState, props.column]); + + return ( + + {props.displayName} + + {!props.disabledSortCols.includes(props.column.getColId()) && ( +
nextSortState()} + > + +
+ )} + +
props.hideColumn(props.column.getColId())} + > + +
+
+ ); +}; + +export default GradingColumnCustomHeaders; diff --git a/src/pages/academy/grading/subcomponents/GradingColumnFilters.tsx b/src/pages/academy/grading/subcomponents/GradingColumnFilters.tsx new file mode 100644 index 0000000000..d85f4f292f --- /dev/null +++ b/src/pages/academy/grading/subcomponents/GradingColumnFilters.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { ColumnFilterBadge } from './GradingBadges'; + +type Props = { + filters: string[]; + onFilterRemove: (toRemove: string) => void; + filtersName: string[]; +}; + +const GradingColumnFilters: React.FC = ({ filters, onFilterRemove, filtersName }) => { + return ( +
+ {filters.map((filter, index) => ( + + ))} +
+ ); +}; + +export default GradingColumnFilters; diff --git a/src/pages/academy/grading/subcomponents/GradingFilterable.tsx b/src/pages/academy/grading/subcomponents/GradingFilterable.tsx new file mode 100644 index 0000000000..df58518a47 --- /dev/null +++ b/src/pages/academy/grading/subcomponents/GradingFilterable.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import classes from 'src/styles/Grading.module.scss'; + +type Props = { + value: string; + children?: React.ReactNode; + filterMode: boolean; +}; + +const GradingFilterable: React.FC = ({ value, children, filterMode }) => { + return ( + + ); +}; + +export default GradingFilterable; diff --git a/src/pages/academy/grading/subcomponents/GradingSubmissionFilters.tsx b/src/pages/academy/grading/subcomponents/GradingSubmissionFilters.tsx index 19a5ca8f35..10c956d7f0 100644 --- a/src/pages/academy/grading/subcomponents/GradingSubmissionFilters.tsx +++ b/src/pages/academy/grading/subcomponents/GradingSubmissionFilters.tsx @@ -1,5 +1,6 @@ -import { ColumnFilter, ColumnFiltersState } from '@tanstack/react-table'; -import { Flex } from '@tremor/react'; +import React from 'react'; +import GradingFlex from 'src/commons/grading/GradingFlex'; +import { ColumnFilter, ColumnFiltersState } from 'src/features/grading/GradingTypes'; import { FilterBadge } from './GradingBadges'; @@ -10,11 +11,14 @@ type Props = { const GradingSubmissionFilters: React.FC = ({ filters, onFilterRemove }) => { return ( - + {filters.map(filter => ( ))} - + ); }; diff --git a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx index 40dac6531c..f1f1e87bde 100644 --- a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx +++ b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx @@ -1,157 +1,152 @@ -import '@tremor/react/dist/esm/tremor.css'; +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/ag-theme-quartz.css'; -import { Icon as BpIcon } from '@blueprintjs/core'; +import { Button, H6, Icon, InputGroup } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import { - Column, - ColumnFilter, - ColumnFiltersState, - createColumnHelper, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - useReactTable -} from '@tanstack/react-table'; -import { - Bold, - Button, - Flex, - Footer, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, - TextInput -} from '@tremor/react'; +import { CellClickedEvent, ColDef } from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import classNames from 'classnames'; 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 { ProgressStatuses } from 'src/commons/assessment/AssessmentTypes'; +import GradingFlex from 'src/commons/grading/GradingFlex'; +import GradingText from 'src/commons/grading/GradingText'; import { useTypedSelector } from 'src/commons/utils/Hooks'; import WorkspaceActions from 'src/commons/workspace/WorkspaceActions'; -import { GradingOverview } from 'src/features/grading/GradingTypes'; +import { + ColumnFields, + ColumnFieldsKeys, + ColumnFilter, + ColumnFiltersState, + ColumnName, + ColumnNameKeys, + GradingColumnVisibility, + GradingSubmissionTableProps, + IGradingTableProperties, + IGradingTableRow, + SortStateProperties, + SortStates +} from 'src/features/grading/GradingTypes'; import { convertFilterToBackendParams } from 'src/features/grading/GradingUtils'; +import classes from 'src/styles/Grading.module.scss'; -import GradingActions from './GradingActions'; -import { AssessmentTypeBadge, ProgressStatusBadge } from './GradingBadges'; +import GradingColumnCustomHeaders from './GradingColumnCustomHeaders'; +import GradingColumnFilters from './GradingColumnFilters'; import GradingSubmissionFilters from './GradingSubmissionFilters'; +import { generateCols } from './gradingSubmissionsTableUtils'; -const columnHelper = createColumnHelper(); - -const makeColumns = (handleClick: () => void) => [ - columnHelper.accessor('assessmentName', { - header: 'Name', - cell: info => - }), - columnHelper.accessor('assessmentType', { - header: 'Type', - cell: info => ( - - - - ) - }), - columnHelper.accessor('studentName', { - header: 'Student(s)', - cell: info => { - const value = info.getValue(); - const fallbackValue = info.row.original.studentNames; - const finalValue = value || ''; - const finalFallbackValue = fallbackValue?.join(', ') || ''; - return ( - - ); - } - }), - columnHelper.accessor('studentUsername', { - header: 'Username(s)', - cell: info => { - const value = info.getValue(); - const fallbackValue = info.row.original.studentUsernames; - const finalValue = value || ''; - const finalFallbackValue = fallbackValue?.join(', ') || ''; - return ( - - ); - } - }), - columnHelper.accessor('groupName', { - header: 'Group', - cell: info => - }), - columnHelper.accessor('progress', { - header: 'Progress', - cell: info => ( - - - - ) - }), - columnHelper.accessor(({ currentXp, xpBonus, maxXp }) => ({ currentXp, xpBonus, maxXp }), { - header: 'Raw XP (+Bonus)', - enableColumnFilter: false, - cell: info => { - const { currentXp, xpBonus, maxXp } = info.getValue(); - return ( - - - {currentXp} (+{xpBonus}) - - / - {maxXp} - - ); - } - }), - columnHelper.accessor(({ submissionId, progress }) => ({ submissionId, progress }), { - header: 'Actions', - enableColumnFilter: false, - cell: info => { - const { submissionId, progress } = info.getValue(); - return ; - } - }) -]; - -type Props = { - totalRows: number; - pageSize: number; - submissions: GradingOverview[]; - updateEntries: (page: number, filterParams: object) => void; +export const getNextSortState = (current: SortStates) => { + switch (current) { + case SortStates.NONE: + return SortStates.ASC; + case SortStates.ASC: + return SortStates.DESC; + case SortStates.DESC: + return SortStates.NONE; + } +}; + +export const freshSortState: SortStateProperties = { + assessmentName: SortStates.NONE, + assessmentType: SortStates.NONE, + studentName: SortStates.NONE, + studentUsername: SortStates.NONE, + groupName: SortStates.NONE, + progressStatus: SortStates.NONE, + xp: SortStates.NONE, + actionsIndex: SortStates.NONE }; -const GradingSubmissionTable: React.FC = ({ +const disabledEditModeCols: string[] = [ColumnFields.actionsIndex]; + +const disabledFilterModeCols: string[] = [ColumnFields.xp, ColumnFields.actionsIndex]; + +const disabledSortCols: string[] = [ColumnFields.actionsIndex]; + +const GradingSubmissionTable: React.FC = ({ + showAllSubmissions, totalRows, pageSize, submissions, updateEntries }) => { 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 gridRef = useRef>(null); + const [page, setPage] = useState(0); + /** 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 [columnFilters, setColumnFilters] = useState([ ...tableFilters.columnFilters ]); + const [hiddenColumns, setHiddenColumns] = useState( + columnVisibility ? columnVisibility : [] + ); + const [rowData, setRowData] = useState([]); + const [colDefs, setColDefs] = useState[]>(); + // This is what that controls Grading Mode. If future feedback says it's better to default to filter mode, change it here. + const [filterMode, setFilterMode] = useState(false); - const [page, setPage] = useState(0); const maxPage = useMemo(() => Math.ceil(totalRows / pageSize) - 1, [totalRows, pageSize]); const resetPage = useCallback(() => setPage(0), [setPage]); - /** 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 defaultColumnDefs: ColDef = { + filter: false, + resizable: false, + sortable: true, + headerComponentParams: { + hideColumn: (id: ColumnNameKeys) => handleColumnFilterAdd(id), + updateSortState: (affectedID: ColumnNameKeys, sortDirection: SortStates) => { + if (!disabledSortCols.includes(affectedID)) { + const newState: SortStateProperties = { ...freshSortState }; + newState[affectedID] = sortDirection; + dispatch( + WorkspaceActions.updateAllColsSortStates({ + currentState: newState, + sortBy: affectedID + }) + ); + } + }, + disabledSortCols: disabledSortCols + } + }; + + const ROW_HEIGHT: number = 60; // in px, declared here to calculate table height + const HEADER_HEIGHT: number = 48; // in px, declared here to calculate table height + + const tableProperties: IGradingTableProperties = { + customComponents: { + agColumnHeader: GradingColumnCustomHeaders + }, + defaultColDefs: defaultColumnDefs, + headerHeight: HEADER_HEIGHT, + overlayLoadingTemplate: '
', + overlayNoRowsTemplate: + "Hmm... we didn't find any submissions, you might want to debug your filter() function.", + pageSize: pageSize, + pagination: true, + rowClass: classNames(classes['grading-left-align'], classes['grading-table-rows']), + rowHeight: ROW_HEIGHT, + suppressMenuHide: true, + suppressPaginationPanel: true, + suppressRowClickSelection: true, + tableHeight: + String(ROW_HEIGHT * (rowData.length > 0 ? rowData.length : 2) + HEADER_HEIGHT + 4) + 'px', + tableMargins: '1rem 0 0 0' + }; + // Placing searchValue as a dependency for triggering a page reset will result in double-querying. const debouncedUpdateSearchValue = useMemo( () => @@ -161,6 +156,7 @@ const GradingSubmissionTable: React.FC = ({ }, 300), [resetPage] ); + const handleSearchQueryUpdate: React.ChangeEventHandler = e => { setSearchQuery(e.target.value); debouncedUpdateSearchValue(e.target.value); @@ -169,7 +165,7 @@ const GradingSubmissionTable: React.FC = ({ // Converts the columnFilters array into backend query parameters. const backendFilterParams = useMemo(() => { const filters: Array<{ [key: string]: any }> = [ - { id: 'assessmentName', value: searchValue }, + { id: ColumnFields.assessmentName, value: searchValue }, ...columnFilters ].map(convertFilterToBackendParams); @@ -182,22 +178,30 @@ const GradingSubmissionTable: React.FC = ({ return params; }, [columnFilters, searchValue]); - const columns = useMemo(() => makeColumns(resetPage), [resetPage]); - const table = useReactTable({ - data: submissions, - columns, - state: { - columnFilters, - pagination: { - pageIndex: 0, - pageSize: pageSize + const cellClickedEvent = (event: CellClickedEvent) => { + const colClicked: string = event.colDef.field ? event.colDef.field : ''; + + if (!filterMode && !disabledEditModeCols.includes(colClicked)) { + navigate(`/courses/${courseId}/grading/${event.data.actionsIndex}`); + } else if (filterMode && !disabledFilterModeCols.includes(colClicked)) { + if (event.data[colClicked] === null || event.data[colClicked] === '') { + return; } - }, - onColumnFiltersChange: setColumnFilters, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel() - }); + handleFilterAdd({ id: colClicked, value: event.data[colClicked] }); + } + }; + + // Filter is to filter by cell value + const handleFilterAdd = ({ id, value }: ColumnFilter) => { + setColumnFilters((prev: ColumnFiltersState) => { + const alreadyExists = prev.reduce( + (acc, curr) => acc || (curr.id === id && curr.value === value), + false + ); + return alreadyExists ? [...prev] : [...prev, { id, value }]; + }); + resetPage(); + }; const handleFilterRemove = ({ id, value }: ColumnFilter) => { const newFilters = columnFilters.filter(filter => filter.id !== id && filter.value !== value); @@ -205,9 +209,47 @@ const GradingSubmissionTable: React.FC = ({ resetPage(); }; + // Column Filter is to hide Columns + const handleColumnFilterRemove = (toRemove: string) => { + if (gridRef.current?.api) { + setHiddenColumns((prev: GradingColumnVisibility) => + prev.filter(column => column !== toRemove) + ); + gridRef.current.api.setColumnsVisible([toRemove], true); + } + }; + + const handleColumnFilterAdd = (toAdd: ColumnNameKeys) => { + setHiddenColumns((prev: GradingColumnVisibility) => [...prev, toAdd]); + }; + useEffect(() => { + if ( + !showAllSubmissions && + columnFilters.reduce( + (doesItContain, currentFilter) => + doesItContain || + (currentFilter.id === ColumnFields.progressStatus && + String(currentFilter.value).toLowerCase() !== ProgressStatuses.graded && + String(currentFilter.value).toLowerCase() !== ProgressStatuses.submitted), + false + ) + ) { + setColumnFilters((prev: ColumnFiltersState) => + prev.filter(filter => filter.id !== ColumnFields.progressStatus) + ); + resetPage(); + return; + } dispatch(WorkspaceActions.updateSubmissionsTableFilters({ columnFilters })); - }, [columnFilters, dispatch]); + }, [columnFilters, showAllSubmissions, dispatch, resetPage]); + + useEffect(() => { + dispatch(WorkspaceActions.updateGradingColumnVisibility(hiddenColumns)); + if (gridRef.current?.api) { + gridRef.current.api.setColumnsVisible(hiddenColumns, false); + } + }, [hiddenColumns, dispatch]); useEffect(() => { resetPage(); @@ -217,115 +259,204 @@ const GradingSubmissionTable: React.FC = ({ updateEntries(page, backendFilterParams); }, [updateEntries, page, backendFilterParams]); + useEffect(() => { + if (gridRef.current?.api) { + if (requestCounter <= 0) { + const newData: IGradingTableRow[] = []; + + const sameData: boolean = submissions.reduce((sameData, currentSubmission, index) => { + const newRow: IGradingTableRow = { + assessmentName: currentSubmission.assessmentName, + assessmentType: currentSubmission.assessmentType, + studentName: currentSubmission.studentName + ? currentSubmission.studentName + : currentSubmission.studentNames + ? currentSubmission.studentNames.join(', ') + : '', + studentUsername: currentSubmission.studentUsername + ? currentSubmission.studentUsername + : currentSubmission.studentUsernames + ? currentSubmission.studentUsernames.join(', ') + : '', + groupName: currentSubmission.groupName, + progressStatus: currentSubmission.progress, + xp: + currentSubmission.currentXp + + ' (+' + + currentSubmission.xpBonus + + ') / ' + + currentSubmission.maxXp, + actionsIndex: currentSubmission.submissionId, + courseID: courseId! + }; + newData.push(newRow); + return ( + sameData && + newRow.actionsIndex === rowData?.[index]?.actionsIndex && + newRow.studentUsername === rowData?.[index]?.studentUsername && + newRow.groupName === rowData?.[index]?.groupName && + newRow.progressStatus === rowData?.[index]?.progressStatus && + newRow.xp === rowData?.[index]?.xp + ); + }, submissions.length === rowData?.length); + + if (!sameData) { + setRowData(newData); + } + + gridRef.current!.api.hideOverlay(); + + if (newData.length === 0 && requestCounter <= 0) { + gridRef.current!.api.showNoRowsOverlay(); + } + } else { + gridRef.current!.api.showLoadingOverlay(); + } + } + // We ignore the dependency on rowData purposely as we setRowData above. + // If not, it could cause a double execution, which is a bit expensive. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requestCounter, submissions, courseId, gridRef.current?.api]); + + const columns = useMemo(() => generateCols(filterMode), [filterMode]); + + useEffect(() => { + setColDefs(columns); + }, [resetPage, columns]); + return ( <> - - -
- - - {columnFilters.length > 0 - ? 'Filters: ' - : 'No filters applied. Click on any cell to filter by its value.'}{' '} - -
+ {hiddenColumns.length > 0 ? ( + + + Columns Hidden: + { + return ColumnName[id]; + })} + onFilterRemove={handleColumnFilterRemove} + /> + + + ) : ( + <> + )} + + + + + + + {columnFilters.length > 0 ? ( + 'Filters: ' + ) : filterMode === true ? ( + 'No filters applied. Click on any cell to filter by its value.' + + (hiddenColumns.length === 0 + ? " Click on any column header's eye icon to hide it." + : '') + ) : ( + Disable Grading Mode to enable click to filter + )} + + -
+ + + - } + -
- - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.map(row => ( - - {row.getVisibleCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
- -
-
- - <> - - - ); -}; - -type FilterableProps = { - column: Column; - value: string; - children?: React.ReactNode; - onClick?: () => void; -}; + > + -const Filterable: React.FC = ({ column, value, children, onClick }) => { - const handleFilterChange = () => { - column.setFilterValue(value); - onClick?.(); - }; +
+ +
- return ( - + + ); diff --git a/src/styles/Grading.module.scss b/src/styles/Grading.module.scss new file mode 100644 index 0000000000..d6aade16c8 --- /dev/null +++ b/src/styles/Grading.module.scss @@ -0,0 +1,160 @@ +@import '_global'; + +.grading-overview-unfilterable-btns, +.grading-overview-filterable-btns { + padding: 0 2px; + text-align: inherit; + outline: none; + line-height: 1.5; + + a { + text-decoration: none; + color: black; + } + + &, + p { + text-overflow: ellipsis; + overflow: hidden; + } +} + +.grading-overview-filterable-btns { + &:hover:not(:has(> .grading-badge)) { + text-decoration: underline; + } + + &:hover:has(> .grading-badge) { + filter: brightness(0.75); + } +} + +.grading-overview-unfilterable-btns, +.grading-overview-filterable-btns { + border-radius: 9999px; +} + +.grading-table-col-icons { + pointer-events: none; + opacity: 0; + padding: 6px; + height: fit-content; + line-height: initial !important; + border-radius: 5px; + margin-right: 2.5px; + position: absolute; + right: 0; + + &:hover { + background-color: #00000022; + } +} + +:global(.ag-header-cell).grading-left-align { + :global(span.ag-header-cell-text) { + margin: auto auto auto 0px; + } +} + +.grading-default-headers { + justify-content: space-between; + width: 100%; + transition: 0.1s ease; + cursor: pointer; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + + &:not(.grading-left-align) { + padding: 0 !important; + } + + :global(span.ag-header-cell-text) { + margin: 0 auto; + font-size: 0.8rem; + color: #6b7280; + font-weight: 600; + margin: auto; + padding: 0 5px; + } + + &:hover { + --ag-header-cell-hover-background-color: #e5e7eb; + } + + &:hover .grading-table-col-icons { + pointer-events: all; + opacity: 1; + position: relative; + transition: 0.1s ease; + } +} + +.grading-table-header-individual { + width: 100%; +} + +.grading-table-rows { + &:global(.ag-row-hover) { + --ag-row-hover-color: #f5f5f5; + } + + &:global(.ag-row.ag-row-last) { + border-bottom: 0px !important; + } +} + +.grading-filter-btn { + padding: 7.5px 15px; + border-radius: 25px; + margin: 0 15px 0 auto; + background-color: #dbeafef5 !important; + color: #3b82f6 !important; + min-width: fit-content; + + &.grading-filter-btn-on { + background-color: #f5f5f5 !important; + color: black !important; + } + + &:hover { + filter: contrast(0.9); + } +} + +.grading-def-cell { + text-align: center; + display: flex !important; + justify-content: center; + flex-direction: column; + font-size: 0.875rem; + user-select: text; + border: 0px !important; + + &:hover:has(.grading-overview-filterable-btns) { + .grading-overview-filterable-btns { + text-decoration: underline; + } + } + + &:active { + border-style: outset; + } + + &.grading-def-cell-pointer { + cursor: pointer; + } + + &.grading-def-cell-selectable { + cursor: text; + border-style: outset; + } + + &.grading-cell-align-left { + text-align: left !important; + } + + &.grading-xp-cell { + text-wrap: wrap; + line-height: 15px; + } +} diff --git a/src/styles/GradingBadges.module.scss b/src/styles/GradingBadges.module.scss new file mode 100644 index 0000000000..8fb258e0a6 --- /dev/null +++ b/src/styles/GradingBadges.module.scss @@ -0,0 +1,58 @@ +@import '_global'; + +.grading-badge { + border-radius: 9999px; + padding: 0.1rem 0.2rem; + display: flex; + flex-direction: row; + justify-content: center; + line-height: normal !important; + width: fit-content; + max-width: max(1200px * 0.2, 20vw); + margin: auto; + color: rgba(0, 0, 0, 0.7); + text-wrap: nowrap; + text-overflow: ellipsis; + max-width: 100%; + + &.grading-badge-xs { + padding: 0.25rem 0.6rem; + font-size: 0.7rem; + } + + &.grading-badge-sm { + padding: 0.4rem 0.8rem; + font-size: 0.8rem; + } + + &.grading-badge-md { + padding: 0.5rem 1rem; + font-size: 0.9rem; + } + + &.grading-badge-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; + } + + &.grading-badge-xl { + padding: 1rem 2rem; + font-size: 1.25rem; + } + + // div: .grading-def-cell + // button: .grading-overview-filterable-btns + // TODO: This also affects .grading-overview-unfilterable-btns + div:hover > button:has(&) { + text-decoration: none !important; + filter: brightness(0.75); + } +} + +.grading-badge-text { + line-height: normal !important; + width: fit-content; + max-width: max(calc(1200px * 0.2 - 16px - 2rem), calc(20vw - 16px - 2rem)); + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/styles/_academy.scss b/src/styles/_academy.scss index aaaa964289..6cdbcb3557 100644 --- a/src/styles/_academy.scss +++ b/src/styles/_academy.scss @@ -465,57 +465,134 @@ } } -.grading-overview-filterable-btns { - padding: 0; - - // Hides overflowed text for the buttons - &, - p { - text-overflow: ellipsis; - overflow: hidden; - - // For general filterable buttons - & { - max-width: 20vw; +// Grading Section +.contentdisplay-content-parent:has(div.grading-table-wrapper) { + max-width: 100%; + + .contentdisplay-content { + min-width: 1200px; + } +} + +.ag-header-cell.hide-cols-btn { + width: 32px; + height: 32px; +} + +.grading-table-footer { + padding-top: 15px; + margin-bottom: 15px; +} + +.grading-search-input { + max-width: 24rem; + width: 100%; + margin-left: 0.75rem; + + > input { + height: 40px; + border-radius: 6px; + box-shadow: none; + border: 1px solid rgba(0, 0, 0, 0.3); + font-size: 0.875rem !important; + + &::placeholder { + color: #b3b3b3; } + } +} + +.grading-loading-icon { + animation: spin 1s linear infinite; + margin: 4px; + top: 0; + bottom: 0; + right: 0; + left: 0; + width: 48px; + height: 48px; + border: 4px solid #b3b3b3 !important; + border-bottom-color: transparent !important; + border-radius: 50%; + z-index: 10; + color: #374151; +} + +.grading-actions-btn-wrappers { + > a { + display: flex; + } +} + +.grading-action-icons { + color: #3b82f6; + border-radius: 10px; + margin: auto 0; + background-color: #7dbcff00; + transition: 0.1s ease; + + svg { + fill: #3b82f6 !important; + } - /* Special case for filtered buttons (those you click to remove filter) - -16px for icon - -0.875rem for icon margins - -1.125rem for additional padding - */ - p { - max-width: calc(20vw - 16px - 2rem); + &.grading-action-icons-bg { + background-color: #7dbcff80; + + &:hover { + background-color: #7dbcffb3; } } - // Invisible border for backgroundless buttons - &:not(:has(> div)) { + .#{$ns}-icon { + margin: 6px; + } + + .#{$ns}-popover-target { + max-height: 32px; + } +} + +.grading-table-wrapper { + padding: 1rem 1.5rem 0 1.5rem; + + * { + border: none; + outline: none; text-decoration: none; + background-color: transparent; } - &:hover { - // Buttons with bg - > div > span { - // Use of contrast due to unknown background color - filter: contrast(0.9); - } + button { + cursor: pointer; + } +} - // Backgroundless buttons - &:not(:has(> div)) { - text-decoration: underline; - } +.export-csv-btn { + color: #3b82f6 !important; + + svg { + fill: #3b82f6 !important; + } + + &:hover { + text-decoration: underline !important; + background-color: transparent !important; } } -.grading-overview-footer-sibling { - // Footer component - ~ div ~ div { - pointer-events: none; +.grading-refresh-loop { + svg { + animation: 0.2s ease rotateHalf; + } + cursor: not-allowed !important; + pointer-events: none; +} - // Footer's children - > * > * { - pointer-events: all; - } +@keyframes rotateHalf { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(180deg); } }