diff --git a/docker-compose.yml b/docker-compose.yml index b97ff4e52..9019dbcc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: RAILS_ENV: 'development' DF_STUDENT_WORK_DIR: /student-work - DF_INSTITUTION_HOST: http://localhost:3000 + DF_INSTITUTION_HOST: http://localhost:4200 DF_INSTITUTION_PRODUCT_NAME: OnTrack DF_SECRET_KEY_BASE: test-secret-key-test-secret-key! @@ -24,8 +24,8 @@ services: # Authentication method - can set to AAF or ldap DF_AUTH_METHOD: database DF_AAF_ISSUER_URL: https://rapid.test.aaf.edu.au - DF_AAF_AUDIENCE_URL: http://localhost:3000 - DF_AAF_CALLBACK_URL: http://localhost:3000/api/auth/jwt + DF_AAF_AUDIENCE_URL: http://localhost:4200 + DF_AAF_CALLBACK_URL: http://localhost:4200/api/auth/jwt DF_AAF_IDENTITY_PROVIDER_URL: https://signon-uat.deakin.edu.au/idp/shibboleth DF_AAF_UNIQUE_URL: https://rapid.test.aaf.edu.au/jwt/authnrequest/research/Ag4EJJhjf0zXHqlKvKZEbg DF_AAF_AUTH_SIGNOUT_URL: https://sync-uat.deakin.edu.au/auth/logout diff --git a/package.json b/package.json index 15405c0da..eb6a1b46b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:angular17": "ng build", "lint:fix": "ng lint --fix", "lint": "ng lint", - "serve:angular17": "export NODE_OPTIONS=--max_old_space_size=4096 && ng serve --configuration $NODE_ENV", + "serve:angular17": "export NODE_OPTIONS=--max_old_space_size=4096 && ng serve --configuration $NODE_ENV --proxy-config proxy.conf.json", "start": "npm-run-all -l -s build:angular1 -p watch:angular1 serve:angular17", "watch:angular1": "grunt delta", "deploy:build2api": "ng build --delete-output-path=true --optimization=true --configuration production --output-path dist", diff --git a/proxy.conf.json b/proxy.conf.json new file mode 100644 index 000000000..63dd62750 --- /dev/null +++ b/proxy.conf.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://localhost:3000", + "secure": false + } +} diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts index 3cb491108..fffc361a4 100644 --- a/src/app/api/models/doubtfire-model.ts +++ b/src/app/api/models/doubtfire-model.ts @@ -32,6 +32,11 @@ export * from './task-comment/discussion-comment'; export * from '../services/task-outcome-alignment.service'; export * from './task-similarity'; export * from './tii-action'; +export * from './scorm-datamodel'; +export * from './scorm-player-context'; +export * from './test-attempt'; +export * from './task-comment/scorm-comment'; +export * from './task-comment/scorm-extension-comment'; // Users -- are students or staff export * from './user/user'; @@ -56,3 +61,4 @@ export * from '../services/teaching-period-break.service'; export * from '../services/learning-outcome.service'; export * from '../services/group-set.service'; export * from '../services/task-similarity.service'; +export * from '../services/test-attempt.service'; diff --git a/src/app/api/models/scorm-datamodel.ts b/src/app/api/models/scorm-datamodel.ts new file mode 100644 index 000000000..0fdc2d3a0 --- /dev/null +++ b/src/app/api/models/scorm-datamodel.ts @@ -0,0 +1,50 @@ +export class ScormDataModel { + dataModel: {[key: string]: any} = {}; + readonly msgPrefix = 'SCORM DataModel: '; + + constructor() { + this.dataModel = {}; + } + + public restore(dataModel: string) { + // console.log(this.msgPrefix + 'restoring DataModel with provided data'); + this.dataModel = JSON.parse(dataModel); + } + + public get(key: string): string { + // console.log(`SCORM DataModel: get ${key} ${this.dataModel[key]}`); + return this.dataModel[key] ?? ''; + } + + public dump(): {[key: string]: any} { + return this.dataModel; + } + + public set(key: string, value: any): string { + // console.log(this.msgPrefix + 'set: ', key, value); + this.dataModel[key] = value; + if (key.match('cmi.interactions.\\d+.id')) { + // cmi.interactions._count must be incremented after a new interaction is created + const interactionPath = key.match('cmi.interactions.\\d+'); + const objectivesCounterForInteraction = interactionPath.toString() + '.objectives._count'; + // console.log('Incrementing cmi.interactions._count'); + this.dataModel['cmi.interactions._count']++; + // cmi.interactions.n.objectives._count must be initialized after an interaction is created + // console.log(`Initializing ${objectivesCounterForInteraction}`); + this.dataModel[objectivesCounterForInteraction] = 0; + } + if (key.match('cmi.interactions.\\d+.objectives.\\d+.id')) { + const interactionPath = key.match('cmi.interactions.\\d+.objectives'); + const objectivesCounterForInteraction = interactionPath.toString() + '._count'; + // cmi.interactions.n.objectives._count must be incremented after objective creation + // console.log(`Incrementing ${objectivesCounterForInteraction}`); + this.dataModel[objectivesCounterForInteraction.toString()]++; + } + if (key.match('cmi.objectives.\\d+.id')) { + // cmi.objectives._count must be incremented after a new objective is created + // console.log('Incrementing cmi.objectives._count'); + this.dataModel['cmi.objectives._count']++; + } + return 'true'; + } +} diff --git a/src/app/api/models/scorm-player-context.ts b/src/app/api/models/scorm-player-context.ts new file mode 100644 index 000000000..e8eed9b12 --- /dev/null +++ b/src/app/api/models/scorm-player-context.ts @@ -0,0 +1,66 @@ +import {User} from 'src/app/api/models/doubtfire-model'; + +type DataModelState = 'Uninitialized' | 'Initialized' | 'Terminated'; + +type DataModelError = Record; +const CMIErrorCodes: DataModelError = { + 0: 'No Error', + 101: 'General Exception', + 102: 'General Initialization Failure', + 103: 'Already Initialized', + 104: 'Content Instance Terminated', + 111: 'General Termination Failure', + 112: 'Termination Before Initialization', + 113: 'Termination After Termination', + 122: 'Retrieve Data Before Initialization', + 123: 'Retrieve Data After Termination', + 132: 'Store Data Before Initialization', + 133: 'Store Data After Termination', + 142: 'Commit Before Initialization', + 143: 'Commit After Termination', + 201: 'General Argument Error', + 301: 'General Get Failure', + 351: 'General Set Failure', + 391: 'General Commit Failure', + 401: 'Undefined Data Model Element', + 402: 'Unimplemented Data Model Element', + 403: 'Data Model Element Value Not Initialized', + 404: 'Data Model Element Is Read Only', + 405: 'Data Model Element Is Write Only', + 406: 'Data Model Element Type Mismatch', + 407: 'Data Model Element Value Out Of Range', + 408: 'Data Model Dependency Not Established', +}; + +export class ScormPlayerContext { + mode: 'browse' | 'normal' | 'review' | 'preview'; + state: DataModelState; + + private _errorCode: number; + get errorCode() { + return this._errorCode; + } + set errorCode(value: number) { + this._errorCode = value; + } + + getErrorMessage(value: string): string { + return CMIErrorCodes[value]; + } + + projectId: number; + taskDefId: number; + user: User; + + attemptId: number; + learnerName: string; + learnerId: number; + + constructor(user: User) { + this.user = user; + this.learnerId = user.id; + this.learnerName = user.firstName + ' ' + user.lastName; + this.state = 'Uninitialized'; + this.errorCode = 0; + } +} diff --git a/src/app/api/models/task-comment/scorm-comment.ts b/src/app/api/models/task-comment/scorm-comment.ts new file mode 100644 index 000000000..b15a2c50c --- /dev/null +++ b/src/app/api/models/task-comment/scorm-comment.ts @@ -0,0 +1,12 @@ +import {Task, TaskComment, TestAttempt} from '../doubtfire-model'; + +export class ScormComment extends TaskComment { + testAttempt: TestAttempt; + + // UI rendering data + lastInScormSeries: boolean = false; + + constructor(task: Task) { + super(task); + } +} diff --git a/src/app/api/models/task-comment/scorm-extension-comment.ts b/src/app/api/models/task-comment/scorm-extension-comment.ts new file mode 100644 index 000000000..b8aac7909 --- /dev/null +++ b/src/app/api/models/task-comment/scorm-extension-comment.ts @@ -0,0 +1,33 @@ +import {Observable} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {AppInjector} from 'src/app/app-injector'; +import {TaskCommentService} from '../../services/task-comment.service'; +import {TaskComment, Task} from '../doubtfire-model'; + +export class ScormExtensionComment extends TaskComment { + assessed: boolean; + granted: boolean; + dateAssessed: Date; + taskScormExtensions: number; + + constructor(task: Task) { + super(task); + } + + private assessScormExtension(): Observable { + const tcs: TaskCommentService = AppInjector.get(TaskCommentService); + return tcs.assessScormExtension(this).pipe( + tap((tc: TaskComment) => { + const scormExtension: ScormExtensionComment = tc as ScormExtensionComment; + + const task = tc.task; + task.scormExtensions = scormExtension.taskScormExtensions; + }), + ); + } + + public grant(): Observable { + this.granted = true; + return this.assessScormExtension(); + } +} diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 0766f85eb..b84868676 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -31,6 +31,12 @@ export class TaskDefinition extends Entity { groupSet: GroupSet = null; hasTaskSheet: boolean; hasTaskResources: boolean; + scormEnabled: boolean; + hasScormData: boolean; + scormAllowReview: boolean; + scormBypassTest: boolean; + scormTimeDelayEnabled: boolean; + scormAttemptLimit: number = 0; hasTaskAssessmentResources: boolean; isGraded: boolean; maxQualityPts: number; @@ -152,6 +158,20 @@ export class TaskDefinition extends Entity { }`; } + public getScormDataUrl(asAttachment: boolean = false) { + const constants = AppInjector.get(DoubtfireConstants); + return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/scorm_data.json${ + asAttachment ? '?as_attachment=true' : '' + }`; + } + + /** + * Open the SCORM test in a new tab - using preview mode. + */ + public previewScormTest(): void { + window.open(`#/task_def_id/${this.id}/preview-scorm`, '_blank'); + } + public get targetGradeText(): string { return Grade.GRADES[this.targetGrade]; } @@ -176,6 +196,12 @@ export class TaskDefinition extends Entity { }/task_resources`; } + public get scormDataUploadUrl(): string { + return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${ + this.id + }/scorm_data`; + } + public get taskAssessmentResourcesUploadUrl(): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${ this.id @@ -198,6 +224,11 @@ export class TaskDefinition extends Entity { return httpClient.delete(this.taskResourcesUploadUrl).pipe(tap(() => (this.hasTaskResources = false))); } + public deleteScormData(): Observable { + const httpClient = AppInjector.get(HttpClient); + return httpClient.delete(this.scormDataUploadUrl).pipe(tap(() => (this.hasScormData = false))); + } + public deleteTaskAssessmentResources(): Observable { const httpClient = AppInjector.get(HttpClient); return httpClient diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 2aa167adf..f5019d4ac 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -15,6 +15,9 @@ import { TaskCommentService, TaskSimilarity, TaskSimilarityService, + TestAttempt, + TestAttemptService, + ScormComment, } from './doubtfire-model'; import {Grade} from './grade'; import {LOCALE_ID} from '@angular/core'; @@ -30,6 +33,7 @@ export class Task extends Entity { status: TaskStatusEnum = 'not_started'; dueDate: Date; extensions: number; + scormExtensions: number; submissionDate: Date; completionDate: Date; timesAssessed: number; @@ -53,6 +57,7 @@ export class Task extends Entity { public readonly commentCache: EntityCache = new EntityCache(); public readonly similarityCache: EntityCache = new EntityCache(); + public readonly testAttemptCache: EntityCache = new EntityCache(); private _unit: Unit; @@ -381,6 +386,14 @@ export class Task extends Entity { if (comments[i].replyToId) { comments[i].originalComment = comments.find((tc) => tc.id === comments[i].replyToId); } + + // Scorm series + if (comments[i].commentType === 'scorm') { + comments[i].firstInSeries = i === 0 || comments[i - 1].commentType !== 'scorm'; + (comments[i] as ScormComment).lastInScormSeries = + i + 1 === comments.length || comments[i + 1]?.commentType !== 'scorm'; + if (!comments[i].firstInSeries) comments[i].shouldShowTimestamp = false; + } } comments[comments.length - 1].shouldShowAvatar = true; @@ -396,7 +409,7 @@ export class Task extends Entity { public taskKeyToIdString(): string { const key = this.taskKey(); - return `task-key-${key.studentId}-${key.taskDefAbbr}`.replace(/[.#]/g, '-'); + return `task-key-${key.studentId}-${key.taskDefAbbr}`.replace(/[.# ]/g, '-'); } public get similaritiesDetected(): boolean { @@ -507,6 +520,33 @@ export class Task extends Entity { ); } + public get scormEnabled(): boolean { + return this.definition.scormEnabled && this.definition.hasScormData; + } + + public get scormPassed(): boolean { + if (this.latestCompletedTestAttempt) { + return this.latestCompletedTestAttempt.successStatus; + } + return false; + } + + /** + * Launch the SCORM player for this task in a new window. + */ + public launchScormPlayer(): void { + const url = `#/projects/${this.project.id}/task_def_id/${this.taskDefId}/scorm-player/normal`; + window.open(url, '_blank'); + } + + public get isReadyForUpload(): boolean { + return !this.scormEnabled || this.definition.scormBypassTest || this.scormPassed; + } + + public get latestCompletedTestAttempt(): TestAttempt { + return this.testAttemptCache.currentValues.find((attempt) => attempt.terminated); + } + public submissionUrl(asAttachment: boolean = false): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/projects/${ this.project.id @@ -659,12 +699,15 @@ export class Task extends Entity { public triggerTransition(status: TaskStatusEnum): void { if (this.status === status) return; + const alerts: AlertService = AppInjector.get(AlertService); const requiresFileUpload = ['ready_for_feedback', 'need_help'].includes(status) && this.requiresFileUpload(); - if (requiresFileUpload) { + if (requiresFileUpload && this.isReadyForUpload) { this.presentTaskSubmissionModal(status); + } else if (requiresFileUpload && !this.isReadyForUpload) { + alerts.error('Complete Knowledge Check first to submit files', 6000); } else { this.updateTaskStatus(status); } @@ -764,4 +807,21 @@ export class Task extends Entity { }, ); } + + /** + * Fetch the SCORM test attempts for this task. + */ + public fetchTestAttempts(): Observable { + const testAttemptService: TestAttemptService = AppInjector.get(TestAttemptService); + return testAttemptService.query( + { + project_id: this.project.id, + task_def_id: this.taskDefId, + }, + { + cache: this.testAttemptCache, + constructorParams: this, + }, + ); + } } diff --git a/src/app/api/models/test-attempt.ts b/src/app/api/models/test-attempt.ts new file mode 100644 index 000000000..4497028a7 --- /dev/null +++ b/src/app/api/models/test-attempt.ts @@ -0,0 +1,28 @@ +import {Entity} from 'ngx-entity-service'; +import {Task} from './doubtfire-model'; + +export class TestAttempt extends Entity { + id: number; + terminated: boolean; + completionStatus: boolean; + successStatus: boolean; + scoreScaled: number; + cmiDatamodel: string; + attemptedTime: Date; + + task: Task; + + constructor(task: Task) { + super(); + this.task = task; + } + + /** + * Open a test attempt window in review mode + */ + public review() { + const url = `#/projects/${this.task.project.id}/task_def_id/${this.task.taskDefId}/scorm-player/review/${this.id}`; + + window.open(url, '_blank'); + } +} diff --git a/src/app/api/services/authentication.service.ts b/src/app/api/services/authentication.service.ts index b2cb12f40..a83637b23 100644 --- a/src/app/api/services/authentication.service.ts +++ b/src/app/api/services/authentication.service.ts @@ -191,4 +191,12 @@ export class AuthenticationService { setTimeout(() => this.router.stateService.go('timeout'), 500); } } + + public getScormToken(): Observable { + return this.httpClient.get(this.AUTH_URL + '/scorm').pipe( + map((response) => { + return response['scorm_auth_token']; + }), + ); + } } diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts new file mode 100644 index 000000000..0f1af9889 --- /dev/null +++ b/src/app/api/services/scorm-adapter.service.ts @@ -0,0 +1,283 @@ +import {Injectable} from '@angular/core'; +import {UserService} from './user.service'; +import API_URL from 'src/app/config/constants/apiURL'; +import {ScormDataModel, ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; + +@Injectable({ + providedIn: 'root', +}) +export class ScormAdapterService { + private dataModel: ScormDataModel; + private context: ScormPlayerContext; + private xhr: XMLHttpRequest; + + constructor(private userService: UserService) { + this.dataModel = new ScormDataModel(); + this.context = new ScormPlayerContext(this.userService.currentUser); + this.xhr = new XMLHttpRequest(); + } + + set projectId(projectId: number) { + this.context.projectId = projectId; + } + + set taskDefId(taskDefId: number) { + this.context.taskDefId = taskDefId; + } + + set mode(mode: 'browse' | 'normal' | 'review' | 'preview') { + this.context.mode = mode; + } + + set testAttemptId(testAttemptId: number) { + this.context.attemptId = testAttemptId; + } + + get state() { + return this.context.state; + } + + destroy() { + this.dataModel = new ScormDataModel(); + this.context.state = 'Uninitialized'; + } + + Initialize(): string { + // console.log('API_1484_11: Initialize'); + + // TODO: error handling and reporting + switch (this.context.state) { + case 'Initialized': + this.context.errorCode = 103; + // console.log('Already Initialized'); + break; + case 'Terminated': + this.context.errorCode = 104; + // console.log('Content Instance Terminated'); + break; + } + + if (this.context.mode === 'review') { + this.xhr.open('GET', `${API_URL}/test_attempts/${this.context.attemptId}/review`, false); + this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken); + this.xhr.setRequestHeader('Username', this.context.user.username); + + this.xhr.send(); + // console.log(this.xhr.responseText); + + const reviewSession = JSON.parse(this.xhr.responseText); + this.dataModel.restore(reviewSession.cmi_datamodel); + // console.log(this.dataModel.dump()); + + this.context.state = 'Initialized'; + return 'true'; + } + + // TODO: move this part into the player component + this.xhr.open( + 'GET', + `${API_URL}/projects/${this.context.projectId}/task_def_id/${this.context.taskDefId}/test_attempts/latest`, + false, + ); + this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken); + this.xhr.setRequestHeader('Username', this.context.user.username); + + let noTestFound = false; + let startNewTest = false; + + this.xhr.onload = () => { + if (this.xhr.status == 404) { + noTestFound = true; + } + + // if (this.xhr.status >= 200 && this.xhr.status < 400) { + // console.log('Retrieved the latest attempt.'); + // } else if (this.xhr.status == 404) { + // console.log('Not found.'); + // noTestFound = true; + // } else { + // console.error('Error saving DataModel:', this.xhr.responseText); + // } + }; + + this.xhr.send(); + // console.log(this.xhr.responseText); + + if (!noTestFound) { + const latestSession = JSON.parse(this.xhr.responseText); + // console.log('Latest exam session:', latestSession); + this.context.attemptId = latestSession.id; + if (latestSession.completion_status) { + startNewTest = true; + } + } else { + startNewTest = true; + } + + if (!startNewTest) { + this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, false); + this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken); + this.xhr.setRequestHeader('Username', this.context.user.username); + this.xhr.send(); + // console.log(this.xhr.responseText); + + const currentSession = JSON.parse(this.xhr.responseText); + // console.log('Current exam session:', currentSession); + this.context.attemptId = currentSession.id; + this.dataModel.restore(currentSession.cmi_datamodel); + // console.log(this.dataModel.dump()); + } else { + this.xhr.open( + 'POST', + `${API_URL}/projects/${this.context.projectId}/task_def_id/${this.context.taskDefId}/test_attempts`, + false, + ); + this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken); + this.xhr.setRequestHeader('Username', this.context.user.username); + this.xhr.send(); + // console.log(this.xhr.responseText); + + const currentSession = JSON.parse(this.xhr.responseText); + // console.log('Current exam session:', currentSession); + this.context.attemptId = currentSession.id; + this.dataModel.restore(currentSession.cmi_datamodel); + // console.log(this.dataModel.dump()); + } + + this.context.state = 'Initialized'; + return 'true'; + } + + Terminate(): string { + // console.log('API_1484_11: Terminate'); + + // TODO: error handling and reporting + switch (this.context.state) { + case 'Uninitialized': + this.context.errorCode = 112; + // console.log('Termination Before Initialization'); + break; + case 'Terminated': + this.context.errorCode = 113; + // console.log('Termination After Termination'); + break; + } + + this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, false); + this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken); + this.xhr.setRequestHeader('Username', this.context.user.username); + this.xhr.setRequestHeader('Content-Type', 'application/json'); + const requestData = { + cmi_datamodel: JSON.stringify(this.dataModel.dump()), + terminated: true, + }; + this.xhr.send(JSON.stringify(requestData)); + // console.log(this.xhr.responseText); + + // all done, clearing datamodel and setting state to terminated + this.dataModel = new ScormDataModel(); + this.context.state = 'Terminated'; + return 'true'; + } + + GetValue(element: string): string { + const value = this.dataModel.get(element); + + // TODO: error reporting + // TODO: can't get until init is done + switch (this.context.state) { + case 'Uninitialized': + this.context.errorCode = 122; + // console.log('Retrieve Data Before Initialization'); + break; + case 'Terminated': + this.context.errorCode = 123; + // console.log('Retrieve Data After Termination'); + break; + } + + // console.log(`API_1484_11: GetValue:`, element, value); + return value; + } + + SetValue(element: string, value: any): string { + // console.log(`API_1484_11: SetValue:`, element, value); + + // TODO: error reporting + // TODO: can't set until init is done + switch (this.context.state) { + case 'Uninitialized': + this.context.errorCode = 132; + // console.log('Store Data Before Initialization'); + break; + case 'Terminated': + this.context.errorCode = 133; + // console.log('Store Data After Termination'); + break; + } + + this.dataModel.set(element, value); + return 'true'; + } + + Commit(): string { + // console.log('API_1484_11: Commit'); + + // TODO: error reporting + // TODO: can't commit until init is done + switch (this.context.state) { + case 'Uninitialized': + this.context.errorCode = 142; + // console.log('Commit Before Initialization'); + break; + case 'Terminated': + this.context.errorCode = 143; + // console.log('Commit After Termination'); + break; + } + + this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, true); + this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken); + this.xhr.setRequestHeader('Username', this.context.user.username); + this.xhr.setRequestHeader('Content-Type', 'application/json'); + const requestData = { + cmi_datamodel: JSON.stringify(this.dataModel.dump()), + }; + + // this.xhr.onload = () => { + // if (this.xhr.status >= 200 && this.xhr.status < 400) { + // console.log('DataModel saved successfully.'); + // } else { + // console.error('Error saving DataModel:', this.xhr.responseText); + // } + // }; + + // this.xhr.onerror = () => { + // console.error('Request failed.'); + // }; + + this.xhr.send(JSON.stringify(requestData)); + this.context.errorCode = 0; + return 'true'; + } + + GetLastError(): string { + const lastError = this.context.errorCode.toString(); + // if (lastError !== '0') { + // console.log(`API_1484_11: GetLastError: ${lastError}`); + // } + return lastError; + } + + GetErrorString(errorCode: string): string { + const errorString = this.context.getErrorMessage(errorCode); + // console.log(`API_1484_11: GetErrorString:`, errorCode, errorString); + return errorString; + } + + GetDiagnostic(errorCode: string): string { + // TODO: implement this + // console.log(`API_1484_11: GetDiagnostic:`, errorCode); + return 'GetDiagnostic is currently not implemented'; + } +} diff --git a/src/app/api/services/task-comment.service.ts b/src/app/api/services/task-comment.service.ts index 7fdf94bc8..4f178ce77 100644 --- a/src/app/api/services/task-comment.service.ts +++ b/src/app/api/services/task-comment.service.ts @@ -1,4 +1,4 @@ -import { Task, TaskComment, UserService } from 'src/app/api/models/doubtfire-model'; +import { ScormComment, Task, TaskComment, TestAttemptService, UserService } from 'src/app/api/models/doubtfire-model'; import { EventEmitter, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @@ -11,6 +11,7 @@ import API_URL from 'src/app/config/constants/apiURL'; import { EmojiService } from 'src/app/common/services/emoji.service'; import { MappingFunctions } from './mapping-fn'; import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service'; +import { ScormExtensionComment } from '../models/task-comment/scorm-extension-comment'; @Injectable() export class TaskCommentService extends CachedEntityService { @@ -22,6 +23,10 @@ export class TaskCommentService extends CachedEntityService { 'projects/:projectId:/task_def_id/:taskDefinitionId:/assess_extension/:id:'; private readonly requestExtensionEndpointFormat = 'projects/:projectId:/task_def_id/:taskDefinitionId:/request_extension'; + private readonly scormExtensionGrantEndpointFormat = + 'projects/:projectId:/task_def_id/:taskDefinitionId:/assess_scorm_extension/:id:'; + private readonly scormRequestExtensionEndpointFormat = + 'projects/:projectId:/task_def_id/:taskDefinitionId:/request_scorm_extension'; private readonly discussionCommentReplyEndpointFormat = "/projects/:project_id:/task_def_id/:task_definition_id:/comments/:task_comment_id:/discussion_comment/reply"; private readonly getDiscussionCommentPromptEndpointFormat = "/projects/:project_id:/task_def_id/:task_definition_id:/comments/:task_comment_id:/discussion_comment/prompt_number/:prompt_number:"; @@ -32,7 +37,8 @@ export class TaskCommentService extends CachedEntityService { httpClient: HttpClient, private emojiService: EmojiService, private userService: UserService, - private downloader: FileDownloaderService + private downloader: FileDownloaderService, + private testAttemptService: TestAttemptService, ) { super(httpClient, API_URL); @@ -85,7 +91,26 @@ export class TaskCommentService extends CachedEntityService { 'status', 'numberOfPrompts', 'timeDiscussionComplete', - 'timeDiscussionStarted' + 'timeDiscussionStarted', + + // Scorm Comments + { + keys: 'testAttempt', + toEntityFn: (data: object, key: string, comment: ScormComment) => { + const testAttempt = this.testAttemptService.cache.getOrCreate( + data[key].id, + testAttemptService, + data[key], + { + constructorParams: comment.task, + }, + ); + return testAttempt; + }, + }, + + // Scorm Extension Comments + ['taskScormExtensions', 'scorm_extensions'] ); this.mapping.addJsonKey( @@ -103,6 +128,10 @@ export class TaskCommentService extends CachedEntityService { return new DiscussionComment(other); case 'extension': return new ExtensionComment(other); + case 'scorm': + return new ScormComment(other); + case 'scorm_extension': + return new ScormExtensionComment(other); default: return new TaskComment(other); } @@ -145,7 +174,7 @@ export class TaskCommentService extends CachedEntityService { const opts: RequestOptions = { endpointFormat: this.commentEndpointFormat }; // Based on the comment type - add to the body and configure the end point - if (commentType === 'text') { + if (commentType === 'text' || commentType === 'scorm') { body.append('comment', data); } else if (commentType === 'discussion') { opts.endpointFormat = this.discussionEndpointFormat; @@ -202,6 +231,39 @@ export class TaskCommentService extends CachedEntityService { ); } + public assessScormExtension(extension: ScormExtensionComment): Observable { + const opts: RequestOptions = { + endpointFormat: this.scormExtensionGrantEndpointFormat, + entity: extension, + }; + + return super.update( + { + id: extension.id, + projectId: extension.project.id, + taskDefinitionId: extension.task.definition.id, + }, + opts, + ); + } + + public requestScormExtension(reason: string, task: any): Observable { + const opts: RequestOptions = { + endpointFormat: this.scormRequestExtensionEndpointFormat, + body: { + comment: reason, + }, + cache: task.commentCache, + }; + return super.create( + { + projectId: task.project.id, + taskDefinitionId: task.definition.id, + }, + opts, + ); + } + public postDiscussionReply(comment: TaskComment, replyAudio: Blob): Observable{ const form = new FormData(); const pathIds = { diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index b2499251e..0914929fb 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -99,6 +99,12 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskSheet', 'hasTaskResources', 'hasTaskAssessmentResources', + 'scormEnabled', + 'hasScormData', + 'scormAllowReview', + 'scormBypassTest', + 'scormTimeDelayEnabled', + 'scormAttemptLimit', 'isGraded', 'maxQualityPts', 'overseerImageId', @@ -109,7 +115,8 @@ export class TaskDefinitionService extends CachedEntityService { 'id', 'hasTaskSheet', 'hasTaskResources', - 'hasTaskAssessmentResources' + 'hasTaskAssessmentResources', + 'hasScormData' ); } @@ -134,4 +141,10 @@ export class TaskDefinitionService extends CachedEntityService { formData.append('file', file); return AppInjector.get(HttpClient).post(taskDefinition.taskAssessmentResourcesUploadUrl, formData); } + + public uploadScormData(taskDefinition: TaskDefinition, file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return AppInjector.get(HttpClient).post(taskDefinition.scormDataUploadUrl, formData); + } } diff --git a/src/app/api/services/task.service.ts b/src/app/api/services/task.service.ts index 344494c0e..737fe1ea4 100644 --- a/src/app/api/services/task.service.ts +++ b/src/app/api/services/task.service.ts @@ -46,6 +46,7 @@ export class TaskService extends CachedEntityService { toEntityFn: MappingFunctions.mapDateToEndOfDay, }, 'extensions', + 'scormExtensions', { keys: 'submissionDate', toEntityFn: MappingFunctions.mapDateToDay, diff --git a/src/app/api/services/test-attempt.service.ts b/src/app/api/services/test-attempt.service.ts new file mode 100644 index 000000000..3e2c46161 --- /dev/null +++ b/src/app/api/services/test-attempt.service.ts @@ -0,0 +1,88 @@ +import {Injectable} from '@angular/core'; +import {CachedEntityService} from 'ngx-entity-service'; +import API_URL from 'src/app/config/constants/apiURL'; +import {Task, TestAttempt} from 'src/app/api/models/doubtfire-model'; +import {Observable} from 'rxjs'; +import {AppInjector} from 'src/app/app-injector'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {HttpClient} from '@angular/common/http'; + +@Injectable() +export class TestAttemptService extends CachedEntityService { + protected readonly endpointFormat = + 'projects/:project_id:/task_def_id/:task_def_id:/test_attempts'; + protected readonly latestCompletedEndpoint = + this.endpointFormat + '/latest?completed=:completed:'; + + constructor(httpClient: HttpClient) { + super(httpClient, API_URL); + + this.mapping.addKeys( + 'id', + 'terminated', + 'completionStatus', + 'successStatus', + 'scoreScaled', + 'cmiDatamodel', + 'attemptedTime', + ); + } + + public override createInstanceFrom(_json: object, constructorParams: Task): TestAttempt { + return new TestAttempt(constructorParams); + } + + public getLatestCompletedAttempt(task: Task): Observable { + return this.get( + { + project_id: task.project.id, + task_def_id: task.taskDefId, + completed: true, + }, + { + endpointFormat: this.latestCompletedEndpoint, + constructorParams: task, + }, + ); + } + + public overrideSuccessStatus(testAttemptId: number, successStatus: boolean): void { + const http = AppInjector.get(HttpClient); + + http + .patch( + `${AppInjector.get(DoubtfireConstants).API_URL}/test_attempts/${testAttemptId}?success_status=${successStatus}`, + {}, + ) + .subscribe({ + next: (_data) => { + (AppInjector.get(AlertService) as AlertService).success( + 'Attempt pass status successfully overridden.', + 6000, + ); + }, + error: (message) => { + (AppInjector.get(AlertService) as AlertService).error(message, 6000); + }, + }); + } + + public deleteAttempt(testAttemptId: number): void { + const http = AppInjector.get(HttpClient); + + http + .delete(`${AppInjector.get(DoubtfireConstants).API_URL}/test_attempts/${testAttemptId}`, {}) + .subscribe({ + next: (_data) => { + (AppInjector.get(AlertService) as AlertService).success( + 'Attempt successfully deleted.', + 6000, + ); + }, + error: (message) => { + (AppInjector.get(AlertService) as AlertService).error(message, 6000); + }, + }); + } +} diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html new file mode 100644 index 000000000..f39a9580b --- /dev/null +++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html @@ -0,0 +1,45 @@ +

