From ca4244f145a1ebcb18e6717dbb18fbcaf931d080 Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 26 May 2025 15:24:38 +0800 Subject: [PATCH 01/27] Revert "Revert "[CORE-7496] 2.4.y/assessment pagination trunk"" This reverts commit 9326c7446418d67836ca213e6cfdb9bb0e3ed41a. --- .../assessment/assessment.component.html | 50 +++- .../assessment/assessment.component.scss | 152 +++++++++++ .../assessment/assessment.component.ts | 250 +++++++++++++++++- .../bottom-action-bar.component.html | 36 ++- .../bottom-action-bar.component.scss | 88 +++++- .../bottom-action-bar.component.ts | 1 + .../activity-desktop.page.html | 6 +- .../activity-desktop/activity-desktop.page.ts | 2 + .../v3/src/app/services/shared.service.ts | 26 +- 9 files changed, 581 insertions(+), 30 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index ce40504a4..d296d798e 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.html +++ b/projects/v3/src/app/components/assessment/assessment.component.html @@ -61,6 +61,7 @@
+
- +

@@ -114,7 +115,7 @@

- + +
+ + (handleClick)="continueToNextTask()" + [hasCustomContent]="true"> + +
+ + + Prev + + +
+ +
+ {{ i + 1 }} + + +
+
+
+ + + Next + + +
+
diff --git a/projects/v3/src/app/components/assessment/assessment.component.scss b/projects/v3/src/app/components/assessment/assessment.component.scss index 870debc66..866245f59 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.scss +++ b/projects/v3/src/app/components/assessment/assessment.component.scss @@ -211,3 +211,155 @@ ion-footer { padding-left: 3em; } } + +// styles for pagination section +.pagination-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 70%; + padding: 8px 4px; + background-color: transparent; + box-shadow: none; +} + +.nav-button { + display: flex; + align-items: center; + --color: var(--ion-color-primary); + font-weight: 500; + min-width: 60px; + z-index: 2; + font-size: 14px; + padding: 0 4px; +} + +.prev-button ion-icon { + margin-right: 2px; + font-size: 16px; +} + +.next-button ion-icon { + margin-left: 2px; + font-size: 16px; +} + +.page-indicators { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; // center items by design + gap: 6px; + overflow-x: visible; + max-width: 60%; + padding: 4px 0; + scrollbar-width: none; + scroll-behavior: smooth; + position: relative; + + // Apply mask only when pages are many + &.many-pages { + overflow-x: auto; + justify-content: flex-start; // Align to start when scrolling + mask-image: linear-gradient(to right, transparent, black 10px, black 90%, transparent); + -webkit-mask-image: linear-gradient(to right, transparent, black 10px, black 90%, transparent); + } +} + +.page-indicators::-webkit-scrollbar { + display: none; +} + +.page-indicator { + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + min-height: 24px; + border-radius: 50%; + background-color: #f5f5f5; + cursor: pointer; + position: relative; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.page-indicator:hover { + background-color: #e0e0e0; +} + +.page-indicator.active { + background-color: var(--ion-color-primary); + transform: scale(1.1); + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.page-indicator.active .page-number { + color: white; + font-weight: bold; +} + +.page-indicator.completed { + background-color: #e8f5e9; + border: 1px solid #81c784; +} + +.page-indicator.incompleted { + background-color: rgba(255, 0, 0, 0.05); + border: 1px solid var(--ion-color-danger); +} + +.page-indicator.incompleted .page-number { + color: var(--ion-color-danger); +} + +.page-number { + font-size: 12px; + font-weight: 500; + color: #555555; +} + +.completion-icon { + position: absolute; + font-size: 10px; + color: var(--ion-color-danger); + bottom: -3px; + right: -3px; + background: white; + border-radius: 50%; + z-index: 1; +} + +@media (max-width: 576px) { + .page-indicators { + max-width: 50%; + } + + .page-indicator { + min-width: 22px; + min-height: 22px; + } + + .page-number { + font-size: 11px; + } + + .nav-button { + min-width: 50px; + font-size: 12px; + } +} + +// space for submit button (mid to large screens) +@media (min-width: 769px) and (max-width: 1344px) { + .pagination-container { + max-width: calc(100% - 150px); + } +} + +@media (min-width: 1345px) { + .pagination-container { + max-width: calc(100% - 40px); + } +} diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index 87c7cc850..04bea8b00 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -1,5 +1,5 @@ import { environment } from '@v3/environments/environment'; -import { Component, Input, Output, EventEmitter, OnChanges, OnDestroy, OnInit, QueryList, ViewChildren, ChangeDetectionStrategy, ViewChild, signal } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnChanges, OnDestroy, OnInit, QueryList, ViewChildren, ChangeDetectionStrategy, ViewChild, signal, ElementRef } from '@angular/core'; import { Assessment, Submission, AssessmentReview, AssessmentSubmitParams, Question, AssessmentService } from '@v3/services/assessment.service'; import { UtilsService } from '@v3/services/utils.service'; import { NotificationsService } from '@v3/services/notifications.service'; @@ -19,6 +19,7 @@ import { ActivityService } from '@v3/app/services/activity.service'; import { FileInput, SubmitActions } from '../types/assessment'; import { FileUploadComponent } from '../file-upload/file-upload.component'; +const MIN_SCROLLING_PAGES = 6; // minimum number of pages to show pagination scrolling @Component({ selector: 'app-assessment', templateUrl: './assessment.component.html', @@ -117,7 +118,13 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { questionsForm?: FormGroup = new FormGroup({}); @ViewChild('form') form?: HTMLFormElement; + + // pagination + pageRequiredCompletion: boolean[] = []; // indicator for required questions + readonly manyPages = MIN_SCROLLING_PAGES; + @ViewChildren('questionBox') questionBoxes!: QueryList<{el: HTMLElement}>; + @ViewChild('pageIndicatorsContainer') pageIndicatorsContainer: ElementRef; // prevent non participants from submitting team assessment get preventSubmission() { @@ -140,6 +147,46 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { } + pageSize = 8; // number of questions per page + pageIndex = 0; + + // each entry is a page: an array of (partial) groups + pagesGroups: { name: string; description?: string; questions: Question[] }[][] = []; + + // override to use question‑based pages + get pageCount() { + return this.pagesGroups.length; + } + + get pagedGroups() { + return this.pagesGroups[this.pageIndex] || []; + } + + prevPage() { + if (this.pageIndex > 0) { + this.pageIndex--; + this.scrollActivePageIntoView(); + } + } + + nextPage() { + if (this.pageIndex < this.pageCount - 1) { + this.pageIndex++; + this.scrollActivePageIntoView(); + } + } + + get pages(): number[] { + return Array(this.pageCount).fill(0).map((_, i) => i); + } + + goToPage(i: number) { + if (i >= 0 && i < this.pageCount) { + this.pageIndex = i; + this.scrollActivePageIntoView(); + } + } + ngOnInit(): void { this.subscribeSaveSubmission(); } @@ -341,6 +388,13 @@ Best regards`; this._handleSubmissionData(); this._handleReviewData(); this._preventSubmission(); + + // split by question count every time assessment changes + this.pagesGroups = this.splitGroupsByQuestionCount(); + this.pageIndex = 0; + + // Ensure we scroll the active page into view after rendering + setTimeout(() => this.scrollActivePageIntoView(), 200); } ngOnDestroy() { @@ -377,6 +431,11 @@ Best regards`; this.questionsForm.addControl('q-' + question.id, new FormControl('', validator)); }); }); + + this.questionsForm.valueChanges.subscribe((form) => { + this.initializePageCompletion(); + this.btnDisabled$.next(this.questionsForm.invalid); + }); } /** @@ -811,4 +870,193 @@ Best regards`; this.flashBlink(element); } } + + /** + * Breaks original groups into pages, each containing ≤ pageSize questions. + * If a single group has more questions than pageSize, it gets sliced. + */ + private splitGroupsByQuestionCount() { + const pages = []; + let currentPage = []; + let count = 0; + + for (const group of this.assessment.groups) { + const qCount = group.questions.length; + + if (count + qCount <= this.pageSize) { + currentPage.push(group); + count += qCount; + + } else { + // flush current page + if (currentPage.length) { + pages.push(currentPage); + } + currentPage = []; + count = 0; + + // if group itself is too big, slice it across multiple pages + if (qCount > this.pageSize) { + let start = 0; + while (start < qCount) { + const slice = { + ...group, + questions: group.questions.slice(start, start + this.pageSize) + }; + pages.push([slice]); + start += this.pageSize; + } + } else { + currentPage.push(group); + count = qCount; + } + } + } + + if (currentPage.length) { + pages.push(currentPage); + } + return pages; + } + + trackById(index: number, item: any) { + return item.id || index; + } + + initializePageCompletion() { + this.pageRequiredCompletion = new Array(this.pageCount).fill(true); + + this.pages.forEach((page, index) => { + const pageQuestions = this.getAllQuestionsForPage(index); + this.pageRequiredCompletion[index] = this.areAllRequiredQuestionsAnswered(pageQuestions); + }); + + // Update the scroll position when page completion status changes + setTimeout(() => this.scrollActivePageIntoView(), 100); + } + + private getAllQuestionsForPage(pageIndex: number): Question[] { + if (!this.pagesGroups[pageIndex]) { + return []; + } + + const allQuestionsOnPage: Question[] = []; + this.pagesGroups[pageIndex].forEach(group => { + if (group.questions && group.questions.length) { + allQuestionsOnPage.push(...group.questions); + } + }); + + return allQuestionsOnPage; + } + + private areAllRequiredQuestionsAnswered(questions: Question[]): boolean { + if (!questions.length) { + return true; // If no questions, consider it complete + } + + // Only check required questions + const requiredQuestions = questions.filter(question => this._isRequired(question)); + + // If no required questions, page is considered complete + if (requiredQuestions.length === 0) { + return true; + } + + // Check each required question if it has a valid answer + return requiredQuestions.every(question => { + const control = this.questionsForm?.controls[`q-${question.id}`]; + + if (!control || control.invalid) { + return false; + } + + const value = control.value; + if (Array.isArray(value)) { + // multi choice questions + return value.length > 0; + } else if (typeof value === 'object' && value !== null) { + // review questions with answer and comment fields + return value.answer !== undefined && value.answer !== null && value.answer !== ''; + } else { + // text / one off questions + return value !== undefined && value !== null && value !== ''; + } + }); + } + + /** + * Find the first unanswered required question and navigate to it. + * @returns {boolean} + * true: if an unanswered question was found and navigated to + * false: if all required questions are answered. + */ + findAndGoToFirstUnansweredQuestion(): boolean { + // Get all questions for the current page + const currentPageQuestions = this.getAllQuestionsForPage(this.pageIndex); + + // Filter only the required questions + const requiredQuestions = currentPageQuestions.filter(question => this._isRequired(question)); + + // Find the first unanswered required question + const unansweredQuestion = requiredQuestions.find(question => { + const control = this.questionsForm?.controls[`q-${question.id}`]; + if (!control || control.invalid) { + return true; // This question is unanswered + } + + const value = control.value; + if (Array.isArray(value)) { + return value.length === 0; // Multi-choice question with no selections + } else if (typeof value === 'object' && value !== null) { + return !value.answer || value.answer === ''; // Review question with empty answer + } else { + return !value || value === ''; // Text/one-off question with empty value + } + }); + + // If found an unanswered question, navigate to it + if (unansweredQuestion) { + const questionIndex = currentPageQuestions.findIndex(q => q.id === unansweredQuestion.id); + if (questionIndex >= 0) { + this.goToQuestion(questionIndex); + return true; // Indicates we found and navigated to an unanswered question + } + } + + return false; + } + + goToQuestion(index: number) { + const questionBoxes = this.getQuestionBoxes(); + if (questionBoxes && questionBoxes.length > 0) { + const questionBox = questionBoxes.toArray()[index]; + if (questionBox) { + this.utils.scrollToElement(questionBox.el); + this.flashBlink(questionBox.el); + } + } + } + + /** + * Scrolls the active page indicator into view within the pagination container + */ + scrollActivePageIntoView() { + setTimeout(() => { + if (this.pageIndicatorsContainer && this.pageCount > this.manyPages) { + const container = this.pageIndicatorsContainer.nativeElement; + const activeIndicator = document.getElementById(`page-indicator-${this.pageIndex}`); + + if (activeIndicator && container) { + // Calculate the scroll position to center the active indicator + const containerWidth = container.offsetWidth; + const indicatorWidth = activeIndicator.offsetWidth; + const indicatorLeft = activeIndicator.offsetLeft; + + // Scroll to position the active indicator in the center + container.scrollLeft = indicatorLeft - (containerWidth / 2) + (indicatorWidth / 2); + } + } + }, 50); + } } diff --git a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html index c7cf7a08c..421e46d7d 100644 --- a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html +++ b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html @@ -4,20 +4,28 @@

Team assessment submissions restricted to participants only.

