From f240091cc7b0d967aebad44c7350895850139fb7 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 9 May 2024 16:10:03 +0800 Subject: [PATCH 01/14] [CORE-6551] handled http429 --- .../assessment/assessment.component.ts | 2 +- .../v3/src/app/services/assessment.service.ts | 44 +++++++++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index 1e9e30551..732e5f22d 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -124,7 +124,7 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { }), ).subscribe( (data: { - autoSave: boolean; + autoSave: boolean; // true: this request is for autosave; false: request is for submission (manual submission); goBack: boolean; questionSave?: { submissionId: number; diff --git a/projects/v3/src/app/services/assessment.service.ts b/projects/v3/src/app/services/assessment.service.ts index 4d865b0c6..a3784054e 100644 --- a/projects/v3/src/app/services/assessment.service.ts +++ b/projects/v3/src/app/services/assessment.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, of, Subscription } from 'rxjs'; -import { map, shareReplay } from 'rxjs/operators'; import { RequestService } from 'request'; +import { map, shareReplay, catchError } from 'rxjs/operators'; import { UtilsService } from '@v3/services/utils.service'; import { BrowserStorageService } from '@v3/services/storage.service'; import { NotificationsService } from '@v3/services/notifications.service'; @@ -463,12 +463,14 @@ export class AssessmentService { } }`, variables - ).pipe(map(res => { - if (!this.isValidData('saveQuestionAnswer', res)) { - throw new Error('Autosave: Invalid API data'); - } - return res; - })); + ).pipe( + map(res => { + if (!this.isValidData('saveQuestionAnswer', res)) { + throw new Error('Autosave: Invalid API data'); + } + return res; + }) + ); } /** @@ -552,12 +554,28 @@ export class AssessmentService { submitAssessment(${params}) }`, variables - ).pipe(map(res => { - if (!this.isValidData('submitAssessment', res)) { - throw new Error('Submission: Invalid API data'); - } - return res; - })); + ).pipe( + map(res => { + if (!this.isValidData('submitAssessment', res)) { + throw new Error('Submission: Invalid API data'); + } + return res; + }), + catchError(error => { + if (error.status === 429) { + // If the error is a 429, return a successful Observable + return of({ + data: { + submitAssessment: { + success: true, + message: 'Rate limit exceeded, treated as success' + }, + }, + }); + } + throw error; + }) + ); } /** From 6beb57a8eee23709eb060f0b0babf4d0b904ffe8 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 13 Jun 2024 11:54:24 +0800 Subject: [PATCH 02/14] [CORE-6621] chore: Add loading spinner to experiences page --- .../src/app/pages/experiences/experiences.page.html | 7 ++++++- .../v3/src/app/pages/experiences/experiences.page.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/projects/v3/src/app/pages/experiences/experiences.page.html b/projects/v3/src/app/pages/experiences/experiences.page.html index ca451866f..fbbfe8bcd 100644 --- a/projects/v3/src/app/pages/experiences/experiences.page.html +++ b/projects/v3/src/app/pages/experiences/experiences.page.html @@ -9,7 +9,7 @@

Home

- +

Experiences

@@ -72,3 +72,8 @@

Your experiences

+ +
+ +
+
diff --git a/projects/v3/src/app/pages/experiences/experiences.page.ts b/projects/v3/src/app/pages/experiences/experiences.page.ts index 7fddef91f..55861538c 100644 --- a/projects/v3/src/app/pages/experiences/experiences.page.ts +++ b/projects/v3/src/app/pages/experiences/experiences.page.ts @@ -55,9 +55,15 @@ export class ExperiencesPage implements OnInit, OnDestroy { const ids = experiences.map(experience => experience.projectId); this.experienceService.getProgresses(ids).subscribe(res => { res.forEach(progress => { - progress.forEach(project => { - this.progresses[project.id] = Math.round(progress.progress * 100); - }); + if (Array.isArray(progress)) { + progress.forEach(project => { + this.progresses[project.id] = Math.round(project.progress * 100); + }); + return; + } + + // single progress objects + this.progresses[progress.id] = Math.round(progress.progress * 100); }); }); }); From ed1ddd0850bd6d8f855e15b4ff4e7f191722ac82 Mon Sep 17 00:00:00 2001 From: sunil Date: Sat, 15 Jun 2024 09:30:43 +0530 Subject: [PATCH 03/14] deploy --- .github/workflows/p2-sandbox-appv3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/p2-sandbox-appv3.yml b/.github/workflows/p2-sandbox-appv3.yml index 9611af378..83c7ceb7a 100644 --- a/.github/workflows/p2-sandbox-appv3.yml +++ b/.github/workflows/p2-sandbox-appv3.yml @@ -16,7 +16,7 @@ on: default: '' push: branches: - - 'develop' + - 'pvt' ################################################ From ac73ef22ba624a10aaf357a1e45d0feeb9d9b5c0 Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 24 Jun 2024 17:56:12 +0800 Subject: [PATCH 04/14] [CORE-6491] self-recovery or NaN --- .../app/services/assessment.service.spec.ts | 44 +++++++++++++++++++ .../v3/src/app/services/assessment.service.ts | 21 ++++++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/projects/v3/src/app/services/assessment.service.spec.ts b/projects/v3/src/app/services/assessment.service.spec.ts index ef8b5e8b8..ea334afe5 100644 --- a/projects/v3/src/app/services/assessment.service.spec.ts +++ b/projects/v3/src/app/services/assessment.service.spec.ts @@ -578,4 +578,48 @@ describe('AssessmentService', () => { }); }); + describe('_normaliseAnswer', () => { + beforeEach(() => { + service.questions = { + 1: { type: 'oneof', choices: [] }, + 2: { type: 'multiple', choices: [] }, + 3: { type: 'multi team member selector', choices: [] } + }; + }); + + it('should convert string to number for oneof question type', () => { + const result = service['_normaliseAnswer'](1, '123'); + expect(result).toEqual(123); + }); + + it('should convert empty string to null for oneof question type', () => { + const result = service['_normaliseAnswer'](1, ''); + expect(result).toBeNull(); + }); + + it('should convert string to array for multiple question type', () => { + const result = service['_normaliseAnswer'](2, '[1,2,3]'); + expect(result).toEqual([1, 2, 3]); + }); + + it('should handle non-array string by wrapping it in an array for multiple question type', () => { + const result = service['_normaliseAnswer'](2, 'not an array'); + expect(result).toEqual(['not an array']); + }); + + it('should parse string to array for multi team member selector question type', () => { + const result = service['_normaliseAnswer'](3, '[1,2,3]'); + expect(result).toEqual([1, 2, 3]); + }); + + it('should return the answer as is for invalid question type', () => { + const result = service['_normaliseAnswer'](999, 'test'); + expect(result).toEqual('test'); + }); + + it('should return the answer as is if question not found', () => { + const result = service['_normaliseAnswer'](4, 'test'); // Assuming questionId 4 does not exist + expect(result).toEqual('test'); + }); + }); }); diff --git a/projects/v3/src/app/services/assessment.service.ts b/projects/v3/src/app/services/assessment.service.ts index fcccc4de8..b799bbde3 100644 --- a/projects/v3/src/app/services/assessment.service.ts +++ b/projects/v3/src/app/services/assessment.service.ts @@ -433,18 +433,27 @@ export class AssessmentService { answer = +answer; } break; + case 'multiple': + // Check if answer is empty or not an array, and attempt to parse if it's a string if (this.utils.isEmpty(answer)) { answer = []; + } else if (typeof answer === 'string') { + try { + answer = JSON.parse(answer); + } catch (e) { + // In case JSON.parse fails, wrap the original answer in an array + answer = [answer]; + } } + + // Ensure answer is an array (wrap non-array values in an array) if (!Array.isArray(answer)) { - // re-format json string to array - answer = JSON.parse(answer); + answer = [answer]; } - // re-format answer from string to number - answer = answer.map(value => { - return +value; - }); + + // Convert all elements to numbers + answer = answer.map(value => +(value || NaN)); break; case 'multi team member selector': From 68291ed4cbb8348575537a47c1d3f99e7c9b8a47 Mon Sep 17 00:00:00 2001 From: trtshen Date: Sun, 30 Jun 2024 02:38:22 +0800 Subject: [PATCH 05/14] [CORE-6667] auto-refresh upon newItem pusher event --- .../components/activity/activity.component.ts | 46 +++++++++++++------ .../activity-desktop/activity-desktop.page.ts | 16 ++++++- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/projects/v3/src/app/components/activity/activity.component.ts b/projects/v3/src/app/components/activity/activity.component.ts index 484dbbccb..97282cea4 100644 --- a/projects/v3/src/app/components/activity/activity.component.ts +++ b/projects/v3/src/app/components/activity/activity.component.ts @@ -1,4 +1,5 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { Subject } from 'rxjs'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; import { SharedService } from '@v3/app/services/shared.service'; import { UnlockIndicatorService } from '@v3/app/services/unlock-indicator.service'; import { Activity, ActivityService, Task } from '@v3/services/activity.service'; @@ -6,13 +7,14 @@ import { Submission } from '@v3/services/assessment.service'; import { NotificationsService } from '@v3/services/notifications.service'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'app-activity', templateUrl: './activity.component.html', styleUrls: ['./activity.component.scss'], }) -export class ActivityComponent implements OnInit, OnChanges { +export class ActivityComponent implements OnInit, OnChanges, OnDestroy { @Input() activity: Activity; @Input() currentTask: Task; @Input() submission: Submission; @@ -25,6 +27,7 @@ export class ActivityComponent implements OnInit, OnChanges { // false: at least one non-team task @Output() cannotAccessTeamActivity = new EventEmitter(); isForTeamOnly: boolean = false; + private unsubscribe$: Subject = new Subject(); constructor( private utils: UtilsService, @@ -32,17 +35,26 @@ export class ActivityComponent implements OnInit, OnChanges { private notificationsService: NotificationsService, private sharedService: SharedService, private activityService: ActivityService, - private unlockIndicatorService: UnlockIndicatorService, + private unlockIndicatorService: UnlockIndicatorService ) {} + ngOnDestroy(): void { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + + resetTaskIndicator(unlockedTasks) { + this.newTasks = {}; + unlockedTasks.forEach((task) => { + this.newTasks[task.taskId] = true; + }); + } + ngOnInit() { this.leadImage = this.storageService.getUser().programImage; - this.unlockIndicatorService.unlockedTasks$.subscribe((unlockedTasks) => { - this.newTasks = {}; - unlockedTasks.forEach((task) => { - this.newTasks[task.taskId] = true; - }); - }); + this.unlockIndicatorService.unlockedTasks$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(this.resetTaskIndicator); } ngOnChanges(changes: SimpleChanges): void { @@ -56,23 +68,27 @@ export class ActivityComponent implements OnInit, OnChanges { const currentValue = changes.activity.currentValue; if (currentValue.tasks?.length > 0) { - this.activityService.nonTeamActivity(changes.activity.currentValue?.tasks).then((nonTeamActivity) => { - this.isForTeamOnly = !nonTeamActivity; - this.cannotAccessTeamActivity.emit(this.isForTeamOnly); - }); + this.activityService + .nonTeamActivity(changes.activity.currentValue?.tasks) + .then((nonTeamActivity) => { + this.isForTeamOnly = !nonTeamActivity; + this.cannotAccessTeamActivity.emit(this.isForTeamOnly); + }); const unlockedTasks = this.unlockIndicatorService.getTasksByActivity(this.activity); if (unlockedTasks.length === 0) { const clearedActivities = this.unlockIndicatorService.clearActivity(this.activity.id); clearedActivities.forEach((activity) => { - this.notificationsService.markTodoItemAsDone(activity).subscribe(); + this.notificationsService + .markTodoItemAsDone(activity) + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(); }); } } } } - /** * Task icon type * diff --git a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts index 7ea3220e8..ab1d0d5b8 100644 --- a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts +++ b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts @@ -1,3 +1,4 @@ +import { UnlockIndicatorService } from './../../services/unlock-indicator.service'; import { DOCUMENT } from '@angular/common'; import { Component, Inject, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; @@ -45,7 +46,8 @@ export class ActivityDesktopPage { private notificationsService: NotificationsService, private storageService: BrowserStorageService, private utils: UtilsService, - @Inject(DOCUMENT) private readonly document: Document + private unlockIndicatorService: UnlockIndicatorService, + @Inject(DOCUMENT) private readonly document: Document, ) { } ionViewWillEnter() { @@ -117,6 +119,7 @@ export class ActivityDesktopPage { }); })); + // refresh when review is available (AI review, peer review, etc.) this.subscriptions.push( this.utils.getEvent('notification').subscribe(event => { const review = event?.meta?.AssessmentReview; @@ -127,6 +130,17 @@ export class ActivityDesktopPage { } }) ); + + // check new unlock indicator to refresh + this.subscriptions.push( + this.unlockIndicatorService.unlockedTasks$.subscribe(unlockedTasks => { + if (this.activity) { + if (unlockedTasks.some(task => task.activityId === this.activity.id)) { + this.activityService.getActivity(this.activity.id); + } + } + }) + ); } ionViewWillLeave() { From b6ab84dcba7849ab3875cc0975124d7b453cc1ae Mon Sep 17 00:00:00 2001 From: trtshen Date: Sun, 30 Jun 2024 03:10:51 +0800 Subject: [PATCH 06/14] [CORE-6667] show instant indicator for unlock --- .../v3/src/app/components/activity/activity.component.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/projects/v3/src/app/components/activity/activity.component.ts b/projects/v3/src/app/components/activity/activity.component.ts index 97282cea4..7e61561ff 100644 --- a/projects/v3/src/app/components/activity/activity.component.ts +++ b/projects/v3/src/app/components/activity/activity.component.ts @@ -45,9 +45,11 @@ export class ActivityComponent implements OnInit, OnChanges, OnDestroy { resetTaskIndicator(unlockedTasks) { this.newTasks = {}; - unlockedTasks.forEach((task) => { - this.newTasks[task.taskId] = true; - }); + unlockedTasks + .filter((task) => task.taskId) + .forEach((task) => { + this.newTasks[task.taskId] = true; + }); } ngOnInit() { @@ -76,6 +78,7 @@ export class ActivityComponent implements OnInit, OnChanges, OnDestroy { }); const unlockedTasks = this.unlockIndicatorService.getTasksByActivity(this.activity); + this.resetTaskIndicator(unlockedTasks); if (unlockedTasks.length === 0) { const clearedActivities = this.unlockIndicatorService.clearActivity(this.activity.id); clearedActivities.forEach((activity) => { From 6d5adb791ff15ace2cc38e0491332d3853e70f89 Mon Sep 17 00:00:00 2001 From: trtshen Date: Tue, 2 Jul 2024 14:33:20 +0800 Subject: [PATCH 07/14] [CORE-6673] revalidate on visit --- projects/v3/src/app/pages/home/home.page.ts | 118 +++++++++++------- .../app/services/unlock-indicator.service.ts | 19 +-- 2 files changed, 73 insertions(+), 64 deletions(-) diff --git a/projects/v3/src/app/pages/home/home.page.ts b/projects/v3/src/app/pages/home/home.page.ts index 7f8f90496..bd1b2ecd1 100644 --- a/projects/v3/src/app/pages/home/home.page.ts +++ b/projects/v3/src/app/pages/home/home.page.ts @@ -1,6 +1,9 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; -import { Achievement, AchievementService } from '@v3/app/services/achievement.service'; +import { + Achievement, + AchievementService, +} from '@v3/app/services/achievement.service'; import { ActivityService } from '@v3/app/services/activity.service'; import { AssessmentService } from '@v3/app/services/assessment.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; @@ -34,7 +37,7 @@ export class HomePage implements OnInit, OnDestroy { getIsPointsConfigured: boolean = false; getEarnedPoints: number = 0; hasUnlockedTasks: Object = {}; - unlockedMilestones: {[key: number]: boolean} = {}; + unlockedMilestones: { [key: number]: boolean } = {}; // default card image (gracefully show broken url) defaultLeadImage: string = ''; @@ -57,57 +60,67 @@ export class HomePage implements OnInit, OnDestroy { ngOnInit() { this.isMobile = this.utils.isMobile(); this.subscriptions = []; - this.subscriptions.push(this.homeService.milestones$.pipe( - distinctUntilChanged(), - filter(milestones => milestones !== null), - ).subscribe( - res => { - this.milestones = res; - } - )); - this.subscriptions.push(this.achievementService.achievements$.subscribe( - res => { + this.subscriptions.push( + this.homeService.milestones$ + .pipe( + distinctUntilChanged(), + filter((milestones) => milestones !== null) + ) + .subscribe((res) => { + this.milestones = res; + }) + ); + this.subscriptions.push( + this.achievementService.achievements$.subscribe((res) => { this.achievements = res; - } - )); - this.subscriptions.push(this.homeService.experienceProgress$.subscribe( - res => { + }) + ); + this.subscriptions.push( + this.homeService.experienceProgress$.subscribe((res) => { this.experienceProgress = res; - } - )); - this.subscriptions.push(this.homeService.projectProgress$.pipe( - filter(progress => progress !== null), - ).subscribe( - progress => { - progress?.milestones?.forEach(m => { - m.activities?.forEach(a => this.activityProgresses[a.id] = a.progress); - }); - } - )); + }) + ); + this.subscriptions.push( + this.homeService.projectProgress$ + .pipe(filter((progress) => progress !== null)) + .subscribe((progress) => { + progress?.milestones?.forEach((m) => { + m.activities?.forEach( + (a) => (this.activityProgresses[a.id] = a.progress) + ); + }); + }) + ); this.subscriptions.push( - this.router.events.subscribe(event => { + this.router.events.subscribe((event) => { if (event instanceof NavigationEnd) { this.updateDashboard(); } }) ); - this.unlockIndicatorService.unlockedTasks$.subscribe(unlockedTasks => { + this.unlockIndicatorService.unlockedTasks$.subscribe((unlockedTasks) => { this.hasUnlockedTasks = {}; // reset this.unlockedMilestones = {}; // reset - unlockedTasks.forEach(task => { + unlockedTasks.forEach((task) => { if (task.milestoneId) { - this.unlockedMilestones[task.milestoneId] = true; + if (this.unlockIndicatorService.isMilestoneClearable(task.milestoneId)) { + this.verifyUnlockedMilestoneValidity(task.milestoneId); + } else { + this.unlockedMilestones[task.milestoneId] = true; + } } - this.hasUnlockedTasks[task.activityId] = true; + if (task.activityId) { + this.hasUnlockedTasks[task.activityId] = true; + } }); }); } ngOnDestroy(): void { - this.subscriptions.forEach(s => s.unsubscribe()); + this.subscriptions.forEach((s) => s.unsubscribe()); } async updateDashboard() { @@ -170,32 +183,27 @@ export class HomePage implements OnInit, OnDestroy { * @param keyboardEvent The keyboard event object, if the function was called by a keyboard event. * @returns A Promise that resolves when the navigation is complete. */ - async gotoActivity({activity, milestone}, keyboardEvent?: KeyboardEvent) { - if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) { + async gotoActivity({ activity, milestone }, keyboardEvent?: KeyboardEvent) { + if ( + keyboardEvent && + (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter') + ) { keyboardEvent.preventDefault(); } else if (keyboardEvent) { return; } if (activity.isLocked) { - return ; + return; } this.activityService.clearActivity(); this.assessmentService.clearAssessment(); - // check & update unlocked milestones if (this.unlockIndicatorService.isMilestoneClearable(milestone.id)) { - const unlockedMilestones = this.unlockIndicatorService.clearActivity(milestone.id); - unlockedMilestones.forEach(unlockedMilestone => { - this.notification.markTodoItemAsDone(unlockedMilestone).pipe(first()).subscribe(() => { - // eslint-disable-next-line no-console - console.log('Marked milestone as done', unlockedMilestone); - }); - }); + this.verifyUnlockedMilestoneValidity(milestone.id); } - if (!this.isMobile) { return this.router.navigate(['v3', 'activity-desktop', activity.id]); } @@ -203,8 +211,26 @@ export class HomePage implements OnInit, OnDestroy { return this.router.navigate(['v3', 'activity-mobile', activity.id]); } + verifyUnlockedMilestoneValidity(milestoneId: number): void { + // check & update unlocked milestones + const unlockedMilestones = + this.unlockIndicatorService.clearActivity(milestoneId); + unlockedMilestones.forEach((unlockedMilestone) => { + this.notification + .markTodoItemAsDone(unlockedMilestone) + .pipe(first()) + .subscribe(() => { + // eslint-disable-next-line no-console + console.log('Marked milestone as done', unlockedMilestone); + }); + }); + } + achievePopup(achievement: Achievement, keyboardEvent?: KeyboardEvent): void { - if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) { + if ( + keyboardEvent && + (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter') + ) { keyboardEvent.preventDefault(); } else if (keyboardEvent) { return; diff --git a/projects/v3/src/app/services/unlock-indicator.service.ts b/projects/v3/src/app/services/unlock-indicator.service.ts index b6a113f8e..8ee3ebcb3 100644 --- a/projects/v3/src/app/services/unlock-indicator.service.ts +++ b/projects/v3/src/app/services/unlock-indicator.service.ts @@ -74,7 +74,7 @@ export class UnlockIndicatorService { /** * Clear all tasks related to a particular activity * - * @param {number[]} id [id description] + * @param {number[]} id can either be activityId or milestoneId * * @return {UnlockedTask[]} unlocked tasks that were cleared */ @@ -130,23 +130,6 @@ export class UnlockIndicatorService { return removedTask; } - dedupStored(records) { - const uniqueRecords = new Map(); - - records.forEach(record => { - // Determine the type of identifier and create a unique key - const key = `milestoneId:${record.milestoneId || 'none'}-activityId:${record.activityId || 'none'}-taskId:${record.taskId || 'none'}`; - - // If the key doesn't exist in the map, add the record - if (!uniqueRecords.has(key)) { - uniqueRecords.set(key, record); - } - }); - - // Return an array of unique records - return Array.from(uniqueRecords.values()); - } - // Method to transform and deduplicate the data transformAndDeduplicateTodoItem(data) { const uniqueEntries = new Map(); From ca55d46aad5954a91842125b04ce09106d277f47 Mon Sep 17 00:00:00 2001 From: trtshen Date: Wed, 3 Jul 2024 11:15:22 +0800 Subject: [PATCH 08/14] [CORE-6675] check latest subsmission status before submission - prerelease --- .../assessment/assessment.component.ts | 4 + .../activity-desktop/activity-desktop.page.ts | 68 +++- .../v3/src/app/services/assessment.service.ts | 353 ++++++++++++------ 3 files changed, 292 insertions(+), 133 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index 09464ea65..1621aec0a 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -637,6 +637,10 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { return this.utils.isColor('red', this.storage.getUser().colors?.primary); } + /** + * Resubmit the assessment submission + * (mostly for regenerate AI feedback) + */ resubmit(): Subscription { if (!this.assessment?.id || !this.submission?.id || !this.activityId) { return; diff --git a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts index ab1d0d5b8..79600dfd2 100644 --- a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts +++ b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts @@ -205,31 +205,69 @@ export class ActivityDesktopPage { this.btnDisabled$.next(true); this.savingText$.next('Saving...'); try { - const saved = await this.assessmentService.submitAssessment( - event.submissionId, - event.assessmentId, - event.contextId, - event.answers - ).toPromise(); + // handle unexpected submission: do final status check before saving + let hasSubmssion = false; + const { submission } = await this.assessmentService + .fetchAssessment( + event.assessmentId, + "assessment", + this.activity.id, + event.contextId, + event.submissionId + ) + .toPromise(); + + if (submission?.status === 'in progress') { + const saved = await this.assessmentService + .submitAssessment( + event.submissionId, + event.assessmentId, + event.contextId, + event.answers + ) + .toPromise(); + + // http 200 but error + if ( + saved?.data?.submitAssessment?.success !== true || + this.utils.isEmpty(saved) + ) { + throw new Error("Error submitting assessment"); + } - // http 200 but error - if (saved?.data?.submitAssessment?.success !== true || this.utils.isEmpty(saved)) { - throw new Error("Error submitting assessment"); + if (this.assessment.pulseCheck === true && event.autoSave === false) { + await this.assessmentService.pullFastFeedback(); + } + } else { + hasSubmssion = true; } - if (this.assessment.pulseCheck === true && event.autoSave === false) { - await this.assessmentService.pullFastFeedback(); - } + this.savingText$.next( + $localize`Last saved ${this.utils.getFormatedCurrentTime()}` + ); - this.savingText$.next($localize `Last saved ${this.utils.getFormatedCurrentTime()}`); if (!event.autoSave) { - this.notificationsService.assessmentSubmittedToast(); + if (hasSubmssion === true) { + this.notificationsService.presentToast($localize`Duplicate submission detected. Your submission is already in our system.`, { + color: 'success', + icon: 'checkmark-circle' + }); + } else { + this.notificationsService.assessmentSubmittedToast(); + } + // get the latest activity tasks this.activityService.getActivity(this.activity.id, false, task, () => { this.loading = false; this.btnDisabled$.next(false); }); - return this.assessmentService.getAssessment(event.assessmentId, 'assessment', this.activity.id, event.contextId, event.submissionId); + return this.assessmentService.getAssessment( + event.assessmentId, + 'assessment', + this.activity.id, + event.contextId, + event.submissionId + ); } else { setTimeout(() => { this.btnDisabled$.next(false); diff --git a/projects/v3/src/app/services/assessment.service.ts b/projects/v3/src/app/services/assessment.service.ts index fcccc4de8..63aa2f2ec 100644 --- a/projects/v3/src/app/services/assessment.service.ts +++ b/projects/v3/src/app/services/assessment.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Observable as RxObsservable, BehaviorSubject, of, Subscription } from 'rxjs'; +import { Observable as RxObsservable, BehaviorSubject, of, Subscription, Observable } from 'rxjs'; import { map, shareReplay } from 'rxjs/operators'; import { UtilsService } from '@v3/services/utils.service'; import { BrowserStorageService } from '@v3/services/storage.service'; @@ -109,7 +109,6 @@ export interface AssessmentReview { @Injectable({ providedIn: 'root' }) - export class AssessmentService { private _assessment$ = new BehaviorSubject(null); assessment$ = this._assessment$.pipe(shareReplay(1)); @@ -131,33 +130,27 @@ export class AssessmentService { private demo: DemoService, private request: RequestService, ) { - this.assessment$.subscribe(res => this.assessment = res); + this.assessment$.subscribe((res) => (this.assessment = res)); } clearAssessment() { this._assessment$.next(null); } - /** - * shared among reviewing & assessment answering page - * - * @param {number} id assessment id - * @param {string} action review/assessment (reviewing or answering) - * @param {number} activityId activity id - * @param {number} contextId context id (activity & task related) - * @param {number} submissionId optional submission id - * - * @return {Subscription} no need to unsubscribe, handled by apollo - */ - getAssessment(id, action, activityId, contextId, submissionId?): Subscription { - if (!this.assessment || this.assessment.id !== id) { - this.clearAssessment(); - } - if (environment.demo) { - return this.demo.assessment(id).pipe(map(res => this._handleAssessmentResponse(res, action))).subscribe(); - } - return this.apolloService.graphQLWatch( - `query getAssessment($assessmentId: Int!, $reviewer: Boolean!, $activityId: Int!, $contextId: Int!, $submissionId: Int) { + fetchAssessment( + id: number, + action: string, + activityId: number, + contextId: number, + submissionId?: number + ): Observable<{ + assessment: Assessment; + submission: Submission; + review: AssessmentReview; + }> { + return this.apolloService + .graphQLFetch( + `query getAssessment($assessmentId: Int!, $reviewer: Boolean!, $activityId: Int!, $contextId: Int!, $submissionId: Int) { assessment(id:$assessmentId, reviewer:$reviewer, activityId:$activityId, submissionId:$submissionId) { id name type description dueDate isTeam pulseCheck allowResubmit groups{ @@ -193,35 +186,78 @@ export class AssessmentService { } } }`, - { - assessmentId: +id, - reviewer: action === 'review', - activityId: +activityId, - submissionId: +submissionId || null, - contextId: +contextId - }, - { - noCache: true - } - ).pipe( - map(res => this._handleAssessmentResponse(res, action)) + { + variables: { + assessmentId: id, + reviewer: action === 'review', + activityId: +activityId, + submissionId: +submissionId || null, + contextId: +contextId, + }, + }, + ) + .pipe(map((res) => this._handleAssessmentResponse(res, action))); + } + + /** + * shared among reviewing & assessment answering page + * + * @param {number} id assessment id + * @param {string} action review/assessment (reviewing or answering) + * @param {number} activityId activity id + * @param {number} contextId context id (activity & task related) + * @param {number} submissionId optional submission id + * + * @return {Subscription} no need to unsubscribe, handled by apollo + */ + getAssessment( + id, + action, + activityId, + contextId, + submissionId? + ): Subscription { + if (!this.assessment || this.assessment.id !== id) { + this.clearAssessment(); + } + if (environment.demo) { + return this.demo + .assessment(id) + .pipe(map((res) => this._handleAssessmentResponse(res, action))) + .subscribe(); + } + return this.fetchAssessment( + id, + action, + activityId, + contextId, + submissionId ).subscribe(); } - private _handleAssessmentResponse(res, action) { + private _handleAssessmentResponse( + res, + action + ): { + assessment: Assessment; + submission: Submission; + review: AssessmentReview; + } { if (!res) { return null; } + const assessment = this._normaliseAssessment(res.data, action); const submission = this._normaliseSubmission(res.data); const review = this._normaliseReview(res.data, action); this._assessment$.next(assessment); this._submission$.next(submission); this._review$.next(review); + return { assessment, submission, - review + review, }; } @@ -236,17 +272,19 @@ export class AssessmentService { description: data.assessment.description, isForTeam: data.assessment.isTeam, dueDate: data.assessment.dueDate, - isOverdue: data.assessment.dueDate ? this.utils.timeComparer(data.assessment.dueDate) < 0 : false, + isOverdue: data.assessment.dueDate + ? this.utils.timeComparer(data.assessment.dueDate) < 0 + : false, pulseCheck: data.assessment.pulseCheck, allowResubmit: data.assessment.allowResubmit, - groups: [] + groups: [], }; - data.assessment.groups.forEach(eachGroup => { + data.assessment.groups.forEach((eachGroup) => { const questions: Question[] = []; if (!eachGroup.questions) { return; } - eachGroup.questions.forEach(eachQuestion => { + eachGroup.questions.forEach((eachQuestion) => { this.questions[eachQuestion.id] = eachQuestion; const question: Question = { id: eachQuestion.id, @@ -255,10 +293,17 @@ export class AssessmentService { description: eachQuestion.description, isRequired: eachQuestion.isRequired, canComment: eachQuestion.hasComment, - canAnswer: action === 'review' ? eachQuestion.audience.includes('reviewer') : eachQuestion.audience.includes('submitter'), + canAnswer: + action === 'review' + ? eachQuestion.audience.includes('reviewer') + : eachQuestion.audience.includes('submitter'), audience: eachQuestion.audience, - submitterOnly: eachQuestion.audience.length === 1 && eachQuestion.audience.includes('submitter'), - reviewerOnly: eachQuestion.audience.length === 1 && eachQuestion.audience.includes('reviewer') + submitterOnly: + eachQuestion.audience.length === 1 && + eachQuestion.audience.includes('submitter'), + reviewerOnly: + eachQuestion.audience.length === 1 && + eachQuestion.audience.includes('reviewer'), }; switch (eachQuestion.type) { case 'oneof': @@ -269,7 +314,11 @@ export class AssessmentService { choices.push({ id: eachChoice.id, name: eachChoice.name, - explanation: eachChoice.explanation ? this.sanitizer.bypassSecurityTrustHtml(eachChoice.explanation) : null, + explanation: eachChoice.explanation + ? this.sanitizer.bypassSecurityTrustHtml( + eachChoice.explanation + ) + : null, }); if (eachChoice.description) { info += '

' + eachChoice.name + ' - ' + eachChoice.description + '

'; @@ -290,10 +339,10 @@ export class AssessmentService { case 'team member selector': case 'multi team member selector': question.teamMembers = []; - eachQuestion.teamMembers.forEach(eachTeamMember => { + eachQuestion.teamMembers.forEach((eachTeamMember) => { question.teamMembers.push({ key: JSON.stringify(eachTeamMember), - userName: eachTeamMember.userName + userName: eachTeamMember.userName, }); }); break; @@ -304,7 +353,7 @@ export class AssessmentService { assessment.groups.push({ name: eachGroup.name, description: eachGroup.description, - questions: questions + questions: questions, }); } }); @@ -312,9 +361,13 @@ export class AssessmentService { } private _normaliseSubmission(data): Submission { - if (!this.utils.has(data, 'assessment.submissions') || data.assessment.submissions.length < 1) { + if ( + !this.utils.has(data, "assessment.submissions") || + data.assessment.submissions.length < 1 + ) { return null; } + const firstSubmission = data.assessment.submissions[0]; let submission: Submission = { id: firstSubmission.id, @@ -324,18 +377,25 @@ export class AssessmentService { modified: firstSubmission.modified, isLocked: firstSubmission.locked, completed: firstSubmission.completed, - reviewerName: firstSubmission.review ? this.checkReviewer(firstSubmission.review.reviewer) : null, - answers: {} + reviewerName: firstSubmission.review + ? this.checkReviewer(firstSubmission.review.reviewer) + : null, + answers: {}, }; - firstSubmission.answers.forEach(eachAnswer => { - eachAnswer.answer = this._normaliseAnswer(eachAnswer.questionId, eachAnswer.answer); + + firstSubmission.answers.forEach((eachAnswer) => { + eachAnswer.answer = this._normaliseAnswer( + eachAnswer.questionId, + eachAnswer.answer + ); submission.answers[eachAnswer.questionId] = { - answer: eachAnswer.answer + answer: eachAnswer.answer, }; if (['published', 'done'].includes(submission.status)) { submission = this._addChoiceExplanation(eachAnswer, submission); } }); + return submission; } @@ -351,7 +411,10 @@ export class AssessmentService { } private _normaliseReview(data, action: string): AssessmentReview { - if (!this.utils.has(data, 'assessment.submissions') || data.assessment.submissions.length < 1) { + if ( + !this.utils.has(data, 'assessment.submissions') || + data.assessment.submissions.length < 1 + ) { return null; } const firstSubmission = data.assessment.submissions[0]; @@ -364,7 +427,7 @@ export class AssessmentService { status: firstSubmissionReview.status, modified: firstSubmissionReview.modified, teamName: firstSubmission.submitter.team?.name, - answers: {} + answers: {}, }; // only get the review answer if the review is published, or it is for the reviewer to see the review @@ -373,11 +436,14 @@ export class AssessmentService { return review; } - firstSubmissionReview.answers.forEach(eachAnswer => { - eachAnswer.answer = this._normaliseAnswer(eachAnswer.questionId, eachAnswer.answer); + firstSubmissionReview.answers.forEach((eachAnswer) => { + eachAnswer.answer = this._normaliseAnswer( + eachAnswer.questionId, + eachAnswer.answer + ); review.answers[eachAnswer.questionId] = { answer: eachAnswer.answer, - comment: eachAnswer.comment + comment: eachAnswer.comment, }; }); return review; @@ -386,7 +452,10 @@ export class AssessmentService { /** * For each question that has choice (oneof & multiple), show the choice explanation in the submission if it is not empty */ - private _addChoiceExplanation(submissionAnswer, submission: Submission): Submission { + private _addChoiceExplanation( + submissionAnswer, + submission: Submission + ): Submission { const questionId = submissionAnswer.questionId; const answer = submissionAnswer.answer; // don't do anything if there's no choices @@ -396,7 +465,7 @@ export class AssessmentService { let explanation = ''; if (Array.isArray(answer)) { // multiple question - this.questions[questionId].choices.forEach(choice => { + this.questions[questionId].choices.forEach((choice) => { // only display the explanation if it is not empty if (answer.includes(choice.id) && !this.utils.isEmpty(choice.explanation)) { explanation += choice.name + ' - ' + choice.explanation + '\n'; @@ -404,7 +473,7 @@ export class AssessmentService { }); } else { // oneof question - this.questions[questionId].choices.forEach(choice => { + this.questions[questionId].choices.forEach((choice) => { // only display the explanation if it is not empty if (answer === choice.id && !this.utils.isEmpty(choice.explanation)) { explanation = choice.explanation; @@ -415,8 +484,12 @@ export class AssessmentService { return submission; } // put the explanation in the submission - const thisExplanation = explanation.replace(/text-align: center;/gi, 'text-align: center; text-align: -webkit-center;'); - submission.answers[questionId].explanation = this.sanitizer.bypassSecurityTrustHtml(thisExplanation); + const thisExplanation = explanation.replace( + /text-align: center;/gi, + "text-align: center; text-align: -webkit-center;" + ); + submission.answers[questionId].explanation = + this.sanitizer.bypassSecurityTrustHtml(thisExplanation); return submission; } @@ -442,7 +515,7 @@ export class AssessmentService { answer = JSON.parse(answer); } // re-format answer from string to number - answer = answer.map(value => { + answer = answer.map((value) => { return +value; }); break; @@ -470,8 +543,9 @@ export class AssessmentService { questionId, answer, }; - return this.apolloService.continuousGraphQLMutate( - `mutation saveSubmissionAnswer(${paramsFormat}) { + return this.apolloService + .continuousGraphQLMutate( + `mutation saveSubmissionAnswer(${paramsFormat}) { saveSubmissionAnswer(${params}) { success message @@ -536,8 +610,9 @@ export class AssessmentService { answer, comment, }; - return this.apolloService.continuousGraphQLMutate( - `mutation saveReviewAnswer(${paramsFormat}) { + return this.apolloService + .continuousGraphQLMutate( + `mutation saveReviewAnswer(${paramsFormat}) { saveReviewAnswer(${params}) { success message @@ -553,26 +628,37 @@ export class AssessmentService { } // set the status of the submission to 'done' or 'pending approval' - submitAssessment(submissionId: number, assessmentId: number, contextId: number, answers: Answer[]) { - const paramsFormat = '$submissionId: Int!, $assessmentId: Int!, $contextId: Int!, $answers: [AssessmentSubmissionAnswerInput]'; - const params = 'submissionId:$submissionId, assessmentId:$assessmentId, contextId:$contextId, answers:$answers'; + submitAssessment( + submissionId: number, + assessmentId: number, + contextId: number, + answers: Answer[] + ) { + const paramsFormat = + '$submissionId: Int!, $assessmentId: Int!, $contextId: Int!, $answers: [AssessmentSubmissionAnswerInput]'; + const params = + 'submissionId:$submissionId, assessmentId:$assessmentId, contextId:$contextId, answers:$answers'; const variables = { submissionId, assessmentId, contextId, answers, }; - return this.apolloService.graphQLMutate( - `mutation submitAssessment(${paramsFormat}) { + return this.apolloService + .graphQLMutate( + `mutation submitAssessment(${paramsFormat}) { submitAssessment(${params}) }`, - variables - ).pipe(map(res => { - if (!this.isValidData('submitAssessment', res)) { - throw new Error('Submission: Invalid API data'); - } - return res; - })); + variables + ) + .pipe( + map((res) => { + if (!this.isValidData('submitAssessment', res)) { + throw new Error('Submission: Invalid API data'); + } + return res; + }) + ); } /** @@ -581,30 +667,46 @@ export class AssessmentService { * @param reviewId - review id * @param submissionId - submission id * @returns - */ - submitReview(assessmentId: number, reviewId: number, submissionId: number, answers: Answer[]) { - const paramsFormat = '$assessmentId: Int!, $reviewId: Int!, $submissionId: Int!, $answers: [AssessmentReviewAnswerInput]'; - const params = 'assessmentId:$assessmentId, reviewId:$reviewId, submissionId:$submissionId, answers:$answers'; + */ + submitReview( + assessmentId: number, + reviewId: number, + submissionId: number, + answers: Answer[] + ) { + const paramsFormat = + '$assessmentId: Int!, $reviewId: Int!, $submissionId: Int!, $answers: [AssessmentReviewAnswerInput]'; + const params = + 'assessmentId:$assessmentId, reviewId:$reviewId, submissionId:$submissionId, answers:$answers'; const variables = { assessmentId, reviewId, submissionId, answers, }; - return this.apolloService.graphQLMutate( - `mutation submitReview(${paramsFormat}) { + return this.apolloService + .graphQLMutate( + `mutation submitReview(${paramsFormat}) { submitReview(${params}) }`, - variables - ).pipe(map(res => { - if (!this.isValidData('submitReview', res)) { - throw new Error('Submission: Invalid API data'); - } - return res; - })); + variables + ) + .pipe( + map((res) => { + if (!this.isValidData('submitReview', res)) { + throw new Error('Submission: Invalid API data'); + } + return res; + }) + ); } - saveAnswers(assessment: AssessmentSubmitParams, answers: Answer[], action: string, hasPulseCheck: boolean) { + saveAnswers( + assessment: AssessmentSubmitParams, + answers: Answer[], + action: string, + hasPulseCheck: boolean + ) { if (!['assessment', 'review'].includes(action)) { return of(false); } @@ -613,37 +715,53 @@ export class AssessmentService { this._afterSubmit(assessment, answers, action, hasPulseCheck); return this.demo.normalResponse(); } - let paramsFormat = `$assessmentId: Int!, $inProgress: Boolean, $answers: [${(action === 'assessment' ? 'AssessmentSubmissionAnswerInput' : 'AssessmentReviewAnswerInput')}]`; - let params = 'assessmentId:$assessmentId, inProgress:$inProgress, answers:$answers'; + let paramsFormat = `$assessmentId: Int!, $inProgress: Boolean, $answers: [${ + action === 'assessment' + ? 'AssessmentSubmissionAnswerInput' + : 'AssessmentReviewAnswerInput' + }]`; + let params = + 'assessmentId:$assessmentId, inProgress:$inProgress, answers:$answers'; const variables = { assessmentId: assessment.id, inProgress: assessment.inProgress, - answers: answers + answers: answers, }; [ { key: 'submissionId', type: 'Int' }, { key: 'contextId', type: 'Int!' }, { key: 'reviewId', type: 'Int' }, - { key: 'unlock', type: 'Boolean' } - ].forEach(item => { + { key: 'unlock', type: 'Boolean' }, + ].forEach((item) => { if (assessment[item.key]) { paramsFormat += `, $${item.key}: ${item.type}`; params += `,${item.key}: $${item.key}`; variables[item.key] = assessment[item.key]; } }); - return this.apolloService.graphQLMutate( - `mutation saveAnswers(${paramsFormat}){ - ` + (action === 'assessment' ? `submitAssessment` : `submitReview`) + `(${params}) + return this.apolloService + .graphQLMutate( + `mutation saveAnswers(${paramsFormat}){ + ` + + (action === 'assessment' ? `submitAssessment` : `submitReview`) + + `(${params}) }`, - variables - ).pipe(map(res => { - this._afterSubmit(assessment, answers, action, hasPulseCheck); - return res; - })); + variables + ) + .pipe( + map((res) => { + this._afterSubmit(assessment, answers, action, hasPulseCheck); + return res; + }) + ); } - private _afterSubmit(assessment: AssessmentSubmitParams, answers: Answer[], action: string, hasPulseCheck: boolean) { + private _afterSubmit( + assessment: AssessmentSubmitParams, + answers: Answer[], + action: string, + hasPulseCheck: boolean + ) { if (hasPulseCheck && !assessment.inProgress) { this.pullFastFeedback(); } @@ -655,7 +773,9 @@ export class AssessmentService { */ async pullFastFeedback() { try { - const modal = await this.fastFeedbackService.pullFastFeedback({ modalOnly: true }).toPromise(); + const modal = await this.fastFeedbackService + .pullFastFeedback({ modalOnly: true }) + .toPromise(); if (modal && modal.present) { await modal.present(); await modal.onDidDismiss(); @@ -663,7 +783,7 @@ export class AssessmentService { } catch (err) { const toasted = await this.NotificationsService.alert({ header: $localize`Error retrieving pulse check data`, - message: err.msg || JSON.stringify(err) + message: err.msg || JSON.stringify(err), }); throw new Error(err); } @@ -675,7 +795,7 @@ export class AssessmentService { return of(true); } return this.NotificationsService.markTodoItemAsDone({ - identifier: 'AssessmentSubmission-' + submissionId + identifier: 'AssessmentSubmission-' + submissionId, }); } @@ -686,16 +806,13 @@ export class AssessmentService { return reviewer.name !== this.storage.getUser().name ? reviewer.name : null; } - resubmitAssessment({ - assessment_id, - submission_id, - }): RxObsservable { + resubmitAssessment({ assessment_id, submission_id }): RxObsservable { return this.request.post({ endPoint: api.post.resubmit, - data:{ + data: { assessment_id, submission_id, - } + }, }); } } From ad8489dee9aa48fa9f80309a7652e16855c8096b Mon Sep 17 00:00:00 2001 From: chaw Date: Tue, 5 Dec 2023 17:16:59 +0800 Subject: [PATCH 09/14] [CORE-6241] improved offline detection --- projects/request/src/lib/request.service.ts | 4 +++ .../v3/src/app/services/network.service.ts | 30 +++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/projects/request/src/lib/request.service.ts b/projects/request/src/lib/request.service.ts index 41e26cd5a..7e3d56e97 100644 --- a/projects/request/src/lib/request.service.ts +++ b/projects/request/src/lib/request.service.ts @@ -14,6 +14,7 @@ import { has, isEmpty, each } from 'lodash'; interface RequestOptions { headers?: any; params?: any; + observe?: string; } @Injectable({ providedIn: 'root' }) @@ -127,6 +128,9 @@ export class RequestService { if (!has(httpOptions, 'params')) { httpOptions.params = ''; } + if (!has(httpOptions, 'observe')) { + httpOptions.observe = 'body'; + } const request = this.http.get(this.getEndpointUrl(endPoint), { headers: this.appendHeaders(httpOptions.headers), diff --git a/projects/v3/src/app/services/network.service.ts b/projects/v3/src/app/services/network.service.ts index 464d8033d..06090034c 100644 --- a/projects/v3/src/app/services/network.service.ts +++ b/projects/v3/src/app/services/network.service.ts @@ -1,19 +1,30 @@ import { Injectable } from '@angular/core'; +import { RequestService } from 'request'; import { Observable, fromEvent, merge, of } from 'rxjs'; -import { mapTo } from 'rxjs/operators'; +import { map, switchMap, startWith, distinctUntilChanged, catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class NetworkService { + private online$: Observable; - private online$: Observable = undefined; - - constructor() { + constructor(private request: RequestService) { this.online$ = merge( of(navigator.onLine), - fromEvent(window, 'online').pipe(mapTo(true)), - fromEvent(window, 'offline').pipe(mapTo(false)) + fromEvent(window, 'online').pipe(map(() => true)), + fromEvent(window, 'offline').pipe(map(() => false)) + ).pipe( + switchMap(isOnline => { + if (!isOnline) { + // When offline, check server reachability + return this.checkServerReachability().pipe( + startWith(false) + ); + } + return of(isOnline); + }), + distinctUntilChanged() // Emit only when the online status actually changes ); } @@ -21,4 +32,11 @@ export class NetworkService { return this.online$; } + private checkServerReachability(): Observable { + return this.request.get('https://practera.com', { observe: 'response' }) + .pipe( + map(response => response.status === 200), + catchError(() => of(false)) + ); + } } From f7c88261facf505163e5afb40bbc80c95ece1b23 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 4 Jul 2024 15:20:04 +0800 Subject: [PATCH 10/14] [CORE-6678] added submission status check for review & mobile view --- .../assessment-mobile.page.ts | 23 ++++++-- .../review-desktop/review-desktop.page.ts | 52 ++++++++++++------- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts b/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts index bb2354d91..ed3db1138 100644 --- a/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts +++ b/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts @@ -49,6 +49,7 @@ export class AssessmentMobilePage implements OnInit { this.assessmentService.submission$.subscribe(res => this.submission = res); this.assessmentService.review$.subscribe(res => this.review = res); this.route.params.subscribe(params => { + const assessmentId = +params.id; this.action = this.route.snapshot.data.action; this.fromPage = this.route.snapshot.data.from; if (!this.fromPage) { @@ -57,7 +58,7 @@ export class AssessmentMobilePage implements OnInit { this.activityId = +params.activityId || 0; this.contextId = +params.contextId; this.submissionId = +params.submissionId; - this.assessmentService.getAssessment(+params.id, this.action, this.activityId, this.contextId, this.submissionId); + this.assessmentService.getAssessment(assessmentId, this.action, this.activityId, this.contextId, this.submissionId); }); } @@ -106,7 +107,15 @@ export class AssessmentMobilePage implements OnInit { this.savingText$.next('Saving...'); try { - if (this.action === 'assessment') { + const { submission } = await this.assessmentService.fetchAssessment( + event.assessmentId, + this.action, + this.activityId, + event.contextId, + event.submissionId, + ).toPromise(); + + if (this.action === 'assessment' && submission.status === 'in progress') { const saved = await this.assessmentService.submitAssessment( event.submissionId, event.assessmentId, @@ -119,7 +128,7 @@ export class AssessmentMobilePage implements OnInit { console.error('Asmt submission error:', saved); throw new Error("Error submitting assessment"); } - } else if (this.action === 'review') { + } else if (this.action === 'review' && submission.status === 'pending review') { const saved = await this.assessmentService.submitReview( event.assessmentId, this.review.id, @@ -144,8 +153,12 @@ export class AssessmentMobilePage implements OnInit { this.savingText$.next($localize `Last saved ${this.utils.getFormatedCurrentTime()}`); if (!event.autoSave) { this.notificationsService.assessmentSubmittedToast(); - // get the latest activity tasks and refresh the assessment submission data - this.activityService.getActivity(this.activityId); + + if (this.action === 'assessment') { + // get the latest activity tasks and refresh the assessment submission data + this.activityService.getActivity(this.activityId); + } + this.btnDisabled$.next(false); this.saving = false; return this.assessmentService.getAssessment(this.assessment.id, this.action, this.activityId, this.contextId, this.submissionId); diff --git a/projects/v3/src/app/pages/review-desktop/review-desktop.page.ts b/projects/v3/src/app/pages/review-desktop/review-desktop.page.ts index 7e5fe1efe..262d67ecb 100644 --- a/projects/v3/src/app/pages/review-desktop/review-desktop.page.ts +++ b/projects/v3/src/app/pages/review-desktop/review-desktop.page.ts @@ -96,29 +96,41 @@ export class ReviewDesktopPage implements OnInit { this.btnDisabled$.next(true); this.savingText$.next('Saving...'); try { - const res = await this.assessmentService.submitReview( - this.assessment.id, - this.review.id, - this.submission.id, - event.answers - ).toPromise(); - - // [CORE-5876] - Fastfeedback is now added for reviewer - if (this.assessment.pulseCheck === true && event.autoSave === false) { - await this.assessmentService.pullFastFeedback(); - } + const { submission } = await this.assessmentService + .fetchAssessment( + event.assessmentId, + "review", + null, + this.currentReview.contextId, + this.submission.id + ) + .toPromise(); - this.assessmentService.getAssessment(this.assessment.id, 'review', 0, this.currentReview.contextId, this.submission.id); - this.reviewService.getReviews(); + if (submission.status === 'pending review') { + const res = await this.assessmentService.submitReview( + this.assessment.id, + this.review.id, + this.submission.id, + event.answers + ).toPromise(); + + // [CORE-5876] - Fastfeedback is now added for reviewer + if (this.assessment.pulseCheck === true && event.autoSave === false) { + await this.assessmentService.pullFastFeedback(); + } + + this.assessmentService.getAssessment(this.assessment.id, 'review', 0, this.currentReview.contextId, this.submission.id); + this.reviewService.getReviews(); - await this.notificationsService.getTodoItems().toPromise(); // update notifications list + await this.notificationsService.getTodoItems().toPromise(); // update notifications list - // fail gracefully: Review submission API may sometimes fail silently - if (res?.data?.submitReview === false) { - this.savingText$.next($localize`Save failed.`); - this.btnDisabled$.next(false); - this.loading = false; - return; + // fail gracefully: Review submission API may sometimes fail silently + if (res?.data?.submitReview === false) { + this.savingText$.next($localize`Save failed.`); + this.btnDisabled$.next(false); + this.loading = false; + return; + } } this.loading = false; From 4a9831adb21ff55514dc021dd521dd08688c2078 Mon Sep 17 00:00:00 2001 From: trtshen Date: Tue, 9 Jul 2024 15:55:59 +0800 Subject: [PATCH 11/14] [CORE-6680] duplicated toast --- .../multi-team-member-selector.component.html | 6 +++--- .../pages/activity-desktop/activity-desktop.page.ts | 5 +---- .../assessment-mobile/assessment-mobile.page.ts | 11 ++++++++++- .../app/pages/review-desktop/review-desktop.page.ts | 2 ++ .../v3/src/app/services/notifications.service.ts | 13 ++++++++++++- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.html b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.html index 1695b0279..b1039ad18 100644 --- a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.html +++ b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.html @@ -11,7 +11,7 @@