diff --git a/ccm_web/client/src/App.tsx b/ccm_web/client/src/App.tsx index 6d4dc211d..cae1e4178 100644 --- a/ccm_web/client/src/App.tsx +++ b/ccm_web/client/src/App.tsx @@ -103,7 +103,7 @@ function App (props: AppProps): JSX.Element { ()} /> {features.map(feature => { - return + return }/> })} (
Under Construction
)} />
diff --git a/ccm_web/client/src/api.ts b/ccm_web/client/src/api.ts index 4a3d970ad..48a465aee 100644 --- a/ccm_web/client/src/api.ts +++ b/ccm_web/client/src/api.ts @@ -1,4 +1,4 @@ -import { CanvasCourseBase } from './models/canvas' +import { CanvasCourseBase, CanvasCourseSection } from './models/canvas' import { Globals } from './models/models' import handleErrors from './utils/handleErrors' @@ -6,6 +6,8 @@ export interface LtiProps { ltiKey: string | undefined } +const jsonMimeType = 'application/json' + const initRequest = (key: string | undefined, headers: string[][] = []): RequestInit => { if (key !== undefined) { headers.push(['Authorization', 'Bearer ' + key]) @@ -23,11 +25,17 @@ const getGet = (key: string | undefined): RequestInit => { return request } +const getPost = (key: string | undefined, body: string): RequestInit => { + const headers: string[][] = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]] + const request = initRequest(key, headers) + request.method = 'POST' + request.body = body + return request +} + // This currently assumes all put requests have a JSON payload and receive a JSON response. const getPut = (key: string | undefined, body: string): RequestInit => { - const headers: string[][] = [] - headers.push(['Content-Type', 'application/json']) - headers.push(['Accept', 'application/json']) + const headers: string[][] = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]] const request = initRequest(key, headers) request.method = 'PUT' request.body = body @@ -55,18 +63,17 @@ export const getGlobals = async (key: string | undefined): Promise => { return await resp.json() } -const delay = async (ms: number): Promise => { - await new Promise(resolve => setTimeout(() => resolve(), ms)) +export const getCourseSections = async (key: string | undefined, courseId: number): Promise => { + const request = getGet(key) + const resp = await fetch('/api/course/' + courseId.toString() + '/sections', request) + await handleErrors(resp) + return await resp.json() } -// This is a placeholder for a real implementation (I mean, obviously :D) -export const getCourseSections = async (key: string | undefined, courseId: string): Promise => { - const sections = await delay(2000).then(() => { - if (Math.random() * 3 > 1) { - return (['AAAA', 'BBBB']) - } else { - return new Promise((resolve, reject) => { reject(new Error('Error retrieving course section information.')) }) - } - }) - return sections +export const addCourseSections = async (key: string | undefined, courseId: number, sectionNames: string[]): Promise => { + const body = JSON.stringify({ sections: sectionNames }) + const request = getPost(key, body) + const resp = await fetch('/api/course/' + courseId.toString() + '/sections', request) + await handleErrors(resp) + return await resp.json() } diff --git a/ccm_web/client/src/models/FeatureUIData.tsx b/ccm_web/client/src/models/FeatureUIData.tsx index b7178964c..a28d4d11c 100644 --- a/ccm_web/client/src/models/FeatureUIData.tsx +++ b/ccm_web/client/src/models/FeatureUIData.tsx @@ -11,9 +11,11 @@ import ConvertCanvasGradebook from '../pages/GradebookCanvas' import MergeSections from '../pages/MergeSections' import BulkSectionCreate from '../pages/BulkSectionCreate' import { LtiProps } from '../api' -import { RoleEnum } from './models' +import { Globals, RoleEnum } from './models' -export interface CCMComponentProps extends LtiProps {} +export interface CCMComponentProps extends LtiProps { + globals: Globals +} interface FeatureUIGroup { id: string diff --git a/ccm_web/client/src/models/canvas.ts b/ccm_web/client/src/models/canvas.ts index c7f857cc5..59bd832ed 100644 --- a/ccm_web/client/src/models/canvas.ts +++ b/ccm_web/client/src/models/canvas.ts @@ -2,3 +2,9 @@ export interface CanvasCourseBase { id: number name: string } + +export interface CanvasCourseSection { + id: number + name: string + total_students?: number +} diff --git a/ccm_web/client/src/models/models.ts b/ccm_web/client/src/models/models.ts index 835273e0d..59c81306d 100644 --- a/ccm_web/client/src/models/models.ts +++ b/ccm_web/client/src/models/models.ts @@ -28,6 +28,7 @@ export interface Course { export interface Globals { environment: 'production' | 'development' + canvasURL: string userLoginId: string course: Course } @@ -42,3 +43,7 @@ export interface APIErrorData { statusCode: number errors: APIErrorPayload[] } + +export interface IDefaultError { + errors: APIErrorPayload[] +} diff --git a/ccm_web/client/src/pages/BulkSectionCreate.tsx b/ccm_web/client/src/pages/BulkSectionCreate.tsx index 022a3b2c6..7cf599717 100644 --- a/ccm_web/client/src/pages/BulkSectionCreate.tsx +++ b/ccm_web/client/src/pages/BulkSectionCreate.tsx @@ -1,16 +1,17 @@ -import { Backdrop, Box, Button, CircularProgress, Grid, makeStyles, Paper, Typography } from '@material-ui/core' -import CloudDoneIcon from '@material-ui/icons/CloudDone' -import ErrorIcon from '@material-ui/icons/Error' +import {Backdrop, Box, Button, Card, CardActions, CardContent, CircularProgress, Grid, Link, makeStyles, Paper, Typography } from '@material-ui/core' +import { CloudDone as CloudDoneIcon, CheckCircle, Error as ErrorIcon, Warning } from '@material-ui/icons' import React, { useEffect, useState } from 'react' -import { getCourseSections } from '../api' +import { addCourseSections, getCourseSections } from '../api' import BulkSectionCreateUploadConfirmationTable, { Section } from '../components/BulkSectionCreateUploadConfirmationTable' import FileUpload from '../components/FileUpload' import ValidationErrorTable from '../components/ValidationErrorTable' -import { createSectionsProps } from '../models/feature' -import { CCMComponentProps } from '../models/FeatureUIData' import usePromise from '../hooks/usePromise' import { DuplicateSectionInFileSectionRowsValidator, hasHeader, InvalidationType, SectionNameHeaderValidator, SectionRowsValidator, SectionsRowInvalidation, SectionsSchemaInvalidation, SectionsSchemaValidator } from '../components/BulkSectionCreateValidators' import ExampleFileDownloadHeader, { ExampleFileDownloadHeaderProps } from '../components/ExampleFileDownloadHeader' +import { CanvasCourseSection } from '../models/canvas' +import { createSectionsProps } from '../models/feature' +import { CCMComponentProps } from '../models/FeatureUIData' +import { APIErrorPayload, IDefaultError } from '../models/models' const useStyles = makeStyles((theme) => ({ root: { @@ -20,6 +21,11 @@ const useStyles = makeStyles((theme) => ({ margin: 5 } }, + confirmContainer: { + position: 'relative', + zIndex: 0, + textAlign: 'center' + }, uploadContainer: { position: 'relative', zIndex: 0, @@ -33,7 +39,8 @@ const useStyles = makeStyles((theme) => ({ fileNameContainer: { marginBottom: 15, paddingLeft: 10, - paddingRight: 10 + paddingRight: 10, + textAlign: 'left' }, fileName: { color: '#3F648E', @@ -74,8 +81,11 @@ const useRowLevelErrorStyles = makeStyles((theme) => ({ paddingLeft: 10, paddingRight: 10 }, - dialogIcon: { - color: 'red' + errorIcon: { + color: '#3F648E' + }, + warningIcon: { + color: '#e2cf2a' } })) @@ -97,7 +107,7 @@ const useTopLevelErrorStyles = makeStyles((theme) => ({ } }, dialogIcon: { - color: 'red' + color: '#3F648E' } })) @@ -120,17 +130,42 @@ const useAPIErrorStyles = makeStyles((theme) => ({ } }, dialogIcon: { - color: 'red' + color: '#3F648E' + } +})) + +const useSuccessStyles = makeStyles((theme) => ({ + card: { + textAlign: 'center' + }, + cardFooter: { + display: 'block', + backgroundColor: '#F7F7F7', + textAlign: 'center' + }, + cardFooterText: { + textAlign: 'center' + }, + icon: { + color: '#306430', + width: 100, + height: 100 } })) enum BulkSectionCreatePageState { - LoadingExistingSectionNames, + UploadPending, LoadingExistingSectionNamesFailed, - Upload, InvalidUpload, - Confirm, - Done + Submit, + CreateSectionsSuccess, + CreateSectionsError, + Saving +} + +enum ErrorType { + Error, + Warning } interface BulkSectionCreatePageStateData { @@ -147,24 +182,72 @@ function BulkSectionCreate (props: BulkSectionCreateProps): JSX.Element { const rowLevelErrorClasses = useRowLevelErrorStyles() const topLevelClasses = useTopLevelErrorStyles() const apiErrorClasses = useAPIErrorStyles() + const successClasses = useSuccessStyles() - const [pageState, setPageState] = useState({ state: BulkSectionCreatePageState.LoadingExistingSectionNames, schemaInvalidation: [], rowInvalidations: [] }) + const [pageState, setPageState] = useState({ state: BulkSectionCreatePageState.UploadPending, schemaInvalidation: [], rowInvalidations: [] }) const [file, setFile] = useState(undefined) const [sectionNames, setSectionNames] = useState([]) const [existingSectionNames, setExistingSectionNames] = useState(undefined) const [doLoadCanvasSectionData, isExistingSectionsLoading, getCanvasSectionDataError] = usePromise( - async () => await getCourseSections(props.ltiKey, 'TODO-CourseNumberFromProps?'), - (value: string[]) => setExistingSectionNames(value.map(s => { return s.toUpperCase() })) + async () => await getCourseSections(props.ltiKey, props.globals.course.id), + (value: CanvasCourseSection[]) => { + const existingSuggestions = value.map(s => { return s.name.toUpperCase() }) + setExistingSectionNames(existingSuggestions) + } ) + useEffect(() => { + if (getCanvasSectionDataError !== undefined) { + setPageState({ state: BulkSectionCreatePageState.LoadingExistingSectionNamesFailed, schemaInvalidation: [], rowInvalidations: [] }) + } + }, [getCanvasSectionDataError]) useEffect(() => { + if (pageState.state === BulkSectionCreatePageState.Saving) { + const serverInvalidations = doServerValidation() + if (serverInvalidations.length !== 0) { + handleRowLevelInvalidationError(serverInvalidations) + } else { + void doSaveCanvasSectionData() + } + } + }, [existingSectionNames]) + + const [doSaveCanvasSectionData, isSaveCanvasSectionDataLoading, getSaveCanvasSectionDataError] = usePromise( + async () => await addCourseSections(props.ltiKey, props.globals.course.id, sectionNames), + (newSections: CanvasCourseSection[]) => { + const originalSectionNames: string[] = (existingSectionNames != null) ? existingSectionNames : [] + setPageState({ state: BulkSectionCreatePageState.CreateSectionsSuccess, schemaInvalidation: [], rowInvalidations: [] }) + setExistingSectionNames([...new Set([...originalSectionNames, ...newSections.map(newSection => { return newSection.name.toUpperCase() })])]) + } + ) + + const isSubmitting = (): boolean => { + return (isExistingSectionsLoading || isSaveCanvasSectionDataLoading) + } + + const submit = async (): Promise => { + setPageState({ state: BulkSectionCreatePageState.Saving, schemaInvalidation: [], rowInvalidations: [] }) void doLoadCanvasSectionData() - }, []) + } + + const convertErrorsToRowInvalidations = (apiError: APIErrorPayload[]): SectionsRowInvalidation[] => { + const invalidations: SectionsRowInvalidation[] = [] + if (apiError !== undefined) { + apiError.forEach(e => { + invalidations.push({ message: `"${e.failedInput !== null ? e.failedInput : 'UNKNOWN'}" ${e.message}`, rowNumber: e.failedInput !== null ? sectionNames.indexOf(e.failedInput) + 1 : -1, type: InvalidationType.Error }) + }) + } + return invalidations + } useEffect(() => { - setPageState({ state: BulkSectionCreatePageState.LoadingExistingSectionNamesFailed, schemaInvalidation: [], rowInvalidations: [] }) - }, [getCanvasSectionDataError]) + if (getSaveCanvasSectionDataError !== undefined) { + const errors = (getSaveCanvasSectionDataError as unknown as IDefaultError).errors + const rowInvalidations = convertErrorsToRowInvalidations(errors) + setPageState({ state: BulkSectionCreatePageState.CreateSectionsError, schemaInvalidation: [], rowInvalidations: rowInvalidations }) + } + }, [getSaveCanvasSectionDataError]) class DuplicateExistingSectionRowsValidator implements SectionRowsValidator { validate = (sectionNames: string[]): SectionsRowInvalidation[] => { @@ -194,11 +277,26 @@ function BulkSectionCreate (props: BulkSectionCreateProps): JSX.Element { } useEffect(() => { - parseUpload(file) + if (file === undefined) { + resetPageState() + } else { + parseFile(file) + } }, [file]) + useEffect(() => { + if (sectionNames.length > 0) { + const clientInvalidations = doClientValidation() + if (clientInvalidations.length !== 0) { + handleRowLevelInvalidationError(clientInvalidations) + } else { + setPageState({ state: BulkSectionCreatePageState.Submit, schemaInvalidation: [], rowInvalidations: [] }) + } + } + }, [sectionNames]) + const resetPageState = (): void => { - setPageState({ state: BulkSectionCreatePageState.Upload, schemaInvalidation: [], rowInvalidations: [] }) + setPageState({ state: BulkSectionCreatePageState.UploadPending, schemaInvalidation: [], rowInvalidations: [] }) } const handleSchemaError = (schemaInvalidations: SectionsSchemaInvalidation[]): void => { @@ -211,14 +309,10 @@ function BulkSectionCreate (props: BulkSectionCreateProps): JSX.Element { const handleParseSuccess = (sectionNames: string[]): void => { setSectionNames(sectionNames) - setPageState({ state: BulkSectionCreatePageState.Confirm, schemaInvalidation: [], rowInvalidations: [] }) + setPageState({ state: BulkSectionCreatePageState.Submit, schemaInvalidation: [], rowInvalidations: [] }) } - const parseUpload = (file: File|undefined): void => { - if (file === undefined) { - resetPageState() - return - } + const parseFile = (file: File): void => { file.text().then(t => { let lines = t.split(/[\r\n]+/).map(line => { return line.trim() }) // An empty file will resultin 1 line @@ -239,20 +333,6 @@ function BulkSectionCreate (props: BulkSectionCreateProps): JSX.Element { if (hasHeader(lines)) { lines = lines.slice(1) } - - const rowInvalidations: SectionsRowInvalidation[] = [] - - const duplicateNamesInFileValidator: SectionRowsValidator = new DuplicateSectionInFileSectionRowsValidator() - rowInvalidations.push(...duplicateNamesInFileValidator.validate(lines)) - - const duplicatesNamesInCanvasValidator: DuplicateExistingSectionRowsValidator = new DuplicateExistingSectionRowsValidator() - rowInvalidations.push(...duplicatesNamesInCanvasValidator.validate(lines)) - - if (rowInvalidations.length !== 0) { - handleRowLevelInvalidationError(rowInvalidations) - return - } - handleParseSuccess(lines) }).catch(e => { // TODO Not sure how to produce this error in real life @@ -260,6 +340,23 @@ function BulkSectionCreate (props: BulkSectionCreateProps): JSX.Element { }) } + const doClientValidation = (): SectionsRowInvalidation[] => { + const rowInvalidations: SectionsRowInvalidation[] = [] + + const duplicateNamesInFileValidator: SectionRowsValidator = new DuplicateSectionInFileSectionRowsValidator() + rowInvalidations.push(...duplicateNamesInFileValidator.validate(sectionNames)) + + return rowInvalidations + } + + const doServerValidation = (): SectionsRowInvalidation[] => { + const rowInvalidations: SectionsRowInvalidation[] = [] + const duplicatesNamesInCanvasValidator: DuplicateExistingSectionRowsValidator = new DuplicateExistingSectionRowsValidator() + rowInvalidations.push(...duplicatesNamesInCanvasValidator.validate(sectionNames)) + + return rowInvalidations + } + const renderUploadHeader = (): JSX.Element => { const fileData = `SECTION_NAME @@ -274,6 +371,14 @@ Section 001` return () } + const renderLoadingText = (): JSX.Element | undefined => { + if (isExistingSectionsLoading) { + return (Loading Section Information) + } else if (isSaveCanvasSectionDataLoading) { + return (Saving Section Information) + } + } + const renderFileUpload = (): JSX.Element => { return
@@ -287,7 +392,7 @@ Section 001` - Loading Section Information + {renderLoadingText()} @@ -320,10 +425,18 @@ Section 001` const renderTryAgainButton = (): JSX.Element => { // eslint-disable-next-line no-void - return + return } - const renderRowLevelErrors = (invalidations: SectionsRowInvalidation[]): JSX.Element => { + const renderErrorIcon = (errorType: ErrorType): JSX.Element => { + if (errorType === ErrorType.Error) { + return () + } else { + return () + } + } + + const renderRowLevelErrors = (invalidations: SectionsRowInvalidation[], title: string, errorType: ErrorType): JSX.Element => { return (
{renderCSVFileName()} @@ -336,8 +449,8 @@ Section 001` - Review your CSV file - + {title} + {renderErrorIcon(errorType)} Correct the file and{renderUploadAgainButton()} @@ -371,7 +484,7 @@ Section 001` let rowLevelErrors: JSX.Element | undefined let schemaLevelErrors: JSX.Element | undefined if (pageState.rowInvalidations.length > 0) { - rowLevelErrors = renderRowLevelErrors(pageState.rowInvalidations) + rowLevelErrors = renderRowLevelErrors(pageState.rowInvalidations, 'Review your CSV file', ErrorType.Error) } if (pageState.schemaInvalidation.length > 0) { const schemaErrors: JSX.Element[] = pageState.schemaInvalidation.map((invalidation, i) => { @@ -387,6 +500,14 @@ Section 001` ) } + const renderPartialSuccess = (): JSX.Element => { + let rowLevelErrors: JSX.Element | undefined + if (pageState.rowInvalidations.length > 0) { + rowLevelErrors = renderRowLevelErrors(pageState.rowInvalidations, 'Some errors occurred', ErrorType.Warning) + } + return
{rowLevelErrors !== undefined && rowLevelErrors}
+ } + const renderAPIError = (): JSX.Element => { return ( @@ -401,7 +522,7 @@ Section 001` const renderConfirm = (sectionNames: Section[]): JSX.Element => { return ( -
+
{renderCSVFileName()} @@ -414,12 +535,23 @@ Section 001` Review your CSV file - Your file is valid! If this looks correct proceed with download - + Your file is valid! If this looks correct, click "Submit" to proceed. + + + + + + + + + {renderLoadingText()} + + +
) } @@ -427,20 +559,36 @@ Section 001` return sectionNames.map((name, i) => { return { rowNumber: i + 1, sectionName: name } }) } + const renderSuccess = (): JSX.Element => { + const { canvasURL, course } = props.globals + const settingsURL = `${canvasURL}/courses/${course.id}/settings` + return ( + + + + New sections have been added! + + + See your sections on the Canvas Settings page for your course. + + + ) + } const renderComponent = (): JSX.Element => { switch (pageState.state) { - case BulkSectionCreatePageState.LoadingExistingSectionNames: + case BulkSectionCreatePageState.UploadPending: return renderUpload() case BulkSectionCreatePageState.LoadingExistingSectionNamesFailed: return renderAPIError() - case BulkSectionCreatePageState.Upload: - return renderUpload() case BulkSectionCreatePageState.InvalidUpload: return renderInvalidUpload() - case BulkSectionCreatePageState.Confirm: + case BulkSectionCreatePageState.Submit: + case BulkSectionCreatePageState.Saving: return renderConfirm(sectionNamesToSection(sectionNames)) - case BulkSectionCreatePageState.Done: - return (
DONE
) + case BulkSectionCreatePageState.CreateSectionsSuccess: + return renderSuccess() + case BulkSectionCreatePageState.CreateSectionsError: + return renderPartialSuccess() default: return
?
} diff --git a/ccm_web/client/src/utils/handleErrors.ts b/ccm_web/client/src/utils/handleErrors.ts index de2493f6b..e11cd2aa5 100644 --- a/ccm_web/client/src/utils/handleErrors.ts +++ b/ccm_web/client/src/utils/handleErrors.ts @@ -33,7 +33,11 @@ class NotFoundError extends Error { } } -class DefaultError extends Error { +interface IDefaultError { + errors: APIErrorPayload[] +} + +class DefaultError extends Error implements IDefaultError { public name = 'DefaultError' errors: APIErrorPayload[] diff --git a/ccm_web/client/tsconfig.json b/ccm_web/client/tsconfig.json index cf2097413..8b023497d 100644 --- a/ccm_web/client/tsconfig.json +++ b/ccm_web/client/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "target": "es5", + "target": "es6", "lib": [ "dom", "dom.iterable", diff --git a/ccm_web/server/src/api/api.interfaces.ts b/ccm_web/server/src/api/api.interfaces.ts index d80ebbe4c..52dc27956 100644 --- a/ccm_web/server/src/api/api.interfaces.ts +++ b/ccm_web/server/src/api/api.interfaces.ts @@ -2,6 +2,7 @@ import { hasKeys } from '../typeUtils' export interface Globals { environment: 'production' | 'development' + canvasURL: string userLoginId: string course: { id: number diff --git a/ccm_web/server/src/api/api.module.ts b/ccm_web/server/src/api/api.module.ts index 01ed39106..0d5b9b227 100644 --- a/ccm_web/server/src/api/api.module.ts +++ b/ccm_web/server/src/api/api.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { APIController } from './api.controller' import { APIService } from './api.service' @@ -6,7 +7,7 @@ import { CanvasModule } from '../canvas/canvas.module' import { CanvasService } from '../canvas/canvas.service' @Module({ - imports: [CanvasModule], + imports: [CanvasModule, ConfigModule], providers: [APIService, CanvasService], controllers: [APIController] }) diff --git a/ccm_web/server/src/api/api.service.ts b/ccm_web/server/src/api/api.service.ts index 8d86d26bf..984dcbf09 100644 --- a/ccm_web/server/src/api/api.service.ts +++ b/ccm_web/server/src/api/api.service.ts @@ -1,5 +1,6 @@ import { SessionData } from 'express-session' import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' import { handleAPIError } from './api.utils' import { CanvasCourse, CanvasCourseBase, CanvasCourseSection } from '../canvas/canvas.interfaces' @@ -13,11 +14,12 @@ const logger = baseLogger.child({ filePath: __filename }) @Injectable() export class APIService { - constructor (private readonly canvasService: CanvasService) {} + constructor (private readonly canvasService: CanvasService, private readonly configService: ConfigService) {} getGlobals (sessionData: SessionData): Globals { return { environment: process.env.NODE_ENV === 'production' ? 'production' : 'development', + canvasURL: this.configService.get('canvas.instanceURL') as string, userLoginId: sessionData.data.userLoginId, course: { id: sessionData.data.course.id,