- {{ text }} - Resubmit - +
+ + + + +
+ {{ text }} + Resubmit +
+
+ diff --git a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss index d5e1ae954..5e3670058 100644 --- a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss +++ b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss @@ -2,13 +2,95 @@ position: sticky; bottom: 0; width: 100%; - display: flex; + display: block; justify-content: center; - border-top: 1px solid var(--practera-grey-25); - background: white; + border-top: 1px solid var(--ion-color-light-shade); + background: var(--ion-color-light); + color: var(--ion-color-dark); + padding: 8px 0; ion-button.ion-focused { border: 1px solid black; border-radius: 8px; } } + +.action-container { + display: flex; + flex-direction: row; // Changed from column to row + align-items: center; // Center items vertically + width: 100%; + justify-content: space-between; // Space between pagination and buttons +} + +.custom-content { + width: 100%; + display: flex; + justify-content: space-between; + flex: 1; // Take available space + margin-right: 10px; // Add some space between pagination and button +} + +.button-container { + display: flex; + justify-content: flex-end; + align-items: center; + white-space: nowrap; // Prevent button text wrapping + width: 100%; + + &.with-custom-content { + margin-top: 0; // Remove top margin + } +} + +.action-button { + margin: 0 8px; // Reduce side margins + --padding-start: 16px; + --padding-end: 16px; + min-width: 120px; // Ensure minimum button width +} + +@media (max-width: 768px) { + .action-container { + flex-direction: column; // Stack vertically on smaller screens + } + + .custom-content { + margin-right: 0; + margin-bottom: 8px; + } + + .button-container { + width: 100%; + justify-content: center; // Center buttons on small screens + } + + .action-button { + --padding-start: 12px; + --padding-end: 12px; + min-width: auto; + } +} + +@media (max-width: 576px) { + .action-button { + --padding-start: 12px; + --padding-end: 12px; + font-size: 14px; // Smaller font on mobile + height: 36px; + } + + .wrapper { + padding: 6px 0; + } +} + +// For screens that are super small (iPhone SE etc.) +@media (max-width: 375px) { + .action-button { + font-size: 14px; + --padding-start: 8px; + --padding-end: 8px; + height: 32px; + } +} diff --git a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.ts b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.ts index a3fe94e9d..abc58f5b6 100644 --- a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.ts +++ b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.ts @@ -14,6 +14,7 @@ export class BottomActionBarComponent { @Output() handleClick = new EventEmitter(); @Output() handleResubmit = new EventEmitter(); @Input() buttonType: string = ''; + @Input() hasCustomContent: boolean = false; constructor() {} diff --git a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.html b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.html index 9a6c84280..e49494660 100644 --- a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.html +++ b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.html @@ -19,7 +19,7 @@ - + @@ -42,7 +42,7 @@ - + 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 9407be7e4..f7bdced78 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 @@ -35,6 +35,8 @@ export class ActivityDesktopPage { // loading overlay for assessment isLoadingAssessment: boolean = false; + longAsmtNavigator: boolean = false; // disable fab navigator on long assessment + // grabs from URL parameter urlParams = { action: null, diff --git a/projects/v3/src/app/services/shared.service.ts b/projects/v3/src/app/services/shared.service.ts index 43c7fe686..59a26cdb4 100644 --- a/projects/v3/src/app/services/shared.service.ts +++ b/projects/v3/src/app/services/shared.service.ts @@ -4,12 +4,13 @@ import { UtilsService } from '@v3/services/utils.service'; import { BrowserStorageService } from '@v3/services/storage.service'; import { NotificationsService } from './notifications.service'; import { HttpClient } from '@angular/common/http'; -import { BehaviorSubject, Observable, first, firstValueFrom } from 'rxjs'; +import { BehaviorSubject, Observable, of, first, firstValueFrom } from 'rxjs'; import { TopicService } from '@v3/services/topic.service'; import { ApolloService } from '@v3/services/apollo.service'; import { PusherService } from '@v3/services/pusher.service'; import { map } from 'rxjs/operators'; import { AchievementService } from './achievement.service'; +import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' @@ -148,14 +149,27 @@ export class SharedService { * Get the user's current location from IP */ getIpLocation() { - this._ipAPI().pipe(first()).subscribe( - res => this.storage.setCountry(res.country_name), - // eslint-disable-next-line no-console - err => console.log(err) - ); + this._ipAPI().pipe(first()).subscribe({ + next: res => this.storage.setCountry(res.country_name), + error: err => console.error(err) + }); } private _ipAPI(): Observable { + if (environment.production !== true) { + // mock data for development mode + return of({ + ip: '127.0.0.1', + city: 'Development City', + region: 'Development Region', + country_name: 'Development Country', + postal: '00000', + latitude: 0, + longitude: 0, + timezone: 'UTC' + }); + } + return this.http.get('https://ipapi.co/json'); } From 5617ab843b9db74662f876918c7b2bf9ebdb1d58 Mon Sep 17 00:00:00 2001 From: trtshen Date: Fri, 23 May 2025 18:55:18 +0800 Subject: [PATCH 02/27] [CORE-7496] when page is 1 --- .../v3/src/app/components/assessment/assessment.component.html | 2 +- .../bottom-action-bar/bottom-action-bar.component.scss | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index d296d798e..1a44e4d60 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.html +++ b/projects/v3/src/app/components/assessment/assessment.component.html @@ -316,7 +316,7 @@

+ [hasCustomContent]="pageCount > 1">
Date: Mon, 26 May 2025 15:04:10 +0800 Subject: [PATCH 03/27] [CORE-7496] improved validation by onChange --- .../assessment/assessment.component.scss | 16 +++++--- .../assessment/assessment.component.ts | 41 ++++++++++++++++++- .../bottom-action-bar.component.scss | 15 ++++--- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.scss b/projects/v3/src/app/components/assessment/assessment.component.scss index 866245f59..f75e44288 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.scss +++ b/projects/v3/src/app/components/assessment/assessment.component.scss @@ -332,13 +332,17 @@ ion-footer { } @media (max-width: 576px) { - .page-indicators { - max-width: 50%; - } + .pagination-container { + padding: 0;; - .page-indicator { - min-width: 22px; - min-height: 22px; + .page-indicators { + max-width: 50%; + + .page-indicator { + min-width: 22px; + min-height: 22px; + } + } } .page-number { diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index 20c724b2e..15f819a45 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -393,7 +393,9 @@ Best regards`; this.pagesGroups = this.splitGroupsByQuestionCount(); this.pageIndex = 0; - // Ensure we scroll the active page into view after rendering + this._populateFormWithAnswers(); + + // scroll to the active page into view after rendering setTimeout(() => this.scrollActivePageIntoView(), 200); } @@ -432,7 +434,9 @@ Best regards`; }); }); - this.questionsForm.valueChanges.subscribe((form) => { + this.questionsForm.valueChanges.pipe( + takeUntil(this.unsubscribe$), + ).subscribe(() => { this.initializePageCompletion(); this.btnDisabled$.next(this.questionsForm.invalid); }); @@ -1071,4 +1075,37 @@ Best regards`; } }, 50); } + + private _populateFormWithAnswers() { + // Populate form with submission answers + if (this.submission?.answers) { + Object.keys(this.submission.answers).forEach(questionId => { + const controlName = 'q-' + questionId; + const control = this.questionsForm.get(controlName); + if (control && this.submission.answers[questionId]?.answer !== undefined) { + control.setValue(this.submission.answers[questionId].answer, { emitEvent: false }); + } + }); + } + + // Populate form with review answers + if (this.review?.answers) { + Object.keys(this.review.answers).forEach(questionId => { + const controlName = 'q-' + questionId; + const control = this.questionsForm.get(controlName); + if (control && this.review.answers[questionId]) { + const reviewAnswer = { + answer: this.review.answers[questionId].answer, + comment: this.review.answers[questionId].comment + }; + control.setValue(reviewAnswer, { emitEvent: false }); + } + }); + } + + // Initialize page completion after form is populated + setTimeout(() => { + this.initializePageCompletion(); + }, 100); + } } diff --git a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss index 2995fed05..45d4b8f19 100644 --- a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss +++ b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.scss @@ -39,7 +39,7 @@ width: 100%; &.with-custom-content { - margin-top: 0; // Remove top margin + margin-top: 0; justify-content: flex-end; } } @@ -51,9 +51,10 @@ min-width: 120px; // Ensure minimum button width } +// mobile responsiveness @media (max-width: 768px) { .action-container { - flex-direction: column; // Stack vertically on smaller screens + flex-direction: column; // Stack vertically } .custom-content { @@ -63,7 +64,7 @@ .button-container { width: 100%; - justify-content: center; // Center buttons on small screens + justify-content: center !important; } .action-button { @@ -74,7 +75,7 @@ } @media (max-width: 576px) { - .action-button { + .with-custom-content > .action-button { --padding-start: 12px; --padding-end: 12px; font-size: 14px; // Smaller font on mobile @@ -82,16 +83,14 @@ } .wrapper { - padding: 6px 0; + padding: 3px 0; } } // For screens that are super small (iPhone SE etc.) @media (max-width: 375px) { - .action-button { + .with-custom-content > .action-button { font-size: 14px; - --padding-start: 8px; - --padding-end: 8px; height: 32px; } } From 7aa1c8ea4a4d4269bc2cce75a2bbc2857a2b2e68 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 5 Jun 2025 10:41:52 +0800 Subject: [PATCH 04/27] [CORE-7496] hide as disabled to spare btn space --- .../bottom-action-bar/bottom-action-bar.component.html | 3 ++- .../bottom-action-bar/bottom-action-bar.component.scss | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html index 421e46d7d..dd4360b66 100644 --- a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html +++ b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html @@ -9,7 +9,8 @@ -
+
.action-button { --padding-start: 12px; --padding-end: 12px; - font-size: 14px; // Smaller font on mobile + font-size: 14px; height: 36px; } .wrapper { padding: 3px 0; } + + .button-container:has(ion-button.button-disabled) { + display: none; + } } // For screens that are super small (iPhone SE etc.) From bec3eef57eba05b8353c4b93830209b51d3eded4 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 19 Jun 2025 10:08:05 +0800 Subject: [PATCH 05/27] [CORE-7496] question logic fixes --- .../assessment/assessment.component.ts | 3 +- .../bottom-action-bar.component.html | 2 +- .../multi-team-member-selector.component.html | 2 +- .../multi-team-member-selector.component.ts | 14 +- .../multiple/multiple.component.html | 2 +- .../components/multiple/multiple.component.ts | 7 +- .../app/components/oneof/oneof.component.html | 2 +- .../app/components/oneof/oneof.component.ts | 9 +- .../team-member-selector.component.html | 2 +- .../team-member-selector.component.ts | 3 +- .../components/text/text.component.spec.ts | 364 +++++++++++++++++- .../src/app/components/text/text.component.ts | 6 +- 12 files changed, 387 insertions(+), 29 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index 15f819a45..77d6e76ef 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -6,7 +6,7 @@ import { NotificationsService } from '@v3/services/notifications.service'; import { FormGroup, FormControl, Validators } from '@angular/forms'; import { BrowserStorageService } from '@v3/services/storage.service'; import { SharedService } from '@v3/services/shared.service'; -import { BehaviorSubject, Observable, of, Subject, Subscription, timer } from 'rxjs'; +import { BehaviorSubject, debounceTime, Observable, of, Subject, Subscription, timer } from 'rxjs'; import { concatMap, take, delay, filter, takeUntil, tap } from 'rxjs/operators'; import { trigger, state, style, animate, transition } from '@angular/animations'; import { TextComponent } from '../text/text.component'; @@ -436,6 +436,7 @@ Best regards`; this.questionsForm.valueChanges.pipe( takeUntil(this.unsubscribe$), + debounceTime(300), ).subscribe(() => { this.initializePageCompletion(); this.btnDisabled$.next(this.questionsForm.invalid); diff --git a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html index dd4360b66..e61430fa3 100644 --- a/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html +++ b/projects/v3/src/app/components/bottom-action-bar/bottom-action-bar.component.html @@ -1,6 +1,6 @@ - +

Team assessment submissions restricted to participants only.

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 f16e54843..5f8281347 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 @@ -37,7 +37,7 @@

{ lines='none'> {{question.
- diff --git a/projects/v3/src/app/components/oneof/oneof.component.ts b/projects/v3/src/app/components/oneof/oneof.component.ts index 0314cc7d5..03c847e27 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.ts +++ b/projects/v3/src/app/components/oneof/oneof.component.ts @@ -150,7 +150,7 @@ export class OneofComponent implements AfterViewInit, ControlValueAccessor, OnIn // adding save values to from control private _showSavedAnswers() { - if ((['in progress', 'not start'].includes(this.reviewStatus)) && (this.doReview)) { + if ((['in progress', 'not start'].includes(this.reviewStatus)) && this.doReview) { this.innerValue = { answer: '', comment: '' @@ -159,11 +159,12 @@ export class OneofComponent implements AfterViewInit, ControlValueAccessor, OnIn this.comment = this.review.comment; this.innerValue.answer = this.review.answer; } - if ((this.submissionStatus === 'in progress') && (this.doAssessment)) { - this.innerValue = this.submission.answer; + + if ((this.submissionStatus === 'in progress') && this.doAssessment) { + this.innerValue = this.control.pristine ? this.submission.answer : this.control.value; } + this.propagateChange(this.innerValue); - this.control.setValue(this.innerValue); } // check question audience have more that one audience and is it includes reviewer as audience. diff --git a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html index d01af9047..02cf7563e 100644 --- a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html +++ b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html @@ -34,7 +34,7 @@

{ let component: TextComponent; @@ -22,6 +24,11 @@ describe('TextComponent', () => { component = fixture.componentInstance; component.answer = new FormControl(''); component.comment = new FormControl(''); + component.submitActions$ = new Subject(); + component.control = new FormControl(''); + + // Mock console.log to avoid test output + spyOn(console, 'log'); }); it('should create', () => { @@ -113,5 +120,358 @@ describe('TextComponent', () => { component.registerOnTouched(() => true); }); + describe('when testing triggerSave()', () => { + beforeEach(() => { + spyOn(component.submitActions$, 'next'); + }); + + it('should trigger save for review mode', () => { + component.doReview = true; + component.doAssessment = false; + component.reviewId = 1; + component.submissionId = 2; + component.question = { + id: 3, + name: 'test', + type: 'text', + description: 'test', + isRequired: false, + canComment: true, + canAnswer: true, + audience: [] + }; + component.innerValue = { answer: 'test answer', comment: 'test comment' }; + + component.triggerSave(); + + expect(component.submitActions$.next).toHaveBeenCalledWith({ + autoSave: true, + goBack: false, + reviewSave: { + reviewId: 1, + submissionId: 2, + questionId: 3, + answer: 'test answer', + comment: 'test comment' + } + }); + }); + + it('should trigger save for assessment mode', () => { + component.doReview = false; + component.doAssessment = true; + component.submissionId = 2; + component.question = { + id: 3, + name: 'test', + type: 'text', + description: 'test', + isRequired: false, + canComment: true, + canAnswer: true, + audience: [] + }; + component.answer = new FormControl('test answer'); + + component.triggerSave(); + + expect(component.submitActions$.next).toHaveBeenCalledWith({ + autoSave: true, + goBack: false, + questionSave: { + submissionId: 2, + questionId: 3, + answer: component.answer + } + }); + }); + + it('should trigger save for both review and assessment mode', () => { + component.doReview = true; + component.doAssessment = true; + component.reviewId = 1; + component.submissionId = 2; + component.question = { + id: 3, + name: 'test', + type: 'text', + description: 'test', + isRequired: false, + canComment: true, + canAnswer: true, + audience: [] + }; + component.innerValue = { answer: 'test answer', comment: 'test comment' }; + component.answer = new FormControl('test answer'); + + component.triggerSave(); + + expect(component.submitActions$.next).toHaveBeenCalledWith({ + autoSave: true, + goBack: false, + reviewSave: { + reviewId: 1, + submissionId: 2, + questionId: 3, + answer: 'test answer', + comment: 'test comment' + }, + questionSave: { + submissionId: 2, + questionId: 3, + answer: component.answer + } + }); + }); + }); + + describe('when testing ngAfterViewInit()', () => { + it('should set up auto-save subscription when answerRef is available', fakeAsync(() => { + const mockIonInput = { + pipe: jasmine.createSpy('pipe').and.returnValue({ + subscribe: jasmine.createSpy('subscribe').and.returnValue({ closed: false, unsubscribe: () => {} }) + }) + }; + + component.answerRef = { ionInput: of('test') } as any; + spyOn(component, 'triggerSave'); + + component.ngAfterViewInit(); + tick(900); + + expect(component.subcriptions.length).toBeGreaterThan(0); + })); + + it('should not set up subscription when answerRef is not available', () => { + component.answerRef = null; + component.ngAfterViewInit(); + expect(component.subcriptions.length).toBe(0); + }); + }); + + describe('when testing ngOnDestroy()', () => { + it('should unsubscribe all subscriptions', () => { + const mockSubscription1 = jasmine.createSpyObj('Subscription', ['unsubscribe'], { closed: false }); + const mockSubscription2 = jasmine.createSpyObj('Subscription', ['unsubscribe'], { closed: true }); + + component.subcriptions = [mockSubscription1, mockSubscription2] as any; + + component.ngOnDestroy(); + + expect(mockSubscription1.unsubscribe).toHaveBeenCalled(); + expect(mockSubscription2.unsubscribe).not.toHaveBeenCalled(); + }); + }); + + describe('when testing onFocus()', () => { + it('should handle IE/Edge text reversal for empty textarea', () => { + const mockTextarea = { + value: '', + setSelectionRange: jasmine.createSpy('setSelectionRange') + }; + const mockEvent = { + target: { firstChild: mockTextarea } + }; + + spyOnProperty(window.navigator, 'userAgent', 'get').and.returnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59'); + + component.onFocus(mockEvent); + + expect(mockTextarea.setSelectionRange).toHaveBeenCalled(); + }); + + it('should handle IE/Edge text reversal for textarea with existing text', () => { + const mockTextarea = { + value: 'existing text', + setSelectionRange: jasmine.createSpy('setSelectionRange') + }; + const mockEvent = { + target: { firstChild: mockTextarea } + }; + + spyOnProperty(window.navigator, 'userAgent', 'get').and.returnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0 Edge/91.0.864.59'); + + component.onFocus(mockEvent); + + expect(mockTextarea.setSelectionRange).toHaveBeenCalled(); + expect(mockTextarea.value).toBe('existing text'); + }); + + it('should not handle text reversal for non-IE/Edge browsers', () => { + const mockTextarea = { + value: 'existing text', + setSelectionRange: jasmine.createSpy('setSelectionRange') + }; + const mockEvent = { + target: { firstChild: mockTextarea } + }; + + spyOnProperty(window.navigator, 'userAgent', 'get').and.returnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'); + + component.onFocus(mockEvent); + + expect(mockTextarea.setSelectionRange).not.toHaveBeenCalled(); + }); + }); + + describe('when testing audienceContainReviewer()', () => { + it('should return true when audience contains reviewer and has multiple audiences', () => { + component.question = { + audience: ['student', 'reviewer'], + id: 1, + name: 'test', + type: 'text', + description: 'test', + isRequired: false, + canComment: true, + canAnswer: true + }; + + expect(component.audienceContainReviewer()).toBe(true); + }); + + it('should return false when audience contains reviewer but only one audience', () => { + component.question = { + audience: ['reviewer'], + id: 1, + name: 'test', + type: 'text', + description: 'test', + isRequired: false, + canComment: true, + canAnswer: true + }; + + expect(component.audienceContainReviewer()).toBe(false); + }); + + it('should return false when audience does not contain reviewer', () => { + component.question = { + audience: ['student', 'mentor'], + id: 1, + name: 'test', + type: 'text', + description: 'test', + isRequired: false, + canComment: true, + canAnswer: true + }; + + expect(component.audienceContainReviewer()).toBe(false); + }); + }); + + describe('when testing _showSavedAnswers() edge cases', () => { + const dummyQuestion = { + id: 1, + name: '', + type: '', + description: '', + isRequired: true, + canComment: false, + canAnswer: true, + choices: [], + audience: [] + }; + + it('should handle review with "not start" status', () => { + component.question = dummyQuestion; + component.submissionStatus = 'pending review'; + component.doAssessment = false; + component.submission = { answer: 'abc' }; + component.reviewStatus = 'not start'; + component.doReview = true; + component.review = { + comment: 'test comment', + answer: 'test answer' + }; + component.control = new FormControl(''); + + // Use ngOnInit to trigger _showSavedAnswers() + component.ngOnInit(); + + expect(component.innerValue.comment).toEqual('test comment'); + expect(component.innerValue.answer).toEqual('test answer'); + expect(component.comment).toEqual('test comment'); + expect(component.answer).toEqual('test answer'); + }); + + it('should not set values when conditions are not met', () => { + component.question = dummyQuestion; + component.submissionStatus = 'submitted'; + component.doAssessment = false; + component.reviewStatus = 'completed'; + component.doReview = false; + component.control = new FormControl('test'); + + component.ngOnInit(); + + expect(component.control.value).toBe('test'); + }); + + it('should handle missing review data gracefully', () => { + component.question = dummyQuestion; + component.reviewStatus = 'in progress'; + component.doReview = true; + component.review = {}; + component.control = new FormControl(''); + + component.ngOnInit(); + + expect(component.innerValue).toEqual({ answer: [], comment: '' }); + }); + + it('should handle missing submission data gracefully', () => { + component.question = dummyQuestion; + component.submissionStatus = 'in progress'; + component.doAssessment = true; + component.submission = {}; + component.control = new FormControl(''); + + component.ngOnInit(); + + expect(component.innerValue).toBeUndefined(); + }); + }); + + describe('when testing onChange() edge cases', () => { + it('should handle onChange when innerValue is not initialized for review', () => { + component.innerValue = null; + component.answer = new FormControl('new answer'); + component.onChange('answer'); + + expect(component.innerValue).toEqual({ answer: 'new answer', comment: '' }); + }); + + it('should propagate changes correctly', () => { + spyOn(component, 'propagateChange'); + component.answer = new FormControl('test'); + + component.onChange(); + + expect(component.propagateChange).toHaveBeenCalledWith(component.answer); + }); + }); + + describe('when testing writeValue() edge cases', () => { + it('should not set innerValue when value is null', () => { + component.innerValue = 'existing'; + component.writeValue(null); + expect(component.innerValue).toBe('existing'); + }); + + it('should not set innerValue when value is undefined', () => { + component.innerValue = 'existing'; + component.writeValue(undefined); + expect(component.innerValue).toBe('existing'); + }); + + it('should not set innerValue when value is empty string', () => { + component.innerValue = 'existing'; + component.writeValue(''); + expect(component.innerValue).toBe('existing'); + }); + }); + }); diff --git a/projects/v3/src/app/components/text/text.component.ts b/projects/v3/src/app/components/text/text.component.ts index ba399c155..abd3c864b 100644 --- a/projects/v3/src/app/components/text/text.component.ts +++ b/projects/v3/src/app/components/text/text.component.ts @@ -192,12 +192,14 @@ export class TextComponent implements ControlValueAccessor, OnInit, AfterViewIni this.innerValue.answer = this.review.answer; this.answer = this.review.answer; } + if ((this.submissionStatus === 'in progress') && (this.doAssessment)) { this.innerValue = this.submission.answer; this.answer = this.submission.answer; } - this.propagateChange(this.innerValue); - this.control.setValue(this.innerValue); + + this.propagateChange(this.control.value || this.innerValue); + this.control.setValue(this.control.value || this.innerValue); } // check question audience have more that one audience and is it includes reviewer as audience. From a296eeb5bab78c267fc457bd22f75987e8de690a Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 19 Jun 2025 14:10:08 +0800 Subject: [PATCH 06/27] [CORE-7496] missing text updates --- .../app/components/text/text.component.html | 4 +-- .../src/app/components/text/text.component.ts | 26 ++++--------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/projects/v3/src/app/components/text/text.component.html b/projects/v3/src/app/components/text/text.component.html index b6edcde0e..84fe1653a 100644 --- a/projects/v3/src/app/components/text/text.component.html +++ b/projects/v3/src/app/components/text/text.component.html @@ -79,7 +79,7 @@

@@ -100,7 +100,7 @@

Date: Thu, 19 Jun 2025 15:45:31 +0800 Subject: [PATCH 07/27] [CORE-7496] review has both answer & comment --- .../app/components/text/text.component.html | 4 +- .../src/app/components/text/text.component.ts | 38 ++++++++++--------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/projects/v3/src/app/components/text/text.component.html b/projects/v3/src/app/components/text/text.component.html index 84fe1653a..b6edcde0e 100644 --- a/projects/v3/src/app/components/text/text.component.html +++ b/projects/v3/src/app/components/text/text.component.html @@ -79,7 +79,7 @@

@@ -100,7 +100,7 @@

1 && this.question.audience.includes('reviewer'); } From 94a3dcb0192e2c2aa59e6f5d2b1805ef7cd3e38b Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 19 Jun 2025 15:45:31 +0800 Subject: [PATCH 08/27] [CORE-7496] review has both answer & comment --- projects/v3/src/app/components/text/text.component.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/projects/v3/src/app/components/text/text.component.ts b/projects/v3/src/app/components/text/text.component.ts index aed960af8..816875022 100644 --- a/projects/v3/src/app/components/text/text.component.ts +++ b/projects/v3/src/app/components/text/text.component.ts @@ -26,22 +26,17 @@ export class TextComponent implements ControlValueAccessor, OnInit, AfterViewIni @Input() submissionId?: number; @Input() review; @Input() reviewId?: number; - // this is for review status @Input() reviewStatus; - // this is for assessment status @Input() submissionStatus; - // this is for doing an assessment or not @Input() doAssessment: Boolean; - // this is for doing review or not @Input() doReview: Boolean; - // FormControl that is passed in from parent component @Input() control: AbstractControl; + // answer field for submitter & reviewer @ViewChild('answerEle') answerRef: IonTextarea; // comment field for reviewer @ViewChild('commentEle') commentRef: ElementRef; - // the value of answer &| comment innerValue: any; answer: FormControl; comment: FormControl; From 824db6f56a43b5ec727a35b76251a76060d9b7e0 Mon Sep 17 00:00:00 2001 From: trtshen Date: Tue, 22 Jul 2025 11:02:57 +0800 Subject: [PATCH 09/27] prevent potential multiple click triggers on API --- .../app/components/topic/topic.component.ts | 35 ++++++++++++++----- .../pages/topic-mobile/topic-mobile.page.ts | 4 +-- .../src/app/services/fast-feedback.service.ts | 19 +++++----- .../v3/src/app/services/shared.service.ts | 8 ++--- projects/v3/src/app/services/topic.service.ts | 23 ++++++------ 5 files changed, 56 insertions(+), 33 deletions(-) diff --git a/projects/v3/src/app/components/topic/topic.component.ts b/projects/v3/src/app/components/topic/topic.component.ts index 0ffd713db..fde4065c9 100644 --- a/projects/v3/src/app/components/topic/topic.component.ts +++ b/projects/v3/src/app/components/topic/topic.component.ts @@ -1,5 +1,5 @@ import { Topic, TopicService } from '@v3/services/topic.service'; -import { Component, Input, Output, EventEmitter, Inject, OnChanges, SimpleChanges, OnDestroy } from '@angular/core'; +import { Component, Input, Output, EventEmitter, Inject, OnChanges, SimpleChanges, OnDestroy, OnInit } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { UtilsService } from '@v3/services/utils.service'; import { SharedService } from '@v3/services/shared.service'; @@ -8,7 +8,7 @@ import { EmbedVideoService } from '@v3/services/ngx-embed-video.service'; import { SafeHtml, DomSanitizer } from '@angular/platform-browser'; import { FilestackService } from '@v3/app/services/filestack.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; -import { BehaviorSubject, Subscription } from 'rxjs'; +import { BehaviorSubject, exhaustMap, filter, finalize, Subject, Subscription } from 'rxjs'; import { Activity, Task } from '@v3/app/services/activity.service'; import { ComponentCleanupService } from '@v3/app/services/component-cleanup.service'; @@ -17,7 +17,7 @@ import { ComponentCleanupService } from '@v3/app/services/component-cleanup.serv templateUrl: './topic.component.html', styleUrls: ['./topic.component.scss'] }) -export class TopicComponent implements OnChanges, OnDestroy { +export class TopicComponent implements OnInit, OnChanges, OnDestroy { @Input() topic: Topic; @Input() task: Task; continuing: boolean; @@ -32,6 +32,7 @@ export class TopicComponent implements OnChanges, OnDestroy { iframeHtml: SafeHtml; sanitizedTitle: SafeHtml; + private continueAction$ = new Subject(); private cleanupSub: Subscription; constructor( @@ -51,6 +52,25 @@ export class TopicComponent implements OnChanges, OnDestroy { }); } + ngOnInit() { + this.continueAction$.pipe( + filter(() => !this.continuing), + exhaustMap((topic) => { + this.continuing = true; + this.buttonDisabled$.next(true); + + this.continue.emit(topic); + + // 1sec cooldown to prevent multiple clicks + return new Promise(resolve => setTimeout(resolve, 1000)); + }), + finalize(() => { + this.continuing = false; + this.buttonDisabled$.next(false); + }) + ).subscribe(); + } + ngOnChanges(changes: SimpleChanges): void { this.continuing = false; if (this.topic) { @@ -70,6 +90,7 @@ export class TopicComponent implements OnChanges, OnDestroy { this.topicService.clearTopic(); this.cleanupMedia(); this.cleanupSub.unsubscribe(); + this.continueAction$.complete(); } ionViewWillLeave() { @@ -108,8 +129,8 @@ export class TopicComponent implements OnChanges, OnDestroy { private _setVideoUrlElelemts() { if (this.topic.videolink.includes('vimeo') || - this.topic.videolink.includes('youtube') || - this.topic.videolink.includes('youtu.be')) { + this.topic.videolink.includes('youtube') || + this.topic.videolink.includes('youtu.be')) { this.iframeHtml = this.embedService.embed(this.topic.videolink, { attr: { @@ -188,9 +209,7 @@ export class TopicComponent implements OnChanges, OnDestroy { } async actionBarContinue(topic): Promise { - this.continuing = true; - this.continue.emit(topic); - return; + this.continueAction$.next(topic); } handleVideoError(videoError) { diff --git a/projects/v3/src/app/pages/topic-mobile/topic-mobile.page.ts b/projects/v3/src/app/pages/topic-mobile/topic-mobile.page.ts index 4a3e111f6..70ac84a15 100644 --- a/projects/v3/src/app/pages/topic-mobile/topic-mobile.page.ts +++ b/projects/v3/src/app/pages/topic-mobile/topic-mobile.page.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivityService, Task } from '@v3/app/services/activity.service'; import { TopicService, Topic } from '@v3/app/services/topic.service'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; @Component({ selector: 'app-topic-mobile', @@ -52,7 +52,7 @@ export class TopicMobilePage implements OnInit { } // mark the topic as completer - await this.topicService.updateTopicProgress(this.topic.id, 'completed').toPromise(); + await firstValueFrom(this.topicService.updateTopicProgress(this.topic.id, 'completed')); // get the latest activity tasks and navigate to the next task return this.activityService.getActivity(this.activityId, true, this.currentTask, () => { this.btnDisabled$.next(false); diff --git a/projects/v3/src/app/services/fast-feedback.service.ts b/projects/v3/src/app/services/fast-feedback.service.ts index 86a415e57..59c112bf0 100644 --- a/projects/v3/src/app/services/fast-feedback.service.ts +++ b/projects/v3/src/app/services/fast-feedback.service.ts @@ -3,7 +3,7 @@ import { NotificationsService } from './notifications.service'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; import { of, from, Observable } from 'rxjs'; -import { switchMap, delay, take, retryWhen } from 'rxjs/operators'; +import { switchMap, retry } from 'rxjs/operators'; import { environment } from '@v3/environments/environment'; import { DemoService } from './demo.service'; import { ApolloService } from './apollo.service'; @@ -18,7 +18,7 @@ export class FastFeedbackService { private utils: UtilsService, private demo: DemoService, private apolloService: ApolloService, - ) {} + ) { } private _getFastFeedback(skipChecking = false): Observable { if (environment.demo) { @@ -58,10 +58,11 @@ export class FastFeedbackService { skipChecking?: boolean; closable?: boolean; // allow skipping modal popup (with a close button) } = { - modalOnly: false, - skipChecking: false, - closable: false, - }): Observable { + modalOnly: false, + skipChecking: false, + closable: false + } + ): Observable { return this._getFastFeedback(options.skipChecking).pipe( switchMap((res) => { try { @@ -117,9 +118,9 @@ export class FastFeedbackService { }); } }), - retryWhen((errors) => { - // retry for 3 times if API go wrong - return errors.pipe(delay(1000), take(3)); + retry({ + count: 3, + delay: 1000 }) ); } diff --git a/projects/v3/src/app/services/shared.service.ts b/projects/v3/src/app/services/shared.service.ts index 43c7fe686..083538d9b 100644 --- a/projects/v3/src/app/services/shared.service.ts +++ b/projects/v3/src/app/services/shared.service.ts @@ -164,14 +164,14 @@ export class SharedService { */ markTopicStopOnNavigating() { if (this.storage.get('startReadTopic')) { - this.topicService.updateTopicProgress(this.storage.get('startReadTopic'), 'stopped').subscribe( - _response => { + this.topicService.updateTopicProgress(this.storage.get('startReadTopic'), 'stopped').subscribe({ + next: _response => { this.storage.remove('startReadTopic'); }, - err => { + error: err => { console.error('error in mark Topic Stop On Navigating - ', err); } - ); + }); } } diff --git a/projects/v3/src/app/services/topic.service.ts b/projects/v3/src/app/services/topic.service.ts index f1eb2ef63..cddb11322 100644 --- a/projects/v3/src/app/services/topic.service.ts +++ b/projects/v3/src/app/services/topic.service.ts @@ -110,14 +110,14 @@ export class TopicService { if (environment.demo) { return this.demo.topic().subscribe(res => this._normaliseTopic(res.data)); } - return this.request.get(api.get.stories, {params: { model_id: id }}) - .pipe(map((response: ApiResponse) => { - if (response.success && response.data) { - return this._normaliseTopic(response.data); - } - - }) - ).subscribe(); + + return this.request.get(api.get.stories, { + params: { model_id: id } + }).pipe(map((response: ApiResponse) => { + if (response.success && response.data) { + return this._normaliseTopic(response.data); + } + })).subscribe(); } private _normaliseTopic(data: TopicData[]) { @@ -162,12 +162,14 @@ export class TopicService { }; } - topic.files = thisTopic.Filestore.map(item => ({url: item.slug || item.url, name: item.name})); + topic.files = thisTopic.Filestore.map(item => ({ + url: item.slug || item.url, name: item.name + })); this._topic$.next(topic); return topic; } - updateTopicProgress(id, state): Observable { + updateTopicProgress(id, state): Observable> { if (environment.demo) { // eslint-disable-next-line no-console console.log('mark topic as ', state); @@ -178,6 +180,7 @@ export class TopicService { model_id: +id, state: state }; + return this.request.post({ endPoint: api.post.updateProgress, data: postData, From 0db48520846dde36159cc7ac8acb06f7c007bf8e Mon Sep 17 00:00:00 2001 From: trtshen Date: Fri, 25 Jul 2025 16:24:19 +0800 Subject: [PATCH 10/27] [CORE-7496] feature toggle for asmt pagination --- .../assessment-pagination-feature-toggle.md | 86 ++++++++ .../assessment/assessment.component.html | 187 +++++++++++------- .../assessment/assessment.component.ts | 76 +++++-- .../v3/src/environments/environment.custom.ts | 3 + .../src/environments/environment.interface.ts | 76 +++++++ .../v3/src/environments/environment.local.ts | 3 + 6 files changed, 346 insertions(+), 85 deletions(-) create mode 100644 docs/features/assessment-pagination-feature-toggle.md create mode 100644 projects/v3/src/environments/environment.interface.ts diff --git a/docs/features/assessment-pagination-feature-toggle.md b/docs/features/assessment-pagination-feature-toggle.md new file mode 100644 index 000000000..c475c43bc --- /dev/null +++ b/docs/features/assessment-pagination-feature-toggle.md @@ -0,0 +1,86 @@ +# Assessment Pagination Feature Toggle + +This document explains how to enable/disable the assessment pagination feature using environment flags. + +## Configuration + +The pagination feature is controlled by the `featureToggles.assessmentPagination` flag in the environment files. + +### Environment Files + +The feature toggle is configured in the following files: +- `src/environments/environment.ts` (staging/default) +- `src/environments/environment.prod.ts` (production) +- `src/environments/environment.local.ts` (local development) +- `src/environments/environment.custom.ts` (custom environments) + +### Flag Configuration + +```typescript +export const environment = { + // ... other environment properties + featureToggles: { + assessmentPagination: true, // Set to false to disable pagination + }, +}; +``` + +## Behavior + +### When `assessmentPagination: true` (Default) +- Assessment questions are split across multiple pages (8 questions per page by default) +- Pagination controls (Previous/Next buttons and page indicators) are visible +- Users can navigate between pages using buttons or clicking page indicators +- Page completion indicators show which pages have unanswered required questions + +### When `assessmentPagination: false` +- All assessment questions are displayed on a single page +- No pagination controls are shown +- Traditional single-page assessment experience +- All questions are accessible without navigation + +## Technical Implementation + +The feature toggle affects: + +1. **Template Rendering**: Pagination UI is conditionally rendered based on `isPaginationEnabled` +2. **Question Display**: Questions are either paginated or shown all at once via `pagedGroups` getter +3. **Navigation Methods**: Pagination methods (`prevPage`, `nextPage`, etc.) are safe-guarded +4. **Page Completion**: Completion tracking is only active when pagination is enabled + +## Usage Examples + +### Disable pagination for a specific environment: +```typescript +// environment.local.ts +export const environment = { + // ... other properties + featureToggles: { + assessmentPagination: false, // Single page mode + }, +}; +``` + +### Enable pagination (default behavior): +```typescript +// environment.prod.ts +export const environment = { + // ... other properties + featureToggles: { + assessmentPagination: true, // Multi-page mode + }, +}; +``` + +## Testing + +To test the feature toggle: + +1. Modify the `assessmentPagination` flag in your target environment file +2. Rebuild the application with the appropriate environment configuration +3. Navigate to any assessment +4. Verify the pagination behavior matches the configuration + +## Backward Compatibility + +The feature toggle defaults to `true` (pagination enabled) if not explicitly set, ensuring backward compatibility with existing deployments. diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index 1a44e4d60..15522969d 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.html +++ b/projects/v3/src/app/components/assessment/assessment.component.html @@ -1,25 +1,35 @@ -
- +
+
-
+

{{ savingMessage$ | async }}

- + - Reviewer Details + Reviewer Details - - Expert - + + Expert + @@ -29,27 +39,37 @@ + class="ion-no-margin ion-padding ion-padding-horizontal main-content"> - Submission Details + Submission Details - - Learner - + + Learner + - + - - Team - + + Team + @@ -67,26 +87,31 @@ [attr.aria-describedby]="randomCode(assessment.name)" role="heading" aria-level="2" - [innerHTML]="assessment.name" - >
+ [innerHTML]="assessment.name">
- + -

+

Due Date: {{ utils.utcToLocal(assessment.dueDate, 'timeZone') }}

+ [content]="assessment.description" + id="asmt-des" + class="body-2 black" + [attr.aria-describedby]="randomCode(assessment.name)"> - + -

Locked by

-

Please wait until the user finishes editing

+

Locked by

+

Please wait until the user finishes editing

-
+
-

- -

+ +
@@ -116,17 +146,17 @@

- - +  * + *ngIf="question.isRequired"> * - -
+
- +
- + (@tickAnimation.done)="onAnimationEnd($event, question.id)"> + [@tickAnimation]="saved()[question.id] ? 'visible' : 'hidden'"> - + [@tickAnimation]="failed()[question.id] ? 'visible' : 'hidden'">
- - No answer for this question. + )" + class="no-answers item-bottom-border" + lines="none"> + + No answer for this question.
-

Unsupported question type: {{ question.type }}

+

Unsupported question type: {{ question.type }}

@@ -316,31 +355,31 @@

1"> + [hasCustomContent]="isPaginationEnabled && pageCount > 1"> -
+
+ class="nav-button prev-button" + (click)="prevPage()" + [disabled]="pageIndex === 0"> Prev
+ [class.many-pages]="pageCount > manyPages" + *ngIf="pageCount > 1" + #pageIndicatorsContainer>
+ [class.active]="pageIndex === page" + [class.incompleted]="!pageRequiredCompletion[page]" + [id]="'page-indicator-' + page" + (click)="goToPage(page)" + (dblclick)="findAndGoToFirstUnansweredQuestion()"> {{ i + 1 }} - @@ -349,9 +388,9 @@

+ class="nav-button next-button" + (click)="nextPage()" + [disabled]="pageIndex >= pageCount - 1"> Next @@ -362,4 +401,4 @@

- + \ No newline at end of file diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index b9865c47a..ef98683ad 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -20,6 +20,14 @@ import { FileInput, SubmitActions } from '../types/assessment'; import { FileUploadComponent } from '../file-upload/file-upload.component'; const MIN_SCROLLING_PAGES = 6; // minimum number of pages to show pagination scrolling + +/** + * Assessment Component with optional pagination feature + * + * Pagination can be enabled/disabled via environment.featureToggles.assessmentPagination + * When disabled, all assessment questions will be displayed on a single page + * When enabled, questions are split across multiple pages based on pageSize + */ @Component({ selector: 'app-assessment', templateUrl: './assessment.component.html', @@ -123,7 +131,7 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { pageRequiredCompletion: boolean[] = []; // indicator for required questions readonly manyPages = MIN_SCROLLING_PAGES; - @ViewChildren('questionBox') questionBoxes!: QueryList<{el: HTMLElement}>; + @ViewChildren('questionBox') questionBoxes!: QueryList<{ el: HTMLElement }>; @ViewChild('pageIndicatorsContainer') pageIndicatorsContainer: ElementRef; // prevent non participants from submitting team assessment @@ -153,16 +161,26 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { // each entry is a page: an array of (partial) groups pagesGroups: { name: string; description?: string; questions: Question[] }[][] = []; + // Feature toggle for pagination + get isPaginationEnabled(): boolean { + return environment.featureToggles?.assessmentPagination ?? true; + } + // override to use question‑based pages get pageCount() { - return this.pagesGroups.length; + return this.isPaginationEnabled ? this.pagesGroups.length : 1; } get pagedGroups() { + if (!this.isPaginationEnabled) { + // Return all groups as a single page when pagination is disabled + return this.assessment?.groups || []; + } return this.pagesGroups[this.pageIndex] || []; } prevPage() { + if (!this.isPaginationEnabled) return; if (this.pageIndex > 0) { this.pageIndex--; this.scrollActivePageIntoView(); @@ -170,6 +188,7 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { } nextPage() { + if (!this.isPaginationEnabled) return; if (this.pageIndex < this.pageCount - 1) { this.pageIndex++; this.scrollActivePageIntoView(); @@ -177,10 +196,12 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { } get pages(): number[] { + if (!this.isPaginationEnabled) return [0]; return Array(this.pageCount).fill(0).map((_, i) => i); } goToPage(i: number) { + if (!this.isPaginationEnabled) return; if (i >= 0 && i < this.pageCount) { this.pageIndex = i; this.scrollActivePageIntoView(); @@ -400,9 +421,15 @@ Best regards`; this._handleReviewData(); this._preventSubmission(); - // split by question count every time assessment changes - this.pagesGroups = this.splitGroupsByQuestionCount(); - this.pageIndex = 0; + // split by question count every time assessment changes - only if pagination is enabled + if (this.isPaginationEnabled) { + this.pagesGroups = this.splitGroupsByQuestionCount(); + this.pageIndex = 0; + } else { + // Reset pagination data when disabled + this.pagesGroups = []; + this.pageIndex = 0; + } this._populateFormWithAnswers(); @@ -482,7 +509,7 @@ Best regards`; ) { this.doAssessment = true; if (this.submission) { - this.savingMessage$.next($localize `Last saved ${this.utils.timeFormatter(this.submission.modified)}`); + this.savingMessage$.next($localize`Last saved ${this.utils.timeFormatter(this.submission.modified)}`); } return; } @@ -502,7 +529,7 @@ Best regards`; private _handleReviewData() { if (this.isPendingReview && this.review.status === 'in progress') { - this.savingMessage$.next($localize `Last saved ${this.utils.timeFormatter(this.review.modified)}`); + this.savingMessage$.next($localize`Last saved ${this.utils.timeFormatter(this.review.modified)}`); this.btnDisabled$.next(false); } } @@ -548,7 +575,7 @@ Best regards`; if (this.action === 'review' && this.utils.isEmpty(thisQuestion.answer) && this.utils.isEmpty(thisQuestion.file)) { isEmpty = true; - // for assessment: file is part of the answer + // for assessment: file is part of the answer } else if (this.action === 'assessment' && (this.utils.isEmpty(thisQuestion) || this.utils.isEmpty(thisQuestion.answer))) { isEmpty = true; } @@ -678,7 +705,7 @@ Best regards`; return answer; } - async _submitAnswer({autoSave = false, goBack = false}) { + async _submitAnswer({ autoSave = false, goBack = false }) { const answers = this.filledAnswers(); // check if all required questions have answer when assessment done const requiredQuestions = this._compulsoryQuestionsAnswered(answers); @@ -960,6 +987,8 @@ Best regards`; } initializePageCompletion() { + if (!this.isPaginationEnabled) return; + this.pageRequiredCompletion = new Array(this.pageCount).fill(true); this.pages.forEach((page, index) => { @@ -972,6 +1001,17 @@ Best regards`; } private getAllQuestionsForPage(pageIndex: number): Question[] { + if (!this.isPaginationEnabled) { + // If pagination is disabled, return all questions from all groups + const allQuestions: Question[] = []; + this.assessment?.groups?.forEach(group => { + if (group.questions && group.questions.length) { + allQuestions.push(...group.questions); + } + }); + return allQuestions; + } + if (!this.pagesGroups[pageIndex]) { return []; } @@ -1028,8 +1068,20 @@ Best regards`; * false: if all required questions are answered. */ findAndGoToFirstUnansweredQuestion(): boolean { - // Get all questions for the current page - const currentPageQuestions = this.getAllQuestionsForPage(this.pageIndex); + let currentPageQuestions: Question[]; + + if (!this.isPaginationEnabled) { + // If pagination is disabled, check all questions across all groups + currentPageQuestions = []; + this.assessment?.groups?.forEach(group => { + if (group.questions && group.questions.length) { + currentPageQuestions.push(...group.questions); + } + }); + } else { + // Get all questions for the current page + currentPageQuestions = this.getAllQuestionsForPage(this.pageIndex); + } // Filter only the required questions const requiredQuestions = currentPageQuestions.filter(question => this._isRequired(question)); @@ -1078,6 +1130,8 @@ Best regards`; * Scrolls the active page indicator into view within the pagination container */ scrollActivePageIntoView() { + if (!this.isPaginationEnabled) return; + setTimeout(() => { if (this.pageIndicatorsContainer && this.pageCount > this.manyPages) { const container = this.pageIndicatorsContainer.nativeElement; diff --git a/projects/v3/src/environments/environment.custom.ts b/projects/v3/src/environments/environment.custom.ts index 222cfbe6b..f10ade535 100644 --- a/projects/v3/src/environments/environment.custom.ts +++ b/projects/v3/src/environments/environment.custom.ts @@ -59,4 +59,7 @@ export const environment = { newrelic: '', goMobile: false, helpline: '', + featureToggles: { + assessmentPagination: true, + }, }; diff --git a/projects/v3/src/environments/environment.interface.ts b/projects/v3/src/environments/environment.interface.ts new file mode 100644 index 000000000..29bc3a053 --- /dev/null +++ b/projects/v3/src/environments/environment.interface.ts @@ -0,0 +1,76 @@ +export interface FeatureToggles { + assessmentPagination?: boolean; +} + +export interface Environment { + stackName?: string; + authCacheDuration: number; + demo: boolean; + production: boolean; + skipGlobalLogin?: boolean; + appkey: string; + pusherKey: string; + pusherCluster?: string; + env: string; + APIEndpoint: string; + graphQL: string; + chatGraphQL: string; + globalLoginUrl: string; + badgeProjectUrl?: string; + loginAPIUrl?: string; + stackUuid: string; + intercomAppId: string; + uppyConfig: { + tusUrl: string; + uploadPreset: string; + restrictions: { + minFileSize?: number; + maxFileSize: number; + minNumberOfFiles: number; + maxNumberOfFiles: number; + maxTotalFileSize?: number; + requiredMetaFields: string[]; + }; + }; + filestack: { + key: string; + s3Config: { + location: string; + container: string; + containerChina: string; + region: string; + regionChina: string; + paths: { + any: string; + image: string; + video: string; + }; + workflows: string[]; + }; + policy: string; + signature: string; + workflows: { + virusDetection: string; + }; + }; + hubspot: { + liveServerRegion: string; + supportFormPortalId: string; + supportFormId: string; + }; + firebase?: { + apiKey: string; + authDomain: string; + projectId: string; + storageBucket: string; + messagingSenderId: string; + appId: string; + measurementId: string; + }; + defaultCountryModel?: string; + intercom: boolean; + newrelic: boolean | string; + goMobile: boolean; + helpline: string; + featureToggles?: FeatureToggles; +} diff --git a/projects/v3/src/environments/environment.local.ts b/projects/v3/src/environments/environment.local.ts index 2f5c308ba..6d1fdbcad 100644 --- a/projects/v3/src/environments/environment.local.ts +++ b/projects/v3/src/environments/environment.local.ts @@ -62,6 +62,9 @@ export const environment = { newrelic: false, goMobile: false, helpline: 'help@practera.com', + featureToggles: { + assessmentPagination: true, + }, }; /* From 3a204fd3b17d6ef5d4874505e3f25bd0afa0f4f6 Mon Sep 17 00:00:00 2001 From: trtshen Date: Wed, 2 Jul 2025 16:52:10 +0800 Subject: [PATCH 11/27] [CORE-7944] disabled status of submit button for both learner & reviewer --- .../assessment/assessment.component.ts | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index ef98683ad..be7417ceb 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -455,6 +455,22 @@ Best regards`; this.isPendingReview = false; } + /** + * Validator to check if an answer is required. + * @param control The form control to validate. + * @returns An object with the validation error or null if valid. + */ + private _answerRequiredValidator(control: FormControl) { + const value = control.value; + if (value === null) return { required: true }; + if (typeof value === 'object' && value !== null) { + if ((!value.answer || value.answer.length === 0) && (!value.file || this.utils.isEmpty(value.file))) return { required: true }; + } else if (typeof value === 'string') { + if (value.length === 0) return { required: true }; + } + return null; + } + // Populate the question form with FormControls. // The name of form control is like 'q-2' (2 is an example of question id) private _populateQuestionsForm() { @@ -464,8 +480,14 @@ Best regards`; group.questions.forEach(question => { let validator = []; // check if the compulsory is mean for current user's role - if (this._isRequired(question) === true) { - validator = [Validators.required]; + const isRequired = this._isRequired(question); + // only apply required validators when user can actually edit (doAssessment or isPendingReview) + if (isRequired === true && (this.doAssessment || this.isPendingReview)) { + if (this.action === 'review' && ['text', 'file'].includes(question.type)) { + validator = [this._answerRequiredValidator]; + } else { + validator = [Validators.required]; + } } this.questionsForm.addControl('q-' + question.id, new FormControl('', validator)); @@ -477,7 +499,8 @@ Best regards`; debounceTime(300), ).subscribe(() => { this.initializePageCompletion(); - this.btnDisabled$.next(this.questionsForm.invalid); + // this.btnDisabled$.next(this.questionsForm.invalid); + this.setSubmissionDisabled(); }); } @@ -1152,7 +1175,7 @@ Best regards`; private _populateFormWithAnswers() { // Populate form with submission answers - if (this.submission?.answers) { + if (this.submission?.answers && this.action === 'assessment') { Object.keys(this.submission.answers).forEach(questionId => { const controlName = 'q-' + questionId; const control = this.questionsForm.get(controlName); @@ -1163,7 +1186,7 @@ Best regards`; } // Populate form with review answers - if (this.review?.answers) { + if (this.review?.answers && this.action === 'review') { Object.keys(this.review.answers).forEach(questionId => { const controlName = 'q-' + questionId; const control = this.questionsForm.get(controlName); @@ -1177,9 +1200,38 @@ Best regards`; }); } + if (this.utils.isEmpty(this.submission?.answers) && this.utils.isEmpty(this.review?.answers) && this.questionsForm?.invalid) { + this.setSubmissionDisabled(); + } + // Initialize page completion after form is populated setTimeout(() => { this.initializePageCompletion(); }, 100); } + + setSubmissionDisabled() { + // only enforce form validation when user can actually edit + if (!this.doAssessment && !this.isPendingReview) { + return; + } + + const isFormValid = this.questionsForm?.valid ?? false; + const isCurrentlyDisabled = this.btnDisabled$.getValue(); + + // Update button state only if it needs to change + if (!isFormValid && !isCurrentlyDisabled) { + this.btnDisabled$.next(true); + } else if (isFormValid && isCurrentlyDisabled) { + this.btnDisabled$.next(false); + } + } + + /** + * determine if required indicators should be shown for a question + * only show required indicators when user can actually edit the form + */ + shouldShowRequiredIndicator(question: Question): boolean { + return this._isRequired(question) && (this.doAssessment || this.isPendingReview); + } } From 533c2fdf49af869b7b8cd7ad80688dcc7db5a355 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 11 Sep 2025 12:49:11 +0800 Subject: [PATCH 12/27] [CORE-7935] reviewer's file value --- docs/assessment-flow.md | 571 ++++++++++++++++++ .../assessment/assessment.component.ts | 19 +- .../file-upload/file-upload.component.ts | 6 +- 3 files changed, 585 insertions(+), 11 deletions(-) create mode 100644 docs/assessment-flow.md diff --git a/docs/assessment-flow.md b/docs/assessment-flow.md new file mode 100644 index 000000000..a031fd3cb --- /dev/null +++ b/docs/assessment-flow.md @@ -0,0 +1,571 @@ +# Assessment Flow Documentation + +## Overview + +This document provides a comprehensive overview of how the Practera AppV2 assessment system works, covering the flow from activity pages through assessment components to form validation and submission handling for both learners and reviewers. + +## Architecture Overview + +The assessment system follows a hierarchical component structure with clear separation of concerns: + +``` +Activity Pages (Desktop/Mobile) + ↓ +Assessment Component (Central Hub) + ↓ +Question Components (Text, File, Multiple Choice, etc.) + ↓ +Bottom Action Bar (Submit/Continue Button) +``` + +## Core Components + +### 1. Entry Point Pages + +#### Activity Desktop Page (`activity-desktop.page.ts`) +- **Purpose**: Main desktop interface for learners doing assessments +- **Key Responsibilities**: + - Manages activity and task navigation + - Handles assessment loading and submission + - Controls button states and loading indicators + - Coordinates with activity service for task progression + +**Key Properties:** +```typescript +assessment = this.assessmentService.assessment$; +submission: Submission; +review: AssessmentReview; +savingText$: BehaviorSubject = new BehaviorSubject(''); +btnDisabled$: BehaviorSubject = new BehaviorSubject(false); +``` + +**Assessment Flow:** +1. Loads assessment via `assessmentService.getAssessment()` +2. Displays assessment component with current data +3. Handles save events from assessment component +4. Manages button states during submission + +#### Assessment Mobile Page (`assessment-mobile.page.ts`) +- **Purpose**: Mobile-optimized interface for assessments +- **Similar functionality** to desktop but adapted for mobile UX +- **Key Differences**: + - Mobile-specific navigation patterns + - Touch-optimized interactions + - Responsive layout adjustments + +#### Review Desktop Page (`review-desktop.page.ts`) +- **Purpose**: Interface for reviewers to evaluate learner submissions +- **Key Responsibilities**: + - Manages review list and current review selection + - Handles review submission and feedback + - Coordinates between review list and assessment components + +**Review-Specific Properties:** +```typescript +currentReview: Review; +reviews: Review[]; +noReview: boolean = false; +``` + +### 2. Central Assessment Component (`assessment.component.ts`) + +The assessment component is the central hub that orchestrates the entire assessment experience for both learners and reviewers. + +#### Core State Management + +**Action Types:** +- `'assessment'` - Learner doing assessment or viewing feedback +- `'review'` - Reviewer providing feedback on learner submission + +**State Flags:** +```typescript +doAssessment: boolean = false; // Learner can edit assessment +isPendingReview: boolean = false; // Reviewer can edit review +feedbackReviewed: boolean = false; // Learner has seen feedback +``` + +**Form Management:** +```typescript +questionsForm: FormGroup = new FormGroup({}); +// Form controls named 'q-{questionId}' for dynamic question handling +``` + +#### Data Flow Logic + +**For Learners (`action === 'assessment'`):** +1. **Not Started/In Progress**: `doAssessment = true` + - Form controls are editable + - Required validators applied to learner-audience questions + - Auto-save functionality enabled + - Submit button available + +2. **Pending Review**: Read-only mode + - Form controls disabled + - Show "waiting for review" message + - No submit functionality + +3. **Feedback Available**: Read-only with feedback + - Display learner answers and reviewer feedback + - "Mark as Read" button to acknowledge feedback + - Navigation to next task after reading + +**For Reviewers (`action === 'review'`):** +1. **Pending Review**: `isPendingReview = true` + - Display learner submission as reference (read-only) + - Reviewer form controls are editable + - Required validators applied to reviewer-audience questions + - "Submit Review" button available + +2. **Review Complete**: Read-only mode + - Show completed review + - No further editing allowed + +#### Form Population Logic + +**Assessment Answers (`this.action === 'assessment'`):** +```typescript +// Populate with submission answers +if (this.submission?.answers) { + Object.keys(this.submission.answers).forEach(questionId => { + const control = this.questionsForm.get('q-' + questionId); + if (control && this.submission.answers[questionId]?.answer !== undefined) { + control.setValue(this.submission.answers[questionId].answer, { emitEvent: false }); + } + }); +} +``` + +**Review Answers (`this.action === 'review'`):** +```typescript +// Populate with review answers (answer + comment structure) +if (this.review?.answers) { + Object.keys(this.review.answers).forEach(questionId => { + const control = this.questionsForm.get('q-' + questionId); + if (control && this.review.answers[questionId]) { + const reviewAnswer = { + answer: this.review.answers[questionId].answer, + comment: this.review.answers[questionId].comment + }; + control.setValue(reviewAnswer, { emitEvent: false }); + } + }); +} +``` + +#### Required Field Validation + +**Validation Rules:** +- Required validators only applied when user can edit +- `doAssessment = true`: Learner doing assessment +- `isPendingReview = true`: Reviewer doing review +- Read-only modes have no required validation + +**Implementation:** +```typescript +private _populateQuestionsForm() { + this.assessment.groups.forEach(group => { + group.questions.forEach(question => { + let validator = []; + + // Apply required validator based on user role and edit permissions + if (this._isRequired(question) === true) { + validator = [Validators.required]; + } + + this.questionsForm.addControl('q-' + question.id, new FormControl('', validator)); + }); + }); + + // Update button state based on form validity + this.questionsForm.valueChanges.pipe( + takeUntil(this.unsubscribe$), + debounceTime(300), + ).subscribe(() => { + this.btnDisabled$.next(this.questionsForm.invalid); + }); +} + +private _isRequired(question: Question): boolean { + if (!question.isRequired) return false; + + // Check if current user can edit and question applies to them + if (this.doAssessment && question.audience.includes('submitter')) { + return true; + } + + if (this.isPendingReview && question.audience.includes('reviewer')) { + return true; + } + + return false; +} +``` + +### 3. Question Components + +Each question type has its dedicated component that handles specific input requirements: + +#### File Upload Component (`app-file-upload`) +**Dual Purpose Display:** +- **Learner View**: Shows upload interface or uploaded file +- **Reviewer View**: Shows learner's file + reviewer's file upload interface + +**Key Properties:** +```typescript +[question]="question" // Question metadata +[doAssessment]="doAssessment" // Learner can edit +[doReview]="isPendingReview" // Reviewer can edit +[submission]="submission?.answers[question.id] || {}" // Learner's answer +[review]="review?.answers[question.id] || {}" // Reviewer's answer +[control]="questionsForm?.controls['q-' + question.id]" // Form control +``` + +**Form Control Updates:** +```typescript +onFileUploaded(file: any) { + if (this.doReview && this.control) { + this.control.setValue(file); + this.control.markAsTouched(); + this.control.updateValueAndValidity(); + } +} + +onFileRemoved() { + if (this.doReview && this.control) { + this.control.setValue(null); + this.control.markAsTouched(); + this.control.updateValueAndValidity(); + } +} +``` + +#### Other Question Components +- **Text Component** (`app-text`): Text input with rich text support +- **Multiple Component** (`app-multiple`): Multiple choice questions +- **Oneof Component** (`app-oneof`): Single choice questions +- **Team Member Selector** (`app-team-member-selector`): Team member selection + +All follow similar patterns with dual-purpose display for learner/reviewer contexts. + +### 4. Bottom Action Bar (`bottom-action-bar.component.html`) + +**Purpose**: Provides the primary action button for assessment submission/continuation + +**Key Features:** +```html +{{ text }} +``` + +**Button States:** +- **Enabled**: Form is valid and user can submit +- **Disabled**: Form has validation errors or submission in progress +- **Dynamic Text**: Changes based on context (Submit, Continue, Mark as Read, etc.) + +## Data Flow Diagrams + +### Assessment Submission Flow (Learner) + +``` +1. Activity Desktop Page + ↓ (Load Assessment) +2. AssessmentService.fetchAssessment() + ↓ (GraphQL Query) +3. Assessment Component receives data + ↓ (Initialize form) +4. Question Components populate with answers + ↓ (User interaction) +5. Form validation triggers + ↓ (Valid form) +6. Bottom Action Bar enabled + ↓ (User clicks submit) +7. Assessment Component emits save event + ↓ (Handle save) +8. Activity Desktop Page calls AssessmentService.submitAssessment() + ↓ (API call) +9. Success: Navigate to next task +``` + +### Review Submission Flow (Reviewer) + +``` +1. Review Desktop Page + ↓ (Select Review) +2. AssessmentService.fetchAssessment() with action='review' + ↓ (GraphQL Query with reviewer=true) +3. Assessment Component receives: + - Assessment structure + - Learner submission (reference) + - Review data (editable) + ↓ (Initialize form) +4. Question Components show: + - Learner answers (read-only) + - Reviewer input fields (editable) + ↓ (Reviewer interaction) +5. Form validation for reviewer fields + ↓ (Valid review form) +6. Bottom Action Bar enabled + ↓ (Reviewer clicks submit) +7. Assessment Component emits save event + ↓ (Handle review save) +8. Review Desktop Page calls AssessmentService.submitReview() + ↓ (API call) +9. Success: Update review list +``` + +## Form Validation System + +### Validation Rules + +**Required Field Logic:** +```typescript +// Only apply required validation when user can edit +if (this._isRequired(question) === true) { + validator = [Validators.required]; +} + +private _isRequired(question: Question): boolean { + if (!question.isRequired) return false; + + // Learner doing assessment + if (this.doAssessment && question.audience.includes('submitter')) { + return true; + } + + // Reviewer doing review + if (this.isPendingReview && question.audience.includes('reviewer')) { + return true; + } + + return false; +} +``` + +**Button State Management:** +```typescript +this.questionsForm.valueChanges.pipe( + debounceTime(300), + takeUntil(this.unsubscribe$) +).subscribe(() => { + // Update button disabled state based on form validity + this.btnDisabled$.next(this.questionsForm.invalid); + + // Update page completion tracking for pagination + this.initializePageCompletion(); +}); +``` + +### Validation Flow for Required File Questions + +**Scenario**: Reviewer must upload a file for a required question + +1. **Form Setup**: + ```typescript + // question.isRequired = true, question.audience = ['reviewer'] + // isPendingReview = true (reviewer can edit) + const validator = [Validators.required]; + this.questionsForm.addControl('q-' + question.id, new FormControl('', validator)); + ``` + +2. **Initial State**: + - Form control value: `null` or `''` + - Form validity: `invalid` + - Button state: `disabled` + +3. **File Upload**: + ```typescript + // File upload component updates control + this.control.setValue(fileObject); + this.control.updateValueAndValidity(); + ``` + +4. **Validation Update**: + - Form control value: `fileObject` + - Form validity: `valid` + - Button state: `enabled` + +## Assessment Pagination System + +### Core Properties +```typescript +pageSize = 8; // Maximum questions per page +pageIndex: number = 0; // Current page (0-based) +pagesGroups: any[] = []; // Pages containing question groups +pageRequiredCompletion: boolean[] = []; // Completion status per page +readonly manyPages = 6; // Minimum pages for scrollable pagination +``` + +### Page Generation Logic +```typescript +splitGroupsByQuestionCount() { + // Divides assessment groups into pages + // - Multiple small groups can fit on one page if total questions ≤ pageSize + // - Large groups with >pageSize questions are split across multiple pages + // - Preserves group structure where possible +} +``` + +### Navigation Methods +```typescript +prevPage() // Go to previous page with boundary check +nextPage() // Go to next page with boundary check +goToPage(i: number) // Jump to specific page with validation +``` + +### Completion Tracking +```typescript +initializePageCompletion() { + // Updates status array for required questions + this.pageRequiredCompletion = this.pagesGroups.map(pageGroups => { + const questions = this.getAllQuestionsForPage(pageIndex); + return this.areAllRequiredQuestionsAnswered(questions); + }); +} +``` + +## Error Handling + +### Validation Errors +- Form validation prevents submission when required fields are empty +- Visual indicators show which fields need attention +- Real-time validation feedback as user types/selects + +### Network Errors +- Auto-save failures trigger retry mechanism +- Submission failures show error messages +- Automatic logout if JWT token expires + +### File Upload Errors +- Upload failures with retry mechanisms +- File size and type validation +- Progress indicators during upload + +## Security Considerations + +### Role-Based Access +- Questions can specify audience: `['submitter', 'reviewer']` +- Form controls only editable based on user role and submission status +- API validates user permissions before allowing actions + +### Data Integrity +- Form validation ensures required fields are completed +- Server-side validation confirms data integrity +- Optimistic updates with rollback on failure + +## Performance Optimizations + +### Change Detection +```typescript +changeDetection: ChangeDetectionStrategy.OnPush +``` + +### Observable Management +```typescript +takeUntil(this.unsubscribe$) // Prevent memory leaks +debounceTime(300) // Reduce validation frequency +shareReplay(1) // Cache service responses +``` + +### Lazy Loading +- Question components loaded on demand +- Assessment data fetched when needed +- Pagination reduces DOM complexity + +## Testing Considerations + +### Unit Tests +- Mock assessment service responses +- Test form validation logic +- Verify button state changes +- Component interaction testing + +### Integration Tests +- End-to-end assessment submission flow +- Review workflow testing +- File upload functionality +- Cross-browser compatibility + +### Test Scenarios +1. **Learner Assessment Flow**: + - Start new assessment + - Save progress (auto-save) + - Submit assessment + - View feedback + +2. **Reviewer Flow**: + - View learner submission + - Provide feedback + - Submit review + - Handle required fields + +3. **Edge Cases**: + - Network interruptions + - Invalid file uploads + - Session timeouts + - Concurrent submissions + +## Configuration + +### Environment Features +```typescript +environment.featureToggles.assessmentPagination // Enable/disable pagination +``` + +### Question Types +- `text` - Text input +- `file` - File upload +- `video` - Video file upload +- `oneof` - Single choice +- `multiple` - Multiple choice +- `team-member-selector` - Team member selection +- `multi-team-member-selector` - Multiple team member selection + +## API Integration + +### GraphQL Queries +```graphql +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 { + name description + questions { + id name description type isRequired hasComment audience fileType + choices { id name explanation description } + teamMembers { userId userName teamId } + } + } + submissions(contextId:$contextId) { + id status completed modified locked + submitter { name image team { name } } + answers { questionId answer file { name url type } } + review { + id status modified meta + reviewer { name } + answers { questionId answer comment file { name url type size } } + } + } + } +} +``` + +### Mutation Operations +- `submitAssessment` - Learner submission +- `submitReview` - Reviewer feedback +- `saveAnswers` - Auto-save progress + +## Conclusion + +The assessment system provides a comprehensive, role-based assessment and review platform with comprehensive form validation, real-time feedback, and optimized user experience for both learners and reviewers. The modular architecture ensures maintainability while the reactive patterns provide responsive user interactions. + +Key strengths: +- Clear separation of concerns between components +- Reactive form validation with real-time feedback +- Dual-purpose components for learner/reviewer contexts +- Robust error handling and network resilience +- Performance optimizations for large assessments +- Comprehensive pagination system for long assessments diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index be7417ceb..3d8f4987c 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -482,15 +482,17 @@ Best regards`; // check if the compulsory is mean for current user's role const isRequired = this._isRequired(question); // only apply required validators when user can actually edit (doAssessment or isPendingReview) - if (isRequired === true && (this.doAssessment || this.isPendingReview)) { - if (this.action === 'review' && ['text', 'file'].includes(question.type)) { - validator = [this._answerRequiredValidator]; - } else { - validator = [Validators.required]; - } + if (isRequired === true && (this.doAssessment || this.isPendingReview || (this.action === 'review' && ['text', 'file'].includes(question.type)))) { + validator = [this._answerRequiredValidator]; + } else { + validator = [Validators.required]; } - this.questionsForm.addControl('q-' + question.id, new FormControl('', validator)); + this.questionsForm.addControl('q-' + question.id, new FormControl({ + answer: '', + comment: '', + file: null, + }, validator)); }); }); @@ -1193,7 +1195,8 @@ Best regards`; if (control && this.review.answers[questionId]) { const reviewAnswer = { answer: this.review.answers[questionId].answer, - comment: this.review.answers[questionId].comment + comment: this.review.answers[questionId].comment, + file: this.review.answers[questionId].file || null, }; control.setValue(reviewAnswer, { emitEvent: false }); } diff --git a/projects/v3/src/app/components/file-upload/file-upload.component.ts b/projects/v3/src/app/components/file-upload/file-upload.component.ts index 7a3d85547..e42d71572 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.ts +++ b/projects/v3/src/app/components/file-upload/file-upload.component.ts @@ -174,8 +174,6 @@ export class FileUploadComponent implements OnInit, OnDestroy { status: number; uploadURL: string; }): void { - const type = this.doReview ? 'answer' : undefined; - // reset errors this.errors = []; const fileInput: TusFileResponse = { @@ -191,7 +189,9 @@ export class FileUploadComponent implements OnInit, OnDestroy { }; this.uploadedFile = fileInput; + const type = this.doReview ? 'answer' : undefined; this.onChange('', type); + if (response?.status !== 200) { this.errors.push('File upload failed, please try again later.'); } @@ -225,7 +225,7 @@ export class FileUploadComponent implements OnInit, OnDestroy { this.submitActions$.next(action); } - // if 'type' is set, it means it comes from reviewer doing review, otherwise it comes from submitter doing assessment + // if 'type' is set, this is a reviewer's action (review mode); if not set, it's an assessment submission (assessment mode) onChange(value, type?: 'comment' | 'answer') { // set changed value (answer or comment) if (type) { // for reviewing From 7f1c105a5a485569390051d296fe0f8fb9b169ed Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 11 Sep 2025 14:22:33 +0800 Subject: [PATCH 13/27] [CORE-7935] rearrange to retrieve the isPendingReview first --- .../assessment/assessment.component.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index 3d8f4987c..adafaecaf 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -416,8 +416,8 @@ Best regards`; } this._initialise(); - this._populateQuestionsForm(); this._handleSubmissionData(); + this._populateQuestionsForm(); this._handleReviewData(); this._preventSubmission(); @@ -463,10 +463,15 @@ Best regards`; private _answerRequiredValidator(control: FormControl) { const value = control.value; if (value === null) return { required: true }; + if (typeof value === 'object' && value !== null) { - if ((!value.answer || value.answer.length === 0) && (!value.file || this.utils.isEmpty(value.file))) return { required: true }; + if ((!value.answer || value.answer.length === 0) && (!value.file || this.utils.isEmpty(value.file))) { + return { required: true }; + } } else if (typeof value === 'string') { - if (value.length === 0) return { required: true }; + if (value.length === 0) { + return { required: true }; + } } return null; } @@ -482,10 +487,12 @@ Best regards`; // check if the compulsory is mean for current user's role const isRequired = this._isRequired(question); // only apply required validators when user can actually edit (doAssessment or isPendingReview) - if (isRequired === true && (this.doAssessment || this.isPendingReview || (this.action === 'review' && ['text', 'file'].includes(question.type)))) { - validator = [this._answerRequiredValidator]; - } else { - validator = [Validators.required]; + if (isRequired === true && (this.doAssessment || this.isPendingReview )) { + if (this.action === 'review' && ['text', 'file'].includes(question.type)) { + validator = [this._answerRequiredValidator]; + } else { + validator = [Validators.required]; + } } this.questionsForm.addControl('q-' + question.id, new FormControl({ From 0a4904d9721a411ca8b04646e3094da01c53288e Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 11 Sep 2025 17:08:34 +0800 Subject: [PATCH 14/27] [CORE-7935] additional file type question validator --- .../assessment/assessment.component.ts | 26 ++++++++++++++++--- .../file-upload/file-upload.component.ts | 5 ++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index adafaecaf..8c884e851 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -460,12 +460,12 @@ Best regards`; * @param control The form control to validate. * @returns An object with the validation error or null if valid. */ - private _answerRequiredValidator(control: FormControl) { + private _answerRequiredValidatorForReviewer(control: FormControl) { const value = control.value; if (value === null) return { required: true }; if (typeof value === 'object' && value !== null) { - if ((!value.answer || value.answer.length === 0) && (!value.file || this.utils.isEmpty(value.file))) { + if ((!value.answer || value.answer.length === 0) && (!value.file || (Object.keys(value.file).length === 0))) { return { required: true }; } } else if (typeof value === 'string') { @@ -476,6 +476,22 @@ Best regards`; return null; } + private _fileRequiredValidatorForLearner(control: FormControl) { + const value: FileInput = control.value; + + if (value === null || value === undefined) return { required: true }; + + if (typeof value === 'object' && value !== null) { + // check if file object has a url property (uploaded file) + if (Object.entries(value).length === 0 || !value.url || value.url.length === 0) { + return { required: true }; + } + } else if (typeof value === 'string') { + return { required: true }; + } + return null; + } + // Populate the question form with FormControls. // The name of form control is like 'q-2' (2 is an example of question id) private _populateQuestionsForm() { @@ -489,7 +505,9 @@ Best regards`; // only apply required validators when user can actually edit (doAssessment or isPendingReview) if (isRequired === true && (this.doAssessment || this.isPendingReview )) { if (this.action === 'review' && ['text', 'file'].includes(question.type)) { - validator = [this._answerRequiredValidator]; + validator = [this._answerRequiredValidatorForReviewer]; + } else if (question.type === 'file' && this.action === 'assessment') { + validator = [this._fileRequiredValidatorForLearner]; } else { validator = [Validators.required]; } @@ -726,7 +744,7 @@ Best regards`; answer = []; break; case 'text': - case 'file': + case 'file': // answer is for text/oneof/multiple/slider only, file is always '' case 'team-member-selector': case 'multi-team-member-selector': answer = ''; diff --git a/projects/v3/src/app/components/file-upload/file-upload.component.ts b/projects/v3/src/app/components/file-upload/file-upload.component.ts index e42d71572..9ba7f957c 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.ts +++ b/projects/v3/src/app/components/file-upload/file-upload.component.ts @@ -244,6 +244,7 @@ export class FileUploadComponent implements OnInit, OnDestroy { } this.control.setValue(this.innerValue); + this.control.markAsTouched(); this.triggerSave(); } @@ -292,9 +293,7 @@ export class FileUploadComponent implements OnInit, OnDestroy { if (this.doAssessment === true) { this.submission.answer = null; this.onChange(''); - } - - if (this.doReview === true) { + } else if (this.doReview === true) { this.review.answer = null; this.onChange('', 'answer'); } From b783f727b3cb025255a7463ef58c4f94b1eae333 Mon Sep 17 00:00:00 2001 From: trtshen Date: Wed, 17 Sep 2025 10:46:15 +0800 Subject: [PATCH 15/27] [CORE-7944] broken multi member selctor fixed --- package-lock.json | 44 ------------------- .../assessment/assessment.component.ts | 8 +++- .../multi-team-member-selector.component.html | 4 +- .../multi-team-member-selector.component.ts | 12 +++-- .../v3/src/app/components/types/assessment.ts | 6 +++ .../v3/src/app/services/assessment.service.ts | 2 +- 6 files changed, 25 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 53b41707a..6edbcdd08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -704,17 +704,6 @@ "node": ">=12" } }, - "node_modules/@angular-devkit/build-angular/node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~6.19.8" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/@vitejs/plugin-basic-ssl": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz", @@ -4371,31 +4360,6 @@ } } }, - "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "3.6.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/@compodoc/compodoc/node_modules/@babel/core": { "version": "7.25.8", "dev": true, @@ -23939,14 +23903,6 @@ "through": "^2.3.8" } }, - "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/uni-global": { "version": "1.0.0", "dev": true, diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index 2ccaeaf91..e6c424b51 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -514,8 +514,14 @@ Best regards`; } } + // multiple initial answer + let answer: string | object | any[] = ''; + if (question.type === 'multi team member selector') { + answer = []; + } + this.questionsForm.addControl('q-' + question.id, new FormControl({ - answer: '', + answer, comment: '', file: null, }, validator)); 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 c7c6160d3..bc537d618 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 @@ -29,15 +29,17 @@

+ + ; - @Input() question; + @Input() question: Question; @Input() submission; @Input() submissionId: number; @Input() review; @@ -107,9 +108,12 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O } } else { if (!this.innerValue) { - this.innerValue = []; + this.innerValue = { + answer: [], + comment: '' + }; } - this.innerValue = this.utils.addOrRemove(this.innerValue, value); + this.innerValue.answer = this.utils.addOrRemove(this.innerValue.answer, value); } // propagate value into form control using control value accessor interface @@ -150,7 +154,7 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O private _showSavedAnswers() { if ((['in progress', 'not start'].includes(this.reviewStatus)) && this.doReview) { this.innerValue = { - answer: this.review.answer, + answer: this.review.answer || [], comment: this.review.comment }; this.comment = this.review.comment; diff --git a/projects/v3/src/app/components/types/assessment.ts b/projects/v3/src/app/components/types/assessment.ts index 4352fb2a6..fa97570ff 100644 --- a/projects/v3/src/app/components/types/assessment.ts +++ b/projects/v3/src/app/components/types/assessment.ts @@ -45,6 +45,12 @@ export interface Choice { explanation?: string | any; } +export interface TeamMemberKey { + userId: number; + userName: string; + teamId: number; +} + export interface TeamMember { key: string; userName: string; diff --git a/projects/v3/src/app/services/assessment.service.ts b/projects/v3/src/app/services/assessment.service.ts index b1bbf6c2c..f830811ba 100644 --- a/projects/v3/src/app/services/assessment.service.ts +++ b/projects/v3/src/app/services/assessment.service.ts @@ -546,7 +546,7 @@ export class AssessmentService { if (this.utils.isEmpty(answer)) { answer = []; } - if (!Array.isArray(answer)) { + if (!Array.isArray(answer) && typeof answer === "string" && answer.length > 0) { // re-format json string to array answer = JSON.parse(answer); } From db22405e0f0d8d974db1c1d57a4a2c242a04c1de Mon Sep 17 00:00:00 2001 From: trtshen Date: Wed, 17 Sep 2025 15:41:46 +0800 Subject: [PATCH 16/27] [CORE-7944] broken logic for multi answer retrieval + submission --- .../assessment/assessment.component.ts | 2 +- .../multi-team-member-selector.component.html | 22 +++--- .../multi-team-member-selector.component.ts | 69 ++++++++++++++++--- .../multiple/multiple.component.html | 8 ++- .../components/multiple/multiple.component.ts | 3 - .../app/components/oneof/oneof.component.html | 7 +- 6 files changed, 87 insertions(+), 24 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index e6c424b51..c01215fb9 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -585,7 +585,7 @@ Best regards`; } private _handleReviewData() { - if (this.isPendingReview && this.review.status === 'in progress') { + if (this.isPendingReview && this.review?.status === 'in progress') { this.savingMessage$.next($localize`Last saved ${this.utils.timeFormatter(this.review.modified)}`); this.btnDisabled$.next(false); } 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 bc537d618..a56208c4a 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 @@ -5,10 +5,17 @@

-

- Learner's answer - Expert's answer -

+ +

+ Learner's answer +

+
+ + +

+ Expert's answer +

+
@@ -29,7 +36,6 @@

-
-

+

Learner's answer

diff --git a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts index 577ebc6ba..b67027e0c 100644 --- a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts +++ b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts @@ -33,7 +33,7 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O // this is for doing review or not @Input() doReview: Boolean; // FormControl that is passed in from parent component - @Input() control: AbstractControl; + @Input() control: AbstractControl<{answer: string[], comment: string}>; // answer field for submitter & reviewer @ViewChild('answerEle') answerRef: ElementRef; // comment field for reviewer @@ -80,7 +80,7 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O action.questionSave = { submissionId: this.submissionId, questionId: this.question.id, - answer: this.innerValue, + answer: this.innerValue.answer, }; } @@ -113,6 +113,7 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O comment: '' }; } + this.innerValue.answer = this.utils.addOrRemove(this.innerValue.answer, value); } @@ -135,9 +136,6 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O // From ControlValueAccessor interface writeValue(value: any) { - if (value) { - this.innerValue = typeof value === 'string' ? JSON.parse(value) : value; - } } // From ControlValueAccessor interface @@ -158,9 +156,13 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O comment: this.review.comment }; this.comment = this.review.comment; - } - if ((this.submissionStatus === 'in progress') && this.doAssessment) { - this.innerValue = this.control.pristine ? this.submission.answer : this.control.value; + } else if ((this.submissionStatus === 'in progress') && this.doAssessment) { + if (!this.innerValue) { + this.innerValue = { + answer: this.submission.answer || [], + }; + } + this.innerValue.answer = this.control.pristine ? this.submission.answer : this.control.value; } this.propagateChange(this.innerValue); @@ -181,4 +183,55 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O return !this.doAssessment && !this.doReview && (this.submissionStatus === 'feedback available' || this.submissionStatus === 'pending review' || (this.submissionStatus === 'done' && this.reviewStatus === '')) && (this.submission?.answer || this.review?.answer); } + + isSelected(teamMember: any): boolean { + if (!this.innerValue?.answer) return false; + try { + const memberObj = JSON.parse(teamMember.key); + return this.innerValue.answer.some((ans: string) => { + try { + const ansObj = JSON.parse(ans); + return ansObj.userId === memberObj.userId; + } catch { + return false; + } + }); + } catch { + return false; + } + } + + isSelectedInSubmission(teamMember: any): boolean { + if (!this.submission?.answer) return false; + try { + const memberObj = JSON.parse(teamMember.key); + return this.submission.answer.some((ans: string) => { + try { + const ansObj = JSON.parse(ans); + return ansObj.userId === memberObj.userId; + } catch { + return false; + } + }); + } catch { + return false; + } + } + + isSelectedInReview(teamMember: any): boolean { + if (!this.review?.answer) return false; + try { + const memberObj = JSON.parse(teamMember.key); + return this.review.answer.some((ans: string) => { + try { + const ansObj = JSON.parse(ans); + return ansObj.userId === memberObj.userId; + } catch { + return false; + } + }); + } catch { + return false; + } + } } diff --git a/projects/v3/src/app/components/multiple/multiple.component.html b/projects/v3/src/app/components/multiple/multiple.component.html index 87f1728c3..8e1e4b256 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.html +++ b/projects/v3/src/app/components/multiple/multiple.component.html @@ -44,15 +44,19 @@

{ - + + > + + {{question. [ngClass]="{'item-bottom-border': i !== question.choices.length - 1 || !checkInnerValue(choice.id)}"> + > + From 29f0d644071fa350606eef3aefc3d26c849596e2 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 18 Sep 2025 12:44:43 +0800 Subject: [PATCH 17/27] [CORE-8012] toggle with text innerHTML --- docs/directives/toggleLabelDirective.md | 37 +++++++++ docs/docs.md | 5 +- .../src/app/components/components.module.ts | 3 + .../multi-team-member-selector.component.html | 17 +++- .../multi-team-member-selector.component.ts | 9 +++ .../multiple/multiple.component.html | 17 +++- .../components/multiple/multiple.component.ts | 5 ++ .../app/components/oneof/oneof.component.html | 17 +++- .../app/components/oneof/oneof.component.ts | 10 +++ .../team-member-selector.component.html | 17 +++- .../team-member-selector.component.ts | 10 +++ .../toggle-label.directive.spec.ts | 81 +++++++++++++++++++ .../toggle-label/toggle-label.directive.ts | 31 +++++++ 13 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 docs/directives/toggleLabelDirective.md create mode 100644 projects/v3/src/app/directives/toggle-label/toggle-label.directive.spec.ts create mode 100644 projects/v3/src/app/directives/toggle-label/toggle-label.directive.ts diff --git a/docs/directives/toggleLabelDirective.md b/docs/directives/toggleLabelDirective.md new file mode 100644 index 000000000..4e323ec48 --- /dev/null +++ b/docs/directives/toggleLabelDirective.md @@ -0,0 +1,37 @@ +# Toggle Label Directive + +This directive was created to accommodate the use of innerHTML in ion-checkbox and ion-radio labels. It provides a solution for dynamically toggling label content and handling HTML content within Ionic form controls where standard label binding may not be sufficient. + +## Problem Statement +The issue arises when clicking on the content within the innerHTML title text of ion-checkbox or ion-radio components. The expected behavior is that clicking the label should toggle the checkbox or radio button state. However, due to the way innerHTML is handled, the click event does not propagate correctly to the underlying input element, preventing the toggle action from occurring. This can lead to a confusing user experience where the label appears clickable but does not perform the intended function. + +### Comparison of Implementations + +#### Default Recommended Code Implementation +```html + + Toggle me + +``` +In this implementation, clicking the label correctly toggles the checkbox state because the label is directly associated with the checkbox input. + +#### Implementation with innerHTML +```html + + + +``` +In this case, while the label appears clickable, the click event does not propagate to the checkbox input, resulting in no toggle action occurring when the label is clicked. + +This comparison highlights the importance of using standard label binding to ensure proper functionality in Ionic form controls. + +## Purpose +- Enable dynamic label content for Ionic checkbox and radio components +- Support HTML content rendering within form control labels +- Provide consistent label behavior across different form input types + +## Usage +Apply this directive to elements that need dynamic label toggling functionality, particularly useful with Ionic form controls that require innerHTML support. + +## Note +This directive addresses limitations in standard Ionic label handling where innerHTML content needs to be dynamically managed for checkbox and radio controls. diff --git a/docs/docs.md b/docs/docs.md index 598cc275f..585bbe02d 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -7,4 +7,7 @@ This is practera documentation with more informations. ### Components - [Chat Room Component](./components/chatRoomComponent.md) -- [Chat List Component](./components/chatListComponent.md) \ No newline at end of file +- [Chat List Component](./components/chatListComponent.md) + +### Directives +- [Toggle Label Directive](./directives/toggleLabelDirective.md) \ No newline at end of file diff --git a/projects/v3/src/app/components/components.module.ts b/projects/v3/src/app/components/components.module.ts index f48f599f1..cddb662b9 100644 --- a/projects/v3/src/app/components/components.module.ts +++ b/projects/v3/src/app/components/components.module.ts @@ -38,6 +38,7 @@ import { VideoConversionComponent } from './video-conversion/video-conversion.co import { SupportPopupComponent } from './support-popup/support-popup.component'; import { BackgroundImageDirective } from '../directives/background-image/background-image.directive'; import { FallbackImageDirective } from '../directives/fallback-image/fallback-image.directive'; +import { ToggleLabelDirective } from '../directives/toggle-label/toggle-label.directive'; import { TrafficLightGroupComponent } from './traffic-light-group/traffic-light-group.component'; import { UppyUploaderComponent } from './uppy-uploader/uppy-uploader.component'; import { FileUploadComponent } from './file-upload/file-upload.component'; @@ -67,6 +68,7 @@ const largeCircleDefaultConfig = { CommonModule, FormsModule, ReactiveFormsModule, + ToggleLabelDirective, UppyAngularDashboardModalModule, UppyAngularDashboardModule, NgCircleProgressModule.forRoot(largeCircleDefaultConfig), @@ -152,6 +154,7 @@ const largeCircleDefaultConfig = { BrandingLogoComponent, BottomActionBarComponent, SupportPopupComponent, + ToggleLabelDirective, TrafficLightComponent, TrafficLightGroupComponent, UppyUploaderComponent, 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 a56208c4a..c77c7936f 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 @@ -40,7 +40,14 @@

- + +
- + +

Learner's answer

diff --git a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts index b67027e0c..07ac2595c 100644 --- a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts +++ b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts @@ -234,4 +234,13 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O return false; } } + + // innerHTML toggle label click handler + onLabelToggle = (id: string): void => { + this.onChange(id); + } + + onLabelToggleReview = (id: string): void => { + this.onChange(id, 'answer'); + } } diff --git a/projects/v3/src/app/components/multiple/multiple.component.html b/projects/v3/src/app/components/multiple/multiple.component.html index 8e1e4b256..1782e8661 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.html +++ b/projects/v3/src/app/components/multiple/multiple.component.html @@ -55,7 +55,14 @@

{ [disabled]="control.disabled" [attr.aria-label]="choice.name" > - + + @@ -93,7 +100,13 @@

{ [attr.aria-label]="choice.name" >
- + +

{ + this.onChange(id); + } } diff --git a/projects/v3/src/app/components/oneof/oneof.component.html b/projects/v3/src/app/components/oneof/oneof.component.html index f22fc802d..b1475f18c 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.html +++ b/projects/v3/src/app/components/oneof/oneof.component.html @@ -47,7 +47,14 @@

{{question. - + + {{question. justify="start" labelPlacement="end">
- + +

Learner's answer

diff --git a/projects/v3/src/app/components/oneof/oneof.component.ts b/projects/v3/src/app/components/oneof/oneof.component.ts index 03c847e27..223621f1b 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.ts +++ b/projects/v3/src/app/components/oneof/oneof.component.ts @@ -195,4 +195,14 @@ export class OneofComponent implements AfterViewInit, ControlValueAccessor, OnIn return !this.doAssessment && !this.doReview && (this.submissionStatus === 'feedback available' || this.submissionStatus === 'pending review' || (this.submissionStatus === 'done' && this.reviewStatus === '')); } + + // innerHTML text toggle + onLabelToggle = (id: string): void => { + this.onChange(id); + } + + // Allow clicking the rendered HTML label to toggle during review + onLabelToggleReview = (id: string): void => { + this.onChange(id, 'answer'); + } } diff --git a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html index 02cf7563e..6df93bb86 100644 --- a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html +++ b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html @@ -47,7 +47,14 @@

- + + @@ -72,7 +79,13 @@

- + +

Learner's answer diff --git a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts index 05ee1e5d9..c998c1281 100644 --- a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts +++ b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts @@ -167,4 +167,14 @@ export class TeamMemberSelectorComponent implements ControlValueAccessor, OnInit return !this.doAssessment && !this.doReview && (this.submissionStatus === 'feedback available' || this.submissionStatus === 'pending review' || (this.submissionStatus === 'done' && this.reviewStatus === '')) && (this.submission?.answer || this.review?.answer); } + + // innerHTML text toggle - submission + onLabelToggle = (id: string): void => { + this.onChange(id); + } + + // innerHTML text toggle - review + onLabelToggleReview = (id: string): void => { + this.onChange(id, 'answer'); + } } diff --git a/projects/v3/src/app/directives/toggle-label/toggle-label.directive.spec.ts b/projects/v3/src/app/directives/toggle-label/toggle-label.directive.spec.ts new file mode 100644 index 000000000..afdb14361 --- /dev/null +++ b/projects/v3/src/app/directives/toggle-label/toggle-label.directive.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ToggleLabelDirective } from './toggle-label.directive'; + +@Component({ + template: ` + + Test Label + + `, + standalone: true, + imports: [ToggleLabelDirective] +}) +class TestComponent { + disabled = false; + toggleFunction = jasmine.createSpy('toggleFunction'); +} + +describe('ToggleLabelDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture; + let spanElement: DebugElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + spanElement = fixture.debugElement.query(By.directive(ToggleLabelDirective)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(spanElement).toBeTruthy(); + }); + + it('should call toggle function on click', () => { + spanElement.triggerEventHandler('click', { preventDefault: () => {}, stopPropagation: () => {}, target: spanElement.nativeElement }); + expect(component.toggleFunction).toHaveBeenCalledWith('test-id'); + }); + + it('should call toggle function on Enter key', () => { + spanElement.triggerEventHandler('keydown', { key: 'Enter', preventDefault: () => {}, stopPropagation: () => {} }); + expect(component.toggleFunction).toHaveBeenCalledWith('test-id'); + }); + + it('should call toggle function on Space key', () => { + spanElement.triggerEventHandler('keydown', { key: ' ', preventDefault: () => {}, stopPropagation: () => {} }); + expect(component.toggleFunction).toHaveBeenCalledWith('test-id'); + }); + + it('should not call toggle function when disabled', () => { + component.disabled = true; + fixture.detectChanges(); + + spanElement.triggerEventHandler('click', { preventDefault: () => {}, stopPropagation: () => {}, target: spanElement.nativeElement }); + spanElement.triggerEventHandler('keydown', { key: 'Enter', preventDefault: () => {}, stopPropagation: () => {} }); + + expect(component.toggleFunction).not.toHaveBeenCalled(); + }); + + it('should not call toggle function when clicking on a link', () => { + const mockEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + target: { + closest: (selector: string) => selector === 'a' ? {} : null + } + }; + + spanElement.triggerEventHandler('click', mockEvent); + expect(component.toggleFunction).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/v3/src/app/directives/toggle-label/toggle-label.directive.ts b/projects/v3/src/app/directives/toggle-label/toggle-label.directive.ts new file mode 100644 index 000000000..f499a7565 --- /dev/null +++ b/projects/v3/src/app/directives/toggle-label/toggle-label.directive.ts @@ -0,0 +1,31 @@ +import { Directive, HostListener, Input } from '@angular/core'; + +@Directive({ + selector: '[toggleLabel]', + standalone: true +}) +export class ToggleLabelDirective { + @Input('toggleLabel') toggleFn!: (id: string) => void; + @Input() toggleId!: string; + @Input() toggleDisabled = false; + + @HostListener('click', ['$event']) + onClick(ev: MouseEvent) { + if (this.toggleDisabled) return; + const el = ev.target as HTMLElement | null; + if (el && el.closest('a')) return; + ev.preventDefault(); + ev.stopPropagation(); + this.toggleFn?.(this.toggleId); + } + + @HostListener('keydown', ['$event']) + onKeydown(ev: KeyboardEvent) { + if (this.toggleDisabled) return; + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + ev.stopPropagation(); + this.toggleFn?.(this.toggleId); + } + } +} From 5be60c02fdd36833f6adc993a4ca2e203e54ecea Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 18 Sep 2025 19:15:27 +0800 Subject: [PATCH 18/27] [CORE-8012] support empty submission for slider now --- .../assessment/assessment.component.html | 30 ++++--- .../assessment/assessment.component.ts | 23 +++-- .../multi-team-member-selector.component.ts | 1 - .../components/slider/slider.component.html | 90 ++++++++++++------- .../app/components/slider/slider.component.ts | 13 +++ projects/v3/src/styles.scss | 8 +- 6 files changed, 109 insertions(+), 56 deletions(-) diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index 7c77db166..428ab710c 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.html +++ b/projects/v3/src/app/components/assessment/assessment.component.html @@ -203,19 +203,21 @@ [@tickAnimation]="failed()[question.id] ? 'visible' : 'hidden'">

- - - No answer for this question. - + + + + No answer for this question. + + -
\ No newline at end of file + diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index c01215fb9..9f83c3160 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -514,17 +514,26 @@ Best regards`; } } + + let quesCtrl: { answer: any; comment?: string; file?: any } | any = null; + // multiple initial answer - let answer: string | object | any[] = ''; if (question.type === 'multi team member selector') { - answer = []; + quesCtrl = { answer: [] }; + } + + if (this.action === 'review') { + quesCtrl.comment = ''; + quesCtrl.answer = ''; + quesCtrl.file = null; + + // multiple initial answer + if (question.type === 'multi team member selector') { + quesCtrl.answer = []; + } } - this.questionsForm.addControl('q-' + question.id, new FormControl({ - answer, - comment: '', - file: null, - }, validator)); + this.questionsForm.addControl('q-' + question.id, new FormControl(quesCtrl, validator)); }); }); diff --git a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts index 07ac2595c..baf4795b9 100644 --- a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts +++ b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts @@ -110,7 +110,6 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O if (!this.innerValue) { this.innerValue = { answer: [], - comment: '' }; } diff --git a/projects/v3/src/app/components/slider/slider.component.html b/projects/v3/src/app/components/slider/slider.component.html index b88506f83..63527b90f 100644 --- a/projects/v3/src/app/components/slider/slider.component.html +++ b/projects/v3/src/app/components/slider/slider.component.html @@ -2,45 +2,69 @@

{{question
- - + +
+ + - -
-
- + +
+
+ +
- -
- - - Learner's answer: {{ getChoiceNameById(submission.answer) }} - - - - Expert's answer: {{ getChoiceNameById(review.answer) }} - + + +
+ + + +

No answer provided

+

The learner has not submitted an answer for this question.

+
+
+
+
+ + +
+ +
+ + + Learner's answer: {{ getChoiceNameById(submission.answer) }} + + + + + Expert's answer: {{ getChoiceNameById(review.answer) }} + +
+ + +
+ + + No answers available yet + +
- - +