diff --git a/docs/assessment-btndisabled-flow.md b/docs/assessment-btndisabled-flow.md new file mode 100644 index 000000000..dde8195d4 --- /dev/null +++ b/docs/assessment-btndisabled-flow.md @@ -0,0 +1,163 @@ +═══════════════════════════════════════════════════════════════════════════════════════ + btnDisabled$ BehaviorSubject Flow Diagram +═══════════════════════════════════════════════════════════════════════════════════════ + +┌─────────────────────────────────────┐ +│ activity-desktop.page.ts │ +│ (Parent Component) │ +└─────────────────────────────────────┘ + │ + │ Creates & Passes btnDisabled$ + │ as @Input to assessment.component + ▼ +┌─────────────────────────────────────┐ +│ assessment.component.ts │ +│ (Child Component) │ +│ │ +│ @Input() btnDisabled$: │ +│ BehaviorSubject │ +└─────────────────────────────────────┘ + +═══════════════════════════════════════════════════════════════════════════════════════ + TRIGGER POINTS IN assessment.component.ts +═══════════════════════════════════════════════════════════════════════════════════════ + +1. ngOnChanges() - Component Lifecycle + └── btnDisabled$.next(false) ──────────► RESET on assessment change + +2. _populateQuestionsForm() - Form Setup + ├── If no questions exist: + │ └── btnDisabled$.next(true) ───────► DISABLE (empty form) + │ + └── questionsForm.valueChanges.subscribe() + └── setSubmissionDisabled() ───────► CHECK & UPDATE based on validation + +3. _handleSubmissionData() - Submission State Handler + └── If submission.isLocked: + └── btnDisabled$.next(true) ───────► DISABLE (locked by another user) + +4. _handleReviewData() - Review State Handler + └── If isPendingReview && review.status === 'in progress': + └── btnDisabled$.next(false) ──────► ENABLE for review + +5. continueToNextTask() - Submit Action + └── If _btnAction === 'submit': + └── btnDisabled$.next(true) ───────► DISABLE during submission + +6. _submitAnswer() - Answer Submission + └── If required questions missing: + └── btnDisabled$.next(false) ──────► RE-ENABLE after validation fail + +7. resubmit() - Resubmission Flow + ├── Start: btnDisabled$.next(true) ────► DISABLE during resubmit + └── End: btnDisabled$.next(false) ─────► RE-ENABLE after completion + +8. setSubmissionDisabled() - Main Validation Logic + ├── Only runs if (doAssessment || isPendingReview) + ├── If form invalid & not disabled: + │ └── btnDisabled$.next(true) ───────► DISABLE + └── If form valid & disabled: + └── btnDisabled$.next(false) ──────► ENABLE + +9. _prefillForm() - Form Population + ├── After populating form with answers + ├── questionsForm.updateValueAndValidity() + └── If edit mode (doAssessment || isPendingReview): + └── setSubmissionDisabled() ───────► CHECK & UPDATE validation + └── If read-only mode: + └── btnDisabled$.next(false) ──────► ENSURE enabled + +10. Page Navigation Methods + ├── goToPage() + ├── nextPage() + └── prevPage() + └── setSubmissionDisabled() ──────► CHECK & UPDATE for new page + +═══════════════════════════════════════════════════════════════════════════════════════ + TRIGGER CONDITIONS SUMMARY +═══════════════════════════════════════════════════════════════════════════════════════ + +DISABLE CONDITIONS (btnDisabled$.next(true)): +├── No questions in assessment +├── Assessment is locked by another user +├── Form is invalid (required fields empty) +├── During submission process +└── During resubmit process + +ENABLE CONDITIONS (btnDisabled$.next(false)): +├── Assessment changes (reset) +├── Form becomes valid +├── Review in progress +├── After failed validation alert +├── After resubmit completion +└── Read-only mode (not doAssessment && not isPendingReview) + +═══════════════════════════════════════════════════════════════════════════════════════ + PROBLEM SCENARIO +═══════════════════════════════════════════════════════════════════════════════════════ + +User Flow - Original Issue (RESOLVED): +1. User visits Assessment A (has required fields) + └── Form invalid → btnDisabled$.next(true) ✓ + +2. User navigates to Assessment B via activity-desktop + └── ngOnChanges() → btnDisabled$.next(false) ✓ + └── _populateQuestionsForm() → questionsForm created + └── _populateFormWithAnswers() → form populated + └── setSubmissionDisabled() → checks validation + └── BUT: Timing issue - form may not be fully populated + └── Result: btnDisabled$ may remain false even if invalid + +3. RESOLVED: State synchronization fixed + └── _prefillForm() now properly checks validation after form population + +═══════════════════════════════════════════════════════════════════════════════════════ + SOLUTION IMPLEMENTATION (COMPLETED) +═══════════════════════════════════════════════════════════════════════════════════════ + +IMPLEMENTED FIXES: +1. ✅ Reset state in ngOnChanges when assessment changes + └── btnDisabled$.next(false) in ngOnChanges() + +2. ✅ Proper validation after form population in _prefillForm() + ├── questionsForm.updateValueAndValidity() + ├── Edit mode: setSubmissionDisabled() checks validation + └── Read-only mode: btnDisabled$.next(false) ensures enabled + +3. ✅ Check validation when changing pages + ├── prevPage() → setSubmissionDisabled() + ├── nextPage() → setSubmissionDisabled() + └── goToPage() → setSubmissionDisabled() + +4. ✅ Apply validation rules only when in edit mode + └── setSubmissionDisabled() has guard: (!doAssessment && !isPendingReview) + +5. ✅ Replaced _populateFormWithAnswers() with _prefillForm() + └── Better state management and validation synchronization + +RESULT: btnDisabled$ now accurately reflects form state at all times +═══════════════════════════════════════════════════════════════════════════════════════ + +═══════════════════════════════════════════════════════════════════════════════════════ + KEY LEARNINGS +═══════════════════════════════════════════════════════════════════════════════════════ + +1. BEHAVIORSUBJECT STATE PERSISTENCE + └── BehaviorSubject remembers last value across component input changes + └── Must explicitly reset when navigating between assessments + +2. FORM VALIDATION TIMING + └── Validation must happen AFTER form population is complete + └── updateValueAndValidity() is crucial for proper validation state + +3. SEPARATION OF CONCERNS + └── setSubmissionDisabled() handles validation-based enabling/disabling + └── _prefillForm() handles initial state setup after population + └── Each method has clear responsibility boundaries + +4. EDIT VS READ-ONLY MODES + └── Only apply validation rules when user can edit + └── Read-only mode should always have enabled button for navigation + └── Guard clauses prevent unnecessary state changes + +═══════════════════════════════════════════════════════════════════════════════════════ \ No newline at end of file diff --git a/docs/assessment-flow.md b/docs/assessment-flow.md new file mode 100644 index 000000000..6a267aeaf --- /dev/null +++ b/docs/assessment-flow.md @@ -0,0 +1,764 @@ +# 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) + ↓ (with Pagination enabled) +Page Indicators ←→ Question Groups (Split into Pages) ←→ Navigation Controls + ↓ +Question Components (Text, File, Multiple Choice, etc.) + ↓ +Bottom Action Bar (Submit/Continue Button + Pagination Controls) +``` + +### Pagination Flow +When pagination is enabled (`environment.featureToggles.assessmentPagination = true` - environment variable file): + +``` +1. Assessment loads → splitGroupsByQuestionCount() +2. Groups divided into pages (≤8 questions per page) +3. Page indicators show completion status +4. Users navigate: Prev/Next buttons or click page indicators +5. Form validation tracks completion per page +6. Submit button integrates with pagination controls +``` + +## 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 + +The form population has been refactored to ensure proper timing and validation state management. + +**Assessment Answers (`this.action === 'assessment'`):** +```typescript +private _prefillForm(): void { + // populate form with submission answers (for assessment action) + if (this.submission?.answers && this.action === 'assessment') { + 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 (for review action) + if (this.review?.answers && this.action === 'review') { + 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, + file: this.review.answers[questionId].file || null, + }; + control.setValue(reviewAnswer, { emitEvent: false }); + } + }); + } + + // revalidate form after setting values + this.questionsForm.updateValueAndValidity(); + + // check validation state and update button accordingly + if (this.doAssessment || this.isPendingReview) { + // in edit mode, check form validation + this.setSubmissionDisabled(); + } else { + // in read-only mode, ensure button is enabled + this.btnDisabled$.next(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 +// Delayed subscription to avoid race conditions during initialization +setTimeout(() => { + this.questionsForm.valueChanges.pipe( + takeUntil(this.unsubscribe$), + debounceTime(300), + ).subscribe(() => { + this.initializePageCompletion(); + this.setSubmissionDisabled(); + }); +}, 300); + +setSubmissionDisabled() { + // only enforce form validation when user can actually edit + if (!this.doAssessment && !this.isPendingReview) { + return; + } + + this.btnDisabled$.next(this.questionsForm.invalid); +} +``` + +### 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 + +The completion tracking system has been improved to handle proper initialization timing and avoid the "incompleted" class showing incorrectly on first load. + +```typescript +ngOnChanges(changes: SimpleChanges): void { + if (!this.assessment) { + return; + } + + this._initialise(); + + if (changes.assessment || changes.submission || changes.review) { + // reset button state when assessment changes + this.btnDisabled$.next(false); + this.pageRequiredCompletion = []; + + this._handleSubmissionData(); + this._populateQuestionsForm(); + this._handleReviewData(); + this._prefillForm(); + } + + // split by question count every time assessment changes - only if pagination is enabled + if (this.isPaginationEnabled) { + this.pagesGroups = this.splitGroupsByQuestionCount(); + this.pageIndex = 0; + + // initialize page completion after form is fully set up + // use delay to ensure form values are populated + setTimeout(() => { + this.initializePageCompletion(); + }, 200); + } else { + // Reset pagination data when disabled + this.pagesGroups = []; + this.pageIndex = 0; + } + + // scroll to the active page into view after rendering + setTimeout(() => this.scrollActivePageIntoView(), 250); +} + +initializePageCompletion() { + if (!this.isPaginationEnabled) return; + + // Only track completion status when user can actually edit the form + // In read-only mode (viewing feedback or completed submissions), completion tracking is not relevant + if (!this.doAssessment && !this.isPendingReview) { + // Set all pages as completed for read-only mode to avoid showing incomplete indicators + this.pageRequiredCompletion = new Array(this.pageCount).fill(true); + this.cdr.detectChanges(); + setTimeout(() => this.scrollActivePageIntoView(), 100); + return; + } + + this.pageRequiredCompletion = new Array(this.pageCount).fill(true); + + this.pages.forEach((page, index) => { + const pageQuestions = this.getAllQuestionsForPage(index); + this.pageRequiredCompletion[index] = this.areAllRequiredQuestionsAnswered(pageQuestions); + }); + + // trigger change detection to update the view + this.cdr.detectChanges(); + + // Update the scroll position when page completion status changes + setTimeout(() => this.scrollActivePageIntoView(), 100); +} +``` + +**Key Improvements for First Load Issue:** +1. **Timing Fix**: `initializePageCompletion()` is now called in `ngOnChanges()` after pagination setup with a 200ms delay +2. **Change Detection**: Added `this.cdr.detectChanges()` to ensure the view updates when completion status changes +3. **Form Population Order**: Form values are populated via `_prefillForm()` before completion tracking runs +4. **Race Condition Prevention**: Delayed form valueChanges subscription to avoid interference during initialization +5. **Read-Only Mode Handling**: Completion tracking is disabled when users are viewing feedback or completed submissions (`!doAssessment && !isPendingReview`), showing all pages as completed instead + +## Pagination Issue Fixes + +### Problem: "Incompleted" Class on First Load + +**Issue Description:** +Page indicators showed up with the "incompleted" class on first load of assessments, even when questions were already answered. This occurred due to a timing mismatch between form population and completion tracking initialization. + +**Root Cause:** +The `initializePageCompletion()` method was being called before form values were fully populated, causing `areAllRequiredQuestionsAnswered()` to return false for completed questions. + +**Solution Implemented:** + +1. **Moved completion initialization to proper lifecycle hook:** + ```typescript + // In ngOnChanges(), after pagination setup + setTimeout(() => { + this.initializePageCompletion(); + }, 200); + ``` + +2. **Added change detection trigger:** + ```typescript + initializePageCompletion() { + // ... completion logic ... + this.cdr.detectChanges(); // Ensure view updates + } + ``` + +3. **Separated form population logic:** + ```typescript + private _prefillForm(): void { + // Form population with proper validation state management + // Called before completion tracking + } + ``` + +4. **Delayed form valueChanges subscription:** + ```typescript + setTimeout(() => { + this.questionsForm.valueChanges.pipe( + takeUntil(this.unsubscribe$), + debounceTime(300), + ).subscribe(() => { + this.initializePageCompletion(); + this.setSubmissionDisabled(); + }); + }, 300); + ``` + +**Result:** +Page indicators now correctly show completion status on first load, with proper visual feedback for answered and unanswered required 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 + +## Troubleshooting + +### Common Pagination Issues + +1. **Page indicators show as incomplete on first load:** + - **Cause**: `initializePageCompletion()` called before form values are set + - **Solution**: Ensure proper timing in `ngOnChanges()` with delays + +2. **Form validation not working correctly:** + - **Cause**: Race condition between form population and validation setup + - **Solution**: Use `_prefillForm()` method with proper sequencing + +3. **Change detection not triggering:** + - **Cause**: OnPush change detection strategy requires manual triggering + - **Solution**: Call `this.cdr.detectChanges()` after completion updates + +4. **Button state incorrect on load:** + - **Cause**: Button state set before form is properly initialized + - **Solution**: Use `setSubmissionDisabled()` method with proper conditions + +5. **Completion indicators showing in read-only mode:** + - **Cause**: Completion tracking running when user is viewing feedback/completed submissions + - **Solution**: Check `doAssessment` and `isPendingReview` flags before running completion logic + + +## Testing Considerations + +### Unit Tests +- Mock assessment service responses +- Test form validation logic +- Verify button state changes +- Component interaction testing +- Test pagination initialization timing + +### 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 +- **Improved initialization timing** to prevent incorrect "incompleted" status on first load +- **Proper change detection management** with OnPush strategy +- **Race condition prevention** through strategic delays and sequencing + +Recent improvements have specifically addressed timing issues that could cause pagination indicators to display incorrectly on first load, ensuring a more reliable and user-friendly assessment experience. + + +## References +- [Button Disabled State Flow](assessment-btndisabled-flow.md) - btnDisabled$ BehaviorSubject flow diagram across assessment component lifecycle \ No newline at end of file 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/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/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/app.component.ts b/projects/v3/src/app/app.component.ts index e972ece91..0fc3d461b 100644 --- a/projects/v3/src/app/app.component.ts +++ b/projects/v3/src/app/app.component.ts @@ -29,7 +29,7 @@ export class AppComponent implements OnInit, OnDestroy { $unsubscribe = new Subject(); lastVisitedUrl: string; - // list of urls that should not be cached + // urls that should not be cached for last visited tracking noneCachedUrl = [ 'devtool', 'registration', @@ -40,6 +40,7 @@ export class AppComponent implements OnInit, OnDestroy { 'direct_login', 'do=secure', 'auth/secure', + 'assessment-mobile/review', 'undefined', ]; diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index a40cdf120..98fd37d70 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 + @@ -61,31 +81,37 @@
+
+ [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

-
- + +
-

- -
+ +
@@ -114,18 +145,18 @@

- - - + +  * + *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. - + + + + No answer for this question. + +
-

Unsupported question type: {{ question.type }}

+

Unsupported question type: {{ question.type }}

+
+ + (handleClick)="continueToNextTask()" + [hasCustomContent]="isPaginationEnabled && pageCount > 1"> + +
+ + + 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..147bd33a7 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.scss +++ b/projects/v3/src/app/components/assessment/assessment.component.scss @@ -211,3 +211,233 @@ 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 0px; + background-color: transparent; + box-shadow: none; + + &.few-pages { + justify-content: space-evenly; + } +} + +.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 5px; +} + +.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: 8px; + overflow-x: visible; + max-width: 65%; + padding: 6px 0; + margin: 0 -5px; + 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 15px, black 85%, transparent); + -webkit-mask-image: linear-gradient(to right, transparent, black 15px, black 85%, transparent); + } +} + +.page-indicators::-webkit-scrollbar { + display: none; +} + +.page-indicator { + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; + min-height: 40px; + border-radius: 50%; + background-color: #f5f5f5; + cursor: pointer; + position: relative; + transition: all 0.2s ease; + flex-shrink: 0; + border: 2px solid transparent; +} + +.page-indicator:hover { + background-color: #e0e0e0; + transform: scale(1.05); +} + +.page-indicator.active { + background-color: var(--ion-color-primary); + transform: scale(1.2); + box-shadow: 0 4px 8px rgba(0,0,0,0.3); + border-color: var(--ion-color-primary); +} + +.page-indicator.active .page-number { + color: white; + font-weight: bold; +} + +.page-indicator.completed { + background-color: #e8f5e9; + border-color: #81c784; +} + +.page-indicator.incompleted { + background-color: rgba(255, 0, 0, 0.05); + border-color: var(--ion-color-danger); +} + +.page-indicator.incompleted .page-number { + color: var(--ion-color-danger); +} + +.progress-ring { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.progress-ring-svg { + width: 36px; + height: 36px; + transform: rotate(-90deg); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(-90deg); +} + +.progress-ring-bg { + fill: none; + stroke: #e0e0e0; + stroke-width: 2; +} + +.progress-ring-fill { + fill: none; + stroke: var(--ion-color-success); + stroke-width: 3; + stroke-linecap: round; + transition: stroke-dasharray 0.3s ease; +} + +.page-indicator.active .progress-ring-fill { + stroke: white; + stroke-width: 4; +} + +.page-indicator.incompleted .progress-ring-fill { + stroke: var(--ion-color-danger); +} + +.page-number { + font-size: 14px; + font-weight: 600; + color: #555555; + z-index: 2; + margin-bottom: 2px; +} + + +.completion-icon { + position: absolute; + font-size: 12px; + color: var(--ion-color-success); + bottom: -2px; + right: -2px; + background: white; + border-radius: 50%; + z-index: 3; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.completion-icon:not(.completed) { + color: var(--ion-color-danger); +} + +@media (max-width: 576px) { + .pagination-container { + padding: 0; + + .page-indicators { + max-width: 55%; + gap: 4px; + + .page-indicator { + min-width: 32px; + min-height: 32px; + } + } + } + + .page-number { + font-size: 12px; + } + + .question-count { + font-size: 7px; + } + + .completion-icon { + font-size: 10px; + width: 14px; + height: 14px; + } + + .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.spec.ts b/projects/v3/src/app/components/assessment/assessment.component.spec.ts index 20efd664b..eb1b98c2e 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.spec.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.spec.ts @@ -397,6 +397,440 @@ describe('AssessmentComponent', () => { expect(component['_compulsoryQuestionsAnswered'](answers)).toEqual([]); }); + describe('_populateQuestionsForm()', () => { + beforeEach(() => { + component.questionsForm = new FormGroup({}); + component.btnDisabled$ = new BehaviorSubject(false); + spyOn(component.btnDisabled$, 'next'); + }); + + it('should create form controls for all questions with correct validators', () => { + // Mock assessment with different question types + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Required Text Question', + type: 'text', + isRequired: true, + audience: ['submitter'] + }, + { + id: 2, + name: 'Optional Multiple Question', + type: 'multiple', + isRequired: false, + audience: ['submitter'] + }, + { + id: 3, + name: 'Multi Team Member Selector', + type: 'multi team member selector', + isRequired: true, + audience: ['submitter'] + } + ] + } + ] + } as any; + + component.doAssessment = true; + component.isPendingReview = false; + + // Call the method + component['_populateQuestionsForm'](); + + // Check that form controls are created + expect(component.questionsForm.get('q-1')).toBeTruthy(); + expect(component.questionsForm.get('q-2')).toBeTruthy(); + expect(component.questionsForm.get('q-3')).toBeTruthy(); + + // Check that required question has validator + const requiredControl = component.questionsForm.get('q-1'); + expect(requiredControl.validator).toBeTruthy(); + + // Check that optional question has no validator + const optionalControl = component.questionsForm.get('q-2'); + expect(optionalControl.validator).toBeFalsy(); + + // Check that multi team member selector has array initial value + const multiControl = component.questionsForm.get('q-3'); + expect(multiControl.value).toEqual([]); + }); + + it('should apply required validators only when user can edit (doAssessment = true)', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Required Question', + type: 'text', + isRequired: true, + audience: ['submitter'] + } + ] + } + ] + } as any; + + component.doAssessment = true; + component.isPendingReview = false; + + component['_populateQuestionsForm'](); + + const control = component.questionsForm.get('q-1'); + expect(control.validator).toBeTruthy(); + }); + + it('should apply required validators only when user can edit (isPendingReview = true)', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Required Question', + type: 'text', + isRequired: true, + audience: ['reviewer'] + } + ] + } + ] + } as any; + + component.doAssessment = false; + component.isPendingReview = true; + component.action = 'review'; + + component['_populateQuestionsForm'](); + + const control = component.questionsForm.get('q-1'); + expect(control.validator).toBeTruthy(); + }); + + it('should not apply required validators when user cannot edit', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Required Question', + type: 'text', + isRequired: true, + audience: ['submitter'] + } + ] + } + ] + } as any; + + component.doAssessment = false; + component.isPendingReview = false; + + component['_populateQuestionsForm'](); + + const control = component.questionsForm.get('q-1'); + expect(control.validator).toBeFalsy(); + }); + + it('should use custom validator for reviewer text and file questions', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Text Question', + type: 'text', + isRequired: true, + audience: ['reviewer'] + }, + { + id: 2, + name: 'File Question', + type: 'file', + isRequired: true, + audience: ['reviewer'] + } + ] + } + ] + } as any; + + component.doAssessment = false; + component.isPendingReview = true; + component.action = 'review'; + + component['_populateQuestionsForm'](); + + const textControl = component.questionsForm.get('q-1'); + const fileControl = component.questionsForm.get('q-2'); + + // Check that custom validator is applied (we can't directly check which validator, + // but we can verify validator exists and behaves correctly) + expect(textControl.validator).toBeTruthy(); + expect(fileControl.validator).toBeTruthy(); + + // Test custom validator behavior + textControl.setValue(null); + expect(textControl.valid).toBeFalsy(); + expect(textControl.errors?.required).toBeTruthy(); + }); + + it('should use file validator for learner file questions', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'File Question', + type: 'file', + isRequired: true, + audience: ['submitter'] + } + ] + } + ] + } as any; + + component.doAssessment = true; + component.isPendingReview = false; + component.action = 'assessment'; + + component['_populateQuestionsForm'](); + + const control = component.questionsForm.get('q-1'); + expect(control.validator).toBeTruthy(); + + // Test file validator behavior + control.setValue(null); + expect(control.valid).toBeFalsy(); + expect(control.errors?.required).toBeTruthy(); + }); + + it('should initialize review form structure correctly', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Text Question', + type: 'text', + isRequired: true, + audience: ['reviewer'] + }, + { + id: 2, + name: 'Multi Team Member Selector', + type: 'multi team member selector', + isRequired: false, + audience: ['reviewer'] + } + ] + } + ] + } as any; + + component.action = 'review'; + component.doAssessment = false; + component.isPendingReview = true; + + component['_populateQuestionsForm'](); + + const textControl = component.questionsForm.get('q-1'); + const multiControl = component.questionsForm.get('q-2'); + + // Check review form structure + expect(textControl.value).toEqual({ + comment: '', + answer: '', + file: null + }); + + // Check multi team member selector has answer as array + expect(multiControl.value.answer).toEqual([]); + expect(multiControl.value.comment).toBe(''); + expect(multiControl.value.file).toBe(null); + }); + + it('should disable button when no questions exist', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [] + } as any; + + spyOn(utils, 'isEmpty').and.returnValue(true); + + component['_populateQuestionsForm'](); + + expect(component.btnDisabled$.next).toHaveBeenCalledWith(true); + }); + + it('should set up form value change subscription', fakeAsync(() => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Text Question', + type: 'text', + isRequired: false, + audience: ['submitter'] + } + ] + } + ] + } as any; + + component.doAssessment = true; + component.isPendingReview = false; + + spyOn(component, 'initializePageCompletion'); + spyOn(component, 'setSubmissionDisabled'); + spyOn(utils, 'isEmpty').and.returnValue(false); + + component['_populateQuestionsForm'](); + + // Trigger form value change + component.questionsForm.get('q-1').setValue('test value'); + tick(300); // Wait for debounce + + expect(component.initializePageCompletion).toHaveBeenCalled(); + expect(component.setSubmissionDisabled).toHaveBeenCalled(); + })); + + it('should handle multiple groups with different question types', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Text Question', + type: 'text', + isRequired: true, + audience: ['submitter'] + } + ] + }, + { + name: 'Group 2', + questions: [ + { + id: 2, + name: 'Multiple Question', + type: 'multiple', + isRequired: false, + audience: ['submitter'] + }, + { + id: 3, + name: 'File Question', + type: 'file', + isRequired: true, + audience: ['submitter'] + } + ] + } + ] + } as any; + + component.doAssessment = true; + component.isPendingReview = false; + component.action = 'assessment'; + + component['_populateQuestionsForm'](); + + // Check all controls are created + expect(component.questionsForm.get('q-1')).toBeTruthy(); + expect(component.questionsForm.get('q-2')).toBeTruthy(); + expect(component.questionsForm.get('q-3')).toBeTruthy(); + + // Check validators are applied correctly + expect(component.questionsForm.get('q-1').validator).toBeTruthy(); // required text + expect(component.questionsForm.get('q-2').validator).toBeFalsy(); // optional multiple + expect(component.questionsForm.get('q-3').validator).toBeTruthy(); // required file + }); + + it('should not apply validators for questions not in user audience', () => { + component.assessment = { + id: 1, + type: 'quiz', + isForTeam: false, + groups: [ + { + name: 'Group 1', + questions: [ + { + id: 1, + name: 'Reviewer Only Question', + type: 'text', + isRequired: true, + audience: ['reviewer'] // submitter not in audience + } + ] + } + ] + } as any; + + component.doAssessment = true; // user is doing assessment (submitter role) + component.isPendingReview = false; + component.action = 'assessment'; + + component['_populateQuestionsForm'](); + + const control = component.questionsForm.get('q-1'); + expect(control.validator).toBeFalsy(); // should not have validator since not in audience + }); + }); + describe('should get correct assessment answers when', () => { let assessment; let answers; diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index be7ad5278..7e7705773 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -1,12 +1,12 @@ import { environment } from '@v3/environments/environment'; -import { Component, Input, Output, EventEmitter, OnChanges, OnDestroy, OnInit, QueryList, ViewChildren, ChangeDetectionStrategy, ViewChild, signal, ElementRef, SimpleChanges } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnChanges, OnDestroy, OnInit, QueryList, ViewChildren, ChangeDetectionStrategy, ViewChild, signal, ElementRef, SimpleChanges, ChangeDetectorRef } from '@angular/core'; import { Assessment, Submission, AssessmentReview, AssessmentSubmitParams, AssessmentService } from '@v3/services/assessment.service'; import { UtilsService } from '@v3/services/utils.service'; 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'; @@ -20,6 +20,16 @@ import { ActivityService } from '@v3/app/services/activity.service'; import { FileInput, Question, SubmitActions } from '../types/assessment'; import { FileUploadComponent } from '../file-upload/file-upload.component'; +const MIN_SCROLLING_PAGES = 8; // minimum number of pages to show pagination scrolling +const MAX_QUESTIONS_PER_PAGE = 8; // maximum number of questions to display per paginated view (controls pagination granularity) + +/** + * 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', @@ -118,7 +128,13 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { questionsForm?: FormGroup = new FormGroup({}); @ViewChild('form') form?: HTMLFormElement; - @ViewChildren('questionBox') questionBoxes!: QueryList<{el: HTMLElement}>; + + // 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() { @@ -132,6 +148,7 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { private sharedService: SharedService, private assessmentService: AssessmentService, private activityService: ActivityService, + private cdr: ChangeDetectorRef, ) { this.resubscribe$.pipe( takeUntil(this.unsubscribe$), @@ -141,6 +158,59 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { } + pageSize = MAX_QUESTIONS_PER_PAGE; // number of questions per page + pageIndex = 0; + + // 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.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(); + } + } + + nextPage() { + if (!this.isPaginationEnabled) return; + if (this.pageIndex < this.pageCount - 1) { + this.pageIndex++; + this.scrollActivePageIntoView(); + } + } + + 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(); + } + } + ngOnInit(): void { this.subscribeSaveSubmission(); } @@ -279,17 +349,6 @@ Best regards`; }): Observable { const answer = this._getAnswerValueForQuestion(questionInput.questionId, questionInput.answer); - this.filledAnswers().forEach(answerObj => { - if (answerObj.questionId === questionInput.questionId) { - // if the answer is empty, we need to set it to null - if (this.utils.isEmpty(answer)) { - answerObj.answer = null; - } else { - answerObj.answer = answer; - } - } - }); - return this.assessmentService.saveQuestionAnswer( questionInput.submissionId, questionInput.questionId, @@ -349,10 +408,29 @@ Best regards`; } this._initialise(); - this._populateQuestionsForm(); this._handleSubmissionData(); + this._populateQuestionsForm(); this._handleReviewData(); this._preventSubmission(); + + // split by question count every time assessment changes - only if pagination is enabled + if (this.isPaginationEnabled) { + this.pagesGroups = this.splitGroupsByQuestionCount(); + this.pageIndex = 0; + + setTimeout(() => { + this.initializePageCompletion(); + }, 200); + } else { + // Reset pagination data when disabled + this.pagesGroups = []; + this.pageIndex = 0; + } + + this._populateFormWithAnswers(); + + // scroll to the active page into view after rendering + setTimeout(() => this.scrollActivePageIntoView(), 250); } ngOnDestroy() { @@ -373,6 +451,43 @@ 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 _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 || (Object.keys(value.file).length === 0))) { + return { required: true }; + } + } else if (typeof value === 'string') { + if (value.length === 0) { + return { required: true }; + } + } + 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() { @@ -382,13 +497,53 @@ 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._answerRequiredValidatorForReviewer]; + } else if (question.type === 'file' && this.action === 'assessment') { + validator = [this._fileRequiredValidatorForLearner]; + } else { + validator = [Validators.required]; + } + } + + + let quesCtrl: { answer: any; comment?: string; file?: any } | any = null; + + if (this.action === 'review') { + quesCtrl = { + comment: '', + answer: question.type === 'multi team member selector' ? [] : '', + file: null + }; + } else { + // For assessment mode, initialize multi team member selector with proper structure + if (question.type === 'multi team member selector') { + quesCtrl = { answer: [] }; + } } - this.questionsForm.addControl('q-' + question.id, new FormControl('', validator)); + this.questionsForm.addControl('q-' + question.id, new FormControl(quesCtrl, validator)); }); }); + + // when no questions in the assessment, disable the button + if (this.utils.isEmpty(this.questionsForm.getRawValue())) { + return this.btnDisabled$.next(true); + } + + // delay the subscription to avoid race conditions during initialization + setTimeout(() => { + this.questionsForm.valueChanges.pipe( + takeUntil(this.unsubscribe$), + debounceTime(300), + ).subscribe(() => { + this.initializePageCompletion(); + this.setSubmissionDisabled(); + }); + }, 300); } /** @@ -419,7 +574,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; } @@ -438,8 +593,8 @@ Best regards`; } private _handleReviewData() { - if (this.isPendingReview && this.review.status === 'in progress') { - this.savingMessage$.next($localize `Last saved ${this.utils.timeFormatter(this.review.modified)}`); + if (this.isPendingReview && this.review?.status === 'in progress') { + this.savingMessage$.next($localize`Last saved ${this.utils.timeFormatter(this.review.modified)}`); this.btnDisabled$.next(false); } } @@ -485,7 +640,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; } @@ -604,9 +759,9 @@ Best regards`; answer = []; break; case 'text': - case 'file': - case 'team-member-selector': - case 'multi-team-member-selector': + case 'file': // answer is for text/oneof/multiple/slider only, file is always '' + case 'team member selector': + case 'multi team member selector': answer = ''; break; case 'slider': @@ -618,7 +773,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); @@ -846,4 +1001,339 @@ 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() { + if (!this.isPaginationEnabled) return; + + // read-only mode (viewing feedback or completed submissions), completion tracking is not relevant + if (!this.doAssessment && !this.isPendingReview) { + this.pageRequiredCompletion = new Array(this.pageCount).fill(true); + this.cdr.detectChanges(); + setTimeout(() => this.scrollActivePageIntoView(), 100); + return; + } + + this.pageRequiredCompletion = new Array(this.pageCount).fill(true); + + this.pages.forEach((page, index) => { + const pageQuestions = this.getAllQuestionsForPage(index); + this.pageRequiredCompletion[index] = this.areAllRequiredQuestionsAnswered(pageQuestions); + }); + + this.cdr.detectChanges(); + + // Update the scroll position when page completion status changes + setTimeout(() => this.scrollActivePageIntoView(), 100); + } + + 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 []; + } + + 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 { + 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)); + + // 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() { + if (!this.isPaginationEnabled) return; + + 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); + } + + private _populateFormWithAnswers() { + // Populate form with 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); + 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 && this.action === 'review') { + 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, + file: this.review.answers[questionId].file || null, + }; + control.setValue(reviewAnswer, { emitEvent: false }); + } + }); + } + + 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); + } + + /** + * prefill form with answers and check validation state + * replaces the old _populateFormWithAnswers() method + */ + private _prefillForm(): void { + // populate form with submission answers (for assessment action) + if (this.submission?.answers && this.action === 'assessment') { + 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 (for review action) + if (this.review?.answers && this.action === 'review') { + 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, + file: this.review.answers[questionId].file || null, + }; + control.setValue(reviewAnswer, { emitEvent: false }); + } + }); + } + + // revalidate form after setting values + this.questionsForm.updateValueAndValidity(); + + // check validation state and update button accordingly + if (this.doAssessment || this.isPendingReview) { + // in edit mode, check form validation + this.setSubmissionDisabled(); + } else { + // in read-only mode, ensure button is enabled + this.btnDisabled$.next(false); + } + } + + + 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); + } } 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..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,23 +1,32 @@ - +

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..1f66be90c 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,99 @@ 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: center; + align-items: center; + white-space: nowrap; // Prevent button text wrapping + width: 100%; + + &.with-custom-content { + margin-top: 0; + justify-content: flex-end; + } +} + +.action-button { + margin: 0 8px; // Reduce side margins + --padding-start: 16px; + --padding-end: 16px; + min-width: 120px; // Ensure minimum button width +} + +// mobile responsiveness +@media (max-width: 768px) { + .action-container { + flex-direction: column; // Stack vertically + } + + .custom-content { + margin-right: 0; + margin-bottom: 8px; + } + + .button-container { + width: 100%; + justify-content: center !important; + } + + .action-button { + --padding-start: 12px; + --padding-end: 12px; + min-width: auto; + } +} + +@media (max-width: 576px) { + .with-custom-content > .action-button { + --padding-start: 12px; + --padding-end: 12px; + 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.) +@media (max-width: 375px) { + .with-custom-content > .action-button { + font-size: 14px; + 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/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/file-upload/file-upload.component.ts b/projects/v3/src/app/components/file-upload/file-upload.component.ts index 7a3d85547..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 @@ -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 @@ -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'); } 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..2e5cd03ee 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 +

+
@@ -33,11 +40,19 @@

- + + +
- -

+ + +

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 412f97555..33c58f3ab 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 @@ -2,6 +2,7 @@ import { Component, Input, forwardRef, ViewChild, ElementRef, OnInit } from '@an import { NG_VALUE_ACCESSOR, ControlValueAccessor, AbstractControl } from '@angular/forms'; import { UtilsService } from '@v3/app/services/utils.service'; import { Subject } from 'rxjs'; +import { Question } from '../types/assessment'; @Component({ selector: 'app-multi-team-member-selector', @@ -18,7 +19,7 @@ import { Subject } from 'rxjs'; export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, OnInit { @Input() submitActions$: Subject; - @Input() question; + @Input() question: Question; @Input() submission; @Input() submissionId: number; @Input() review; @@ -32,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 @@ -90,10 +91,6 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O // event fired when radio is selected. propagate the change up to the form control using the custom value accessor interface // if 'type' is set, it means it comes from reviewer doing review, otherwise it comes from submitter doing assessment onChange(value, type?: string) { - // innerValue should be either array or object, if it is a string, parse it - if (typeof this.innerValue === 'string') { - this.innerValue = JSON.parse(this.innerValue); - } // set changed value (answer or comment) if (type) { // initialise innerValue if not set @@ -110,9 +107,6 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O this.innerValue.answer = this.utils.addOrRemove(this.innerValue.answer, value); } } else { - if (!this.innerValue) { - this.innerValue = []; - } this.innerValue = this.utils.addOrRemove(this.innerValue, value); } @@ -136,7 +130,7 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O // From ControlValueAccessor interface writeValue(value: any) { if (value) { - this.innerValue = JSON.stringify(value); + this.innerValue = value; } } @@ -152,18 +146,22 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O // 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: this.review.answer, + answer: this.review.answer || [], comment: this.review.comment }; this.comment = this.review.comment; + } 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; } - if ((this.submissionStatus === 'in progress') && (this.doAssessment)) { - this.innerValue = this.submission.answer; - } + this.propagateChange(this.innerValue); - this.control.setValue(this.innerValue); } // check question audience have more that one audience and is it includes reviewer as audience. @@ -181,4 +179,68 @@ 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) return false; + if (this.doReview && !this.innerValue.answer) return false; + if (this.doAssessment && !this.innerValue) return false; + + try { + const answer = this.doReview ? this.innerValue.answer : this.innerValue; + const memberObj = JSON.parse(teamMember.key); + return 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; + } + } + + // 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 83066219e..00c0ad371 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.html +++ b/projects/v3/src/app/components/multiple/multiple.component.html @@ -44,15 +44,26 @@

{ - + + > + + + { [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 80a7008cd..058952280 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.html +++ b/projects/v3/src/app/components/oneof/oneof.component.html @@ -41,20 +41,30 @@

{{question.
- - + + + > + @@ -88,7 +98,13 @@

{{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 0314cc7d5..223621f1b 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. @@ -194,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/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 + +
- - +