Extra attempt request

+
+

+ Please explain why you require an extra attempt for this knowledge check, the teaching team will + assess the request shortly. +

+ + + Reason + + {{ extensionData.controls.extensionReason.value.length }} / {{ reasonMaxLength }} + @if (extensionData.controls.extensionReason.hasError('required')) { + You must enter a reason + } + @if (extensionData.controls.extensionReason.hasError('minlength')) { + The reason must be at least {{ reasonMinLength }} characters long + } + @if (extensionData.controls.extensionReason.hasError('maxlength')) { + The reason must be less than {{ reasonMaxLength }} characters long + } + +
+ +
+ + +
diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts new file mode 100644 index 000000000..c7f583382 --- /dev/null +++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts @@ -0,0 +1,67 @@ +import {Component, Inject, LOCALE_ID} from '@angular/core'; +import {MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {TaskComment, TaskCommentService, Task} from 'src/app/api/models/doubtfire-model'; +import {AppInjector} from 'src/app/app-injector'; +import {FormControl, Validators, FormGroup, FormGroupDirective, NgForm} from '@angular/forms'; +import {ErrorStateMatcher} from '@angular/material/core'; +import {AlertService} from '../../services/alert.service'; + +/** Error when invalid control is dirty, touched, or submitted. */ +export class ReasonErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = form && form.submitted; + return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)); + } +} + +@Component({ + selector: 'f-scorm-extension-modal', + templateUrl: './scorm-extension-modal.component.html', +}) +export class ScormExtensionModalComponent { + protected reasonMinLength: number = 15; + protected reasonMaxLength: number = 256; + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: {task: Task; afterApplication?: () => void}, + private alerts: AlertService, + ) {} + + matcher = new ReasonErrorStateMatcher(); + currentLocale = AppInjector.get(LOCALE_ID); + extensionData = new FormGroup({ + extensionReason: new FormControl('', [ + Validators.required, + Validators.minLength(this.reasonMinLength), + Validators.maxLength(this.reasonMaxLength), + ]), + }); + + private scrollCommentsDown(): void { + setTimeout(() => { + const objDiv = document.querySelector('div.comments-body'); + // let wrappedResult = angular.element(objDiv); + objDiv.scrollTop = objDiv.scrollHeight; + }, 50); + } + + submitApplication() { + const tcs: TaskCommentService = AppInjector.get(TaskCommentService); + tcs + .requestScormExtension(this.extensionData.controls.extensionReason.value, this.data.task) + .subscribe({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + next: ((tc: TaskComment) => { + this.alerts.success('Extra attempt requested.', 2000); + this.scrollCommentsDown(); + if (typeof this.data.afterApplication === 'function') { + this.data.afterApplication(); + } + }).bind(this), + error: ((response: never) => { + this.alerts.error('Error requesting extra attempt ' + response); + console.log(response); + }).bind(this), + }); + } +} diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service.ts b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service.ts new file mode 100644 index 000000000..7e9b46f8e --- /dev/null +++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service.ts @@ -0,0 +1,26 @@ +import {Injectable} from '@angular/core'; +import {Task} from 'src/app/api/models/task'; +import {MatDialogRef, MatDialog} from '@angular/material/dialog'; +import {ScormExtensionModalComponent} from './scorm-extension-modal.component'; + +@Injectable({ + providedIn: 'root', +}) +export class ScormExtensionModalService { + constructor(public dialog: MatDialog) {} + + public show(task: Task, afterApplication?: any) { + let dialogRef: MatDialogRef; + + dialogRef = this.dialog.open(ScormExtensionModalComponent, { + data: { + task, + afterApplication, + }, + }); + + dialogRef.afterOpened().subscribe((result: any) => {}); + + dialogRef.afterClosed().subscribe((result: any) => {}); + } +} diff --git a/src/app/common/scorm-player/scorm-player.component.html b/src/app/common/scorm-player/scorm-player.component.html new file mode 100644 index 000000000..990a9ef4a --- /dev/null +++ b/src/app/common/scorm-player/scorm-player.component.html @@ -0,0 +1 @@ + diff --git a/src/app/common/scorm-player/scorm-player.component.scss b/src/app/common/scorm-player/scorm-player.component.scss new file mode 100644 index 000000000..f011d35ae --- /dev/null +++ b/src/app/common/scorm-player/scorm-player.component.scss @@ -0,0 +1,13 @@ +f-scorm-player { + position: relative; + height: 100vh; + width: 100vw; +} + +iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/src/app/common/scorm-player/scorm-player.component.spec.ts b/src/app/common/scorm-player/scorm-player.component.spec.ts new file mode 100644 index 000000000..7980df7c3 --- /dev/null +++ b/src/app/common/scorm-player/scorm-player.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ScormPlayerComponent } from './scorm-player.component'; + +describe('ScormPlayerComponent', () => { + let component: ScormPlayerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ScormPlayerComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ScormPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts new file mode 100644 index 000000000..6c2bdadd2 --- /dev/null +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -0,0 +1,108 @@ +import {Component, OnInit, Input, HostListener} from '@angular/core'; +import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; +import { + AuthenticationService, + ScormPlayerContext, + UserService, +} from 'src/app/api/models/doubtfire-model'; +import {ScormAdapterService} from 'src/app/api/services/scorm-adapter.service'; +import {AppInjector} from 'src/app/app-injector'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; + +declare global { + interface Window { + API_1484_11: { + Initialize: () => void; + Terminate: () => void; + GetValue: (element: string) => string; + SetValue: (element: string, value: string) => void; + Commit: () => void; + GetLastError: () => string; + GetErrorString: (errorCode: string) => string; + GetDiagnostic: (errorCode: string) => string; + }; + } +} + +@Component({ + selector: 'f-scorm-player', + templateUrl: './scorm-player.component.html', + styleUrls: ['./scorm-player.component.scss'], +}) +export class ScormPlayerComponent implements OnInit { + context: ScormPlayerContext; + + @Input() + projectId: number; + + @Input() + taskDefId: number; + + @Input() + mode: 'browse' | 'normal' | 'review' | 'preview'; + + @Input() + testAttemptId: number; + + iframeSrc: SafeResourceUrl; + + constructor( + private globalState: GlobalStateService, + private scormAdapter: ScormAdapterService, + private userService: UserService, + private authService: AuthenticationService, + private sanitizer: DomSanitizer, + ) {} + + ngOnInit(): void { + this.globalState.setView(ViewType.OTHER); + this.globalState.hideHeader(); + this.authService.getScormToken().subscribe((value: string) => this.setupScorm(value)); + } + + private setupScorm(token: string): void { + this.scormAdapter.mode = this.mode; + if (this.mode === 'normal') { + this.scormAdapter.projectId = this.projectId; + this.scormAdapter.taskDefId = this.taskDefId; + } else if (this.mode === 'review') { + this.scormAdapter.testAttemptId = this.testAttemptId; + } + + if (this.mode !== 'preview') { + window.API_1484_11 = { + Initialize: () => this.scormAdapter.Initialize(), + Terminate: () => this.scormAdapter.Terminate(), + GetValue: (element: string) => this.scormAdapter.GetValue(element), + SetValue: (element: string, value: string) => this.scormAdapter.SetValue(element, value), + Commit: () => this.scormAdapter.Commit(), + GetLastError: () => this.scormAdapter.GetLastError(), + GetErrorString: (errorCode: string) => this.scormAdapter.GetErrorString(errorCode), + GetDiagnostic: (errorCode: string) => this.scormAdapter.GetDiagnostic(errorCode), + }; + } else { + window.API_1484_11 = undefined; + } + + // Encode . as %2e to avoid issue with grape treating the username as a file extension + const username = this.userService.currentUser.username.replaceAll('.', '%2e'); + + this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl( + `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.taskDefId}/${username}/${token}/index.html`, + ); + } + + @HostListener('window:beforeunload', ['$event']) + beforeUnload(_event: Event): void { + if (this.scormAdapter.state == 'Initialized') { + // console.log('SCORM player closing during an initialized session, commiting DataModel'); + this.scormAdapter.Commit(); + } + } + + @HostListener('window:unload', ['$event']) + onUnload(_event: Event): void { + this.scormAdapter.destroy(); + } +} diff --git a/src/app/config/constants/apiURL.ts b/src/app/config/constants/apiURL.ts index 737aad40b..a4d39f05f 100644 --- a/src/app/config/constants/apiURL.ts +++ b/src/app/config/constants/apiURL.ts @@ -1,4 +1,4 @@ let API_URL: string; -API_URL = `${window.location.protocol}//${window.location.hostname}:3000/api`; +API_URL = `${window.location.protocol}//${window.location.hostname}:4200/api`; export default API_URL; diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 69acaf196..991ac397b 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -208,6 +208,7 @@ import {TaskDefinitionUploadComponent} from './units/states/edit/directives/unit import {TaskDefinitionOptionsComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component'; import {TaskDefinitionResourcesComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component'; import {TaskDefinitionOverseerComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component'; +import {TaskDefinitionScormComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component'; import {UnitAnalyticsComponent} from './units/states/analytics/unit-analytics-route.component'; import {FileDropComponent} from './common/file-drop/file-drop.component'; import {UnitTaskEditorComponent} from './units/states/edit/directives/unit-tasks-editor/unit-task-editor.component'; @@ -229,6 +230,13 @@ import {FTaskSheetViewComponent} from './units/states/tasks/viewer/directives/f- import {TasksViewerComponent} from './units/states/tasks/tasks-viewer/tasks-viewer.component'; import {UnitCodeComponent} from './common/unit-code/unit-code.component'; import {GradeService} from './common/services/grade.service'; +import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; +import {ScormAdapterService} from './api/services/scorm-adapter.service'; +import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/scorm-comment.component'; +import {TaskScormCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component'; +import {TestAttemptService} from './api/services/test-attempt.service'; +import {ScormExtensionCommentComponent} from './tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component'; +import {ScormExtensionModalComponent} from './common/modals/scorm-extension-modal/scorm-extension-modal.component'; // See https://stackoverflow.com/questions/55721254/how-to-change-mat-datepicker-date-format-to-dd-mm-yyyy-in-simplest-way/58189036#58189036 const MY_DATE_FORMAT = { @@ -280,6 +288,7 @@ const MY_DATE_FORMAT = { TaskDefinitionOptionsComponent, TaskDefinitionResourcesComponent, TaskDefinitionOverseerComponent, + TaskDefinitionScormComponent, UnitAnalyticsComponent, StudentTutorialSelectComponent, StudentCampusSelectComponent, @@ -343,6 +352,11 @@ const MY_DATE_FORMAT = { FUsersComponent, FTaskBadgeComponent, FUnitsComponent, + ScormPlayerComponent, + ScormCommentComponent, + TaskScormCardComponent, + ScormExtensionCommentComponent, + ScormExtensionModalComponent, ], // Services we provide providers: [ @@ -416,6 +430,8 @@ const MY_DATE_FORMAT = { TasksForInboxSearchPipe, IsActiveUnitRole, CreateNewUnitModal, + ScormAdapterService, + TestAttemptService, provideLottieOptions({ player: () => player, }), diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index 304e0bf74..e14fdbb54 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -224,6 +224,8 @@ import {FUnitsComponent} from './admin/states/f-units/f-units.component'; import {MarkedPipe} from './common/pipes/marked.pipe'; import {AlertService} from './common/services/alert.service'; import {GradeService} from './common/services/grade.service'; +import {TaskScormCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component'; + export const DoubtfireAngularJSModule = angular.module('doubtfire', [ 'doubtfire.config', 'doubtfire.sessions', @@ -358,6 +360,10 @@ DoubtfireAngularJSModule.directive( 'activityTypeList', downgradeComponent({component: ActivityTypeListComponent}), ); +DoubtfireAngularJSModule.directive( + 'fTaskScormCard', + downgradeComponent({component: TaskScormCardComponent}), +); DoubtfireAngularJSModule.directive( 'fTaskStatusCard', downgradeComponent({component: TaskStatusCardComponent}), diff --git a/src/app/doubtfire.states.ts b/src/app/doubtfire.states.ts index c578d87b2..86b9f2b4d 100644 --- a/src/app/doubtfire.states.ts +++ b/src/app/doubtfire.states.ts @@ -8,6 +8,7 @@ import {TeachingPeriodListComponent} from './admin/states/teaching-periods/teach import {AcceptEulaComponent} from './eula/accept-eula/accept-eula.component'; import {FUsersComponent} from './admin/states/f-users/f-users.component'; import {FUnitsComponent} from './admin/states/f-units/f-units.component'; +import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; /* * Use this file to store any states that are sourced by angular components. @@ -269,7 +270,6 @@ const AdministerUnits: NgHybridStateDeclaration = { }, }; - const ViewAllUnits: NgHybridStateDeclaration = { name: 'view-all-units', url: '/view-all-units', @@ -291,6 +291,105 @@ const ViewAllUnits: NgHybridStateDeclaration = { }, }; +/** + * Define the SCORM Player state. + */ +const ScormPlayerNormalState: NgHybridStateDeclaration = { + name: 'scorm-player-normal', + url: '/projects/:project_id/task_def_id/:task_definition_id/scorm-player/normal', + resolve: { + projectId: [ + '$stateParams', + function ($stateParams: {project_id: number}) { + return $stateParams.project_id; + } + ], + taskDefId: [ + '$stateParams', + function ($stateParams: {task_definition_id: number}) { + return $stateParams.task_definition_id; + } + ], + mode: function () { + return 'normal'; + }, + }, + views: { + main: { + component: ScormPlayerComponent, + }, + }, + data: { + pageTitle: 'Knowledge Check', + roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin'], + }, +}; + +/** + * Define the SCORM Player state. + */ +const ScormPlayerStudentReviewState: NgHybridStateDeclaration = { + name: 'scorm-player-student-review', + url: '/projects/:project_id/task_def_id/:task_definition_id/scorm-player/review/:test_attempt_id', + resolve: { + projectId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.project_id; + } + ], + taskDefId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.task_definition_id; + } + ], + testAttemptId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.test_attempt_id; + } + ], + mode: function () { + return 'review'; + }, + }, + views: { + main: { + component: ScormPlayerComponent, + }, + }, + data: { + pageTitle: 'Review Knowledge Check', + roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin'], + }, +}; + +const ScormPlayerReviewState: NgHybridStateDeclaration = { + name: 'scorm-preview', + url: '/task_def_id/:task_definition_id/preview-scorm', + resolve: { + taskDefId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.task_definition_id; + } + ], + mode: function () { + return 'preview'; + }, + }, + views: { + main: { + component: ScormPlayerComponent, + }, + }, + data: { + pageTitle: 'Preview Scorm Test', + roleWhitelist: ['Tutor', 'Convenor', 'Admin'], + }, +}; + /** * Export the list of states we have created in angular */ @@ -306,4 +405,7 @@ export const doubtfireStates = [ ViewAllProjectsState, ViewAllUnits, AdministerUnits, + ScormPlayerNormalState, + ScormPlayerReviewState, + ScormPlayerStudentReviewState, ]; diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee index a504f3f1c..b4212fa09 100644 --- a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee +++ b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee @@ -44,7 +44,7 @@ angular.module('doubtfire.projects.states.dashboard.directives.student-task-list $scope.taskData.onSelectedTaskChange?(task) scrollToTaskInList(task) if task? scrollToTaskInList = (task) -> - taskEl = document.querySelector("student-task-list-#{task.taskKeyToIdString()}") + taskEl = document.querySelector("##{task.taskKeyToIdString()}") return unless taskEl? funcName = if taskEl.scrollIntoViewIfNeeded? then 'scrollIntoViewIfNeeded' else if taskEl.scrollIntoView? then 'scrollIntoView' return unless funcName? diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html new file mode 100644 index 000000000..ac1221f87 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -0,0 +1,92 @@ +@if (isPassed) { + + @if (this.task.latestCompletedTestAttempt.scoreScaled === 1) { + + check + Knowledge Check Passed Without Mistakes + + } + @if (this.task.latestCompletedTestAttempt.scoreScaled !== 1) { + + check + Knowledge Check Passed + + } + +

+ You have successfully completed this knowledge check. You can now proceed to submitting task + files. +

+
+ + + +
+} +@if (!isPassed) { + + @if (isPassed === false) { + + close + Knowledge Check Unsuccessful + + } + @if (isPassed === undefined) { + + Knowledge Check + + } + +

You have to successfully pass this knowledge check to complete the task.

+

+ You have {{ attemptsLeft !== undefined ? attemptsLeft : 'unlimited' }} attempts left to + complete this test. +

+

+ There will be an increased time delay between test attempts. First 2 attempts will not have + a time delay in between. +

+
+ + + + + +
+} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts new file mode 100644 index 000000000..377b89c2f --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts @@ -0,0 +1,59 @@ +import {Component, Input, OnChanges, SimpleChanges} from '@angular/core'; +import {Task, User, UserService} from 'src/app/api/models/doubtfire-model'; +import {ScormExtensionModalService} from 'src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service'; + +@Component({ + selector: 'f-task-scorm-card', + templateUrl: './task-scorm-card.component.html', + styleUrls: ['./task-scorm-card.component.scss'], +}) +export class TaskScormCardComponent implements OnChanges { + @Input() task: Task; + attemptsLeft: number; + isPassed: boolean; + user: User; + + constructor( + private extensions: ScormExtensionModalService, + private userService: UserService, + ) { + this.user = this.userService.currentUser; + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.task && changes.task.currentValue && changes.task.currentValue.scormEnabled) { + this.attemptsLeft = undefined; + this.isPassed = undefined; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + this.task?.fetchTestAttempts().subscribe((_) => { + this.getAttemptsLeft(); + if (this.task.latestCompletedTestAttempt) this.isPassed = this.task.scormPassed; + }); + } + } + + getAttemptsLeft(): void { + if (this.task.definition.scormAttemptLimit != 0) { + const attempts = this.task.testAttemptCache.currentValues; + let count = attempts.length; + if (count > 0 && attempts[0].terminated === false) count--; + this.attemptsLeft = + this.task.definition.scormAttemptLimit + this.task.scormExtensions - count; + } + } + + launchScormPlayer(): void { + this.task.launchScormPlayer(); + } + + reviewLatestCompletedAttempt(): void { + this.task.latestCompletedTestAttempt.review(); + } + + requestExtraAttempt(): void { + this.extensions.show(this.task, () => { + this.task.refresh(); + }); + } +} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html index 84da532b5..d2797fa66 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html @@ -41,6 +41,7 @@
+ diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html new file mode 100644 index 000000000..0c44be728 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html @@ -0,0 +1,36 @@ +
+
+ + @if (!user.isStaff && !task.definition.scormAllowReview) { +
+ } @else { +
+ } + + + + + +
+
diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss new file mode 100644 index 000000000..7bc5f74d9 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss @@ -0,0 +1,47 @@ +p { + color: #2c2c2c; + text-align: center; +} + +hr { + width: 100%; +} + +.hr-fade { + background: linear-gradient(to right, transparent, #9696969d, transparent); + width: 100%; + margin-top: 6px; +} + +.hr-text { + margin: 0; + line-height: 1em; + position: relative; + outline: 0; + border: 0; + color: black; + text-align: center; + height: 1.5em; + opacity: 0.8; + &:before { + content: ""; + background: linear-gradient(to right, transparent, #9696969d, transparent); + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: 1px; + } + &:after { + content: attr(data-content); + position: relative; + display: inline-block; + color: black; + + padding: 0 0.5em; + line-height: 1.5em; + + color: #9696969d; + background-color: #fff; + } +} diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts new file mode 100644 index 000000000..869b79aa5 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts @@ -0,0 +1,54 @@ +import {Component, Input, Inject} from '@angular/core'; +import {confirmationModal} from 'src/app/ajs-upgraded-providers'; +import { + Task, + ScormComment, + User, + UserService, + TestAttemptService, +} from 'src/app/api/models/doubtfire-model'; + +@Component({ + selector: 'f-scorm-comment', + templateUrl: './scorm-comment.component.html', + styleUrls: ['./scorm-comment.component.scss'], +}) +export class ScormCommentComponent { + @Input() task: Task; + @Input() comment: ScormComment; + + user: User; + + constructor( + private userService: UserService, + private testAttemptService: TestAttemptService, + @Inject(confirmationModal) private confirmationModal: any, + ) { + this.user = this.userService.currentUser; + } + + reviewScormTest() { + this.comment.testAttempt.review(); + } + + passScormAttempt() { + this.confirmationModal.show( + 'Pass Test Attempt', + 'Are you sure you want to pass this test attempt? This action will override the success status of this test attempt to a pass.', + () => { + this.testAttemptService.overrideSuccessStatus(this.comment.testAttempt.id, true); + }, + ); + } + + deleteScormAttempt() { + this.confirmationModal.show( + 'Delete Test Attempt', + 'Are you sure you want to delete this test attempt? This action is final and will delete information associated with this test attempt.', + () => { + this.testAttemptService.deleteAttempt(this.comment.testAttempt.id); + this.comment.delete(); + }, + ); + } +} diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.html new file mode 100644 index 000000000..b0a74a991 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.html @@ -0,0 +1,29 @@ +
+ @if (comment.assessed) { +
+
+

reason: {{ comment.text }}

+
+ } + + @if (!comment.assessed) { +
+
+

+ {{ message }}
+ reason: {{ comment.text }} +

+ @if (isNotStudent) { + + } +
+
+ } +
diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.scss b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.scss new file mode 100644 index 000000000..c2917f902 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.scss @@ -0,0 +1,55 @@ +div { + width: 100%; +} + +p { + color: #2c2c2c; + text-align: center; +} + +hr { + width: 100%; +} + +.hr-fade { + background: linear-gradient(to right, transparent, #9696969d, transparent); + width: 100%; +} + +.fade-text { + color: #9696969d; + opacity: 0.8; +} + +.hr-text { + margin: 0; + line-height: 1em; + position: relative; + outline: 0; + border: 0; + color: black; + text-align: center; + height: 1.5em; + opacity: 0.8; + &:before { + content: ""; + background: linear-gradient(to right, transparent, #9696969d, transparent); + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: 1px; + } + &:after { + content: attr(data-content); + position: relative; + display: inline-block; + color: black; + + padding: 0 0.5em; + line-height: 1.5em; + + color: #9696969d; + background-color: #fff; + } +} diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts new file mode 100644 index 000000000..7585e8d7c --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts @@ -0,0 +1,52 @@ +import {Component, OnInit, Input} from '@angular/core'; +import {ScormExtensionComment, TaskComment, Task} from 'src/app/api/models/doubtfire-model'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-scorm-extension-comment', + templateUrl: './scorm-extension-comment.component.html', + styleUrls: ['./scorm-extension-comment.component.scss'], +}) +export class ScormExtensionCommentComponent implements OnInit { + @Input() comment: ScormExtensionComment; + @Input() task: Task; + + constructor(private alerts: AlertService) {} + + private handleError(error: any) { + this.alerts.error('Error: ' + error.data.error, 6000); + } + + ngOnInit() {} + + get message() { + const studentName = this.comment.author.name; + if (this.comment.assessed && this.comment.granted) { + return 'Extra attempt granted.'; + } else if (this.comment.assessed && !this.comment.granted) { + return 'Extra attempt request rejected.'; + } + const subject = this.isStudent ? 'You have ' : studentName + ' has '; + const message = 'requested an extra attempt for the knowledge check.'; + return subject + message; + } + + get isStudent() { + return !this.isNotStudent; + } + + get isNotStudent() { + return this.task.unit.currentUserIsStaff; + } + + grantExtension() { + this.comment.grant().subscribe({ + next: (tc: TaskComment) => { + this.alerts.success('Attempt request granted', 2000); + }, + error: (response) => { + this.handleError(response); + }, + }); + } +} diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index d104df63a..81b09e601 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -72,6 +72,14 @@ >
+
+ +
+ +
+ + +
+
diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss index 0ee9fbb91..a1b481194 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss @@ -140,11 +140,11 @@ $comment-inner-border-radius: 4px; } } - .comment-container .comment-extension { + .comment-container .comment-extension, .comment-container .comment-scorm-extension { width: 100%; } - .comment-container .comment-assessment { + .comment-container .comment-assessment, .comment-container .comment-scorm { width: 100%; } @@ -354,7 +354,7 @@ $comment-inner-border-radius: 4px; } } - .comment .extension-bubble { + .comment .extension-bubble, .comment .scorm_extension-bubble { width: 100%; background-color: transparent; } diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts index 1abfa9934..857f4581f 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts @@ -98,6 +98,10 @@ export class TaskCommentsViewerComponent implements OnChanges, OnInit { return this.constants.IsOverseerEnabled.value; } + get scormEnabled(): boolean { + return this.task.scormEnabled; + } + uploadFiles(event) { [...event].forEach((file) => { if ( @@ -150,7 +154,13 @@ export class TaskCommentsViewerComponent implements OnChanges, OnInit { } shouldShowAuthorIcon(commentType: string) { - return !(commentType === 'extension' || commentType === 'status' || commentType == 'assessment'); + return !( + commentType === 'extension' || + commentType === 'status' || + commentType == 'assessment' || + commentType === 'scorm' || + commentType === 'scorm_extension' + ); } commentClasses(comment: TaskComment): object { diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html index 202ec4f9d..7d6224f34 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html @@ -118,7 +118,9 @@

Configure automated assessment

- +
@@ -131,24 +133,48 @@

7 +
+

SCORM test

+

+ Upload the corresponding SCORM 2004 test (e.g. Numbas) +

+
+ +
+
+ + +
+
+
+ 8 +
+
-
+

Optional settings

Apply other options

- - - Options - - - +
+ + + Options + + + +
+
+ +
+
+ +
-
-
-
-
-
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html new file mode 100644 index 000000000..fb02d119a --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html @@ -0,0 +1,64 @@ +
+ + Enable test for task + + + @if (taskDefinition.scormEnabled) { + + @if (taskDefinition.hasScormData) { +
+ + + +
+ } + +
+
+ + Allow students to review completed test attempt + + + Allow file upload regardless of test pass status + +
+ + Limit to this number of attempts - 0 is unlimited + + +
+ +
+ + Enable incremental time delays between test attempts + + If enabled, first 2 attempts can be completed immediately. Subsequently, a time delay will + be added, increasing by 2 hours every attempt made. +
+ } +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts new file mode 100644 index 000000000..8dee18a29 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts @@ -0,0 +1,67 @@ +import {Component, Input} from '@angular/core'; +import {FormControl, Validators} from '@angular/forms'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; + +@Component({ + selector: 'f-task-definition-scorm', + templateUrl: 'task-definition-scorm.component.html', + styleUrls: ['task-definition-scorm.component.scss'], +}) +export class TaskDefinitionScormComponent { + @Input() taskDefinition: TaskDefinition; + + constructor( + private fileDownloaderService: FileDownloaderService, + private alerts: AlertService, + private taskDefinitionService: TaskDefinitionService, + ) {} + + public get unit(): Unit { + return this.taskDefinition?.unit; + } + + /** + * Open the SCORM test in a new tab - using preview mode. + */ + public previewScormTest() { + this.taskDefinition.previewScormTest(); + } + + public downloadScormData() { + this.fileDownloaderService.downloadFile( + this.taskDefinition.getScormDataUrl(true), + this.taskDefinition.name + '-SCORM.zip', + ); + } + + public removeScormData() { + this.taskDefinition.deleteScormData().subscribe({ + next: () => this.alerts.success('Deleted SCORM test data', 2000), + error: (message) => this.alerts.error(message, 6000), + }); + } + + public uploadScormData(files: FileList) { + // console.log(Array.from(files).map((f) => f.type)); + const validMimeTypes = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip']; + const validFiles = Array.from(files as ArrayLike).filter((f) => + validMimeTypes.includes(f.type), + ); + if (validFiles.length > 0) { + const file = validFiles[0]; + this.taskDefinitionService.uploadScormData(this.taskDefinition, file).subscribe({ + next: () => { + this.alerts.success('Uploaded SCORM test data', 2000); + this.taskDefinition.hasScormData = true; + }, + error: (message) => this.alerts.error(message, 6000), + }); + } else { + this.alerts.error('Please drop a zip file to upload SCORM test data for this task', 6000); + } + } +} diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index 95b498af2..b16d23033 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -122,27 +122,28 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.refreshData(); } } + ngOnDestroy(): void { - this.hotkeys.removeShortcuts('meta.shift.arrowdown'); - this.hotkeys.removeShortcuts('meta.shift.arrowup'); + this.hotkeys.removeShortcuts('control.shift.arrowdown'); + this.hotkeys.removeShortcuts('control.shift.arrowup'); } ngOnInit(): void { const registeredHotkeys = this.hotkeys.getHotkeys().map((hotkey) => hotkey.keys); - if (!registeredHotkeys.includes('meta.shift.arrowdown')) { + if (!registeredHotkeys.includes('control.shift.arrowdown')) { this.hotkeys .addShortcut({ - keys: 'meta.shift.arrowdown', + keys: 'control.shift.arrowdown', description: 'Select next task', }) .subscribe(() => this.nextTask()); } - if (!registeredHotkeys.includes('meta.shift.arrowup')) { + if (!registeredHotkeys.includes('control.shift.arrowup')) { this.hotkeys .addShortcut({ - keys: 'meta.shift.arrowup', + keys: 'control.shift.arrowup', description: 'Select previous task', }) .subscribe(() => this.previousTask()); @@ -363,7 +364,7 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } private scrollToTaskInList(task) { - const taskEl = document.querySelector(`staff-task-list #${task.taskKeyToIdString()}`) as any; + const taskEl = document.querySelector(`#${task.taskKeyToIdString()}`) as any; if (!taskEl) { return; } @@ -385,6 +386,9 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } nextTask(): void { + if (!this.filteredTasks) { + return; + } const currentTaskIndex = this.filteredTasks.findIndex((task) => this.isSelectedTask(task)); if (currentTaskIndex >= this.filteredTasks.length) { return; diff --git a/src/app/units/states/tasks/inbox/inbox.component.ts b/src/app/units/states/tasks/inbox/inbox.component.ts index 568df3744..7e73fcccb 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.ts @@ -1,6 +1,5 @@ import {CdkDragEnd, CdkDragStart, CdkDragMove} from '@angular/cdk/drag-drop'; import { - AfterViewInit, Component, ElementRef, Input, @@ -19,13 +18,14 @@ import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-ta import {HotkeysService, HotkeysHelpComponent} from '@ngneat/hotkeys'; import {MatDialog} from '@angular/material/dialog'; import {UserService} from 'src/app/api/services/user.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; @Component({ selector: 'f-inbox', templateUrl: './inbox.component.html', styleUrls: ['./inbox.component.scss'], }) -export class InboxComponent implements OnInit { +export class InboxComponent implements OnInit, OnDestroy { @Input() unit: Unit; @Input() unitRole: UnitRole; @Input() taskData: {selectedTask: Task; any}; @@ -58,6 +58,7 @@ export class InboxComponent implements OnInit { private router: UIRouter, public dialog: MatDialog, private userService: UserService, + private constants: DoubtfireConstants, ) { this.selectedTask.currentPdfUrl$.subscribe((url) => { this.visiblePdfUrl = url; @@ -76,24 +77,39 @@ export class InboxComponent implements OnInit { const ref = this.dialog.open(HotkeysHelpComponent, { // width: '250px', }); - ref.componentInstance.title = 'Formatif Marking Shortcuts'; + ref.componentInstance.title = `${this.constants.ExternalName.value} Feedback Shortcuts`; ref.componentInstance.dismiss.subscribe(() => ref.close()); }); } - // this.hotkeys - // .addShortcut({ - // keys: 'control.c', - // description: 'Mark selected task as complete', - // }) - // .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('complete')); + if (!registeredHotkeys.includes('control.shift.f')) { + this.hotkeys + .addShortcut({ + keys: 'control.shift.f', + description: 'Mark selected task as fix', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); + } - // this.hotkeys - // .addShortcut({ - // keys: 'control.f', - // description: 'Mark selected task as fix', - // }) - // .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); + if (!registeredHotkeys.includes('control.shift.c')) { + this.hotkeys + .addShortcut({ + keys: 'control.Shift.c', + description: 'Mark selected task as complete', + }) + .subscribe(() => + this.selectedTask.selectedTask?.updateTaskStatus('complete') + ); + } + + if (!registeredHotkeys.includes('control.shift.d')) { + this.hotkeys + .addShortcut({ + keys: 'control.shift.d', + description: 'Mark selected task as discuss', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('discuss')); + } this.dragMoveAudited$ = this.dragMove$.pipe( withLatestFrom(this.inboxStartSize$), @@ -126,6 +142,13 @@ export class InboxComponent implements OnInit { window.dispatchEvent(new Event('resize')); } + ngOnDestroy(): void { + this.hotkeys.removeShortcuts('control.shift.d'); + this.hotkeys.removeShortcuts('control.shift.f'); + this.hotkeys.removeShortcuts('control.shift.c'); + this.hotkeys.removeShortcuts('shift.?'); + } + startedDragging(event: CdkDragStart, div: HTMLDivElement) { event.source.element.nativeElement.classList.add('hovering'); const w = div.getBoundingClientRect().width;