From 08a4dc5c4a8e4b531acb85536029d655ea383b3d Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 16 Jul 2026 15:03:24 +0800 Subject: [PATCH] [CORE-8277] reviewer-only improvement --- docs/assessment-flow.md | 14 +- docs/features/slider-rating-implementation.md | 1 + ...readonly-preview-multiple-selected-only.md | 70 +++-- .../assessment/assessment.component.html | 66 +++-- .../assessment/assessment.component.scss | 24 ++ .../assessment/assessment.component.spec.ts | 268 ++++++++++++++++++ .../assessment/assessment.component.ts | 106 ++++++- .../bottom-action-bar.component.html | 16 +- .../bottom-action-bar.component.scss | 8 +- .../bottom-action-bar.component.spec.ts | 61 ++++ .../bottom-action-bar.component.ts | 36 ++- .../file-upload/file-upload.component.html | 11 +- .../file-upload/file-upload.component.ts | 2 + .../multi-team-member-selector.component.html | 13 +- ...lti-team-member-selector.component.spec.ts | 46 +++ .../multi-team-member-selector.component.ts | 11 + .../multiple/multiple.component.html | 27 +- .../multiple/multiple.component.scss | 1 - .../multiple/multiple.component.spec.ts | 97 +++++-- .../components/multiple/multiple.component.ts | 17 ++ .../app/components/oneof/oneof.component.html | 20 +- .../components/oneof/oneof.component.spec.ts | 48 ++++ .../app/components/oneof/oneof.component.ts | 2 + .../components/slider/slider.component.html | 35 ++- .../components/slider/slider.component.scss | 1 - .../slider/slider.component.spec.ts | 63 ++++ .../app/components/slider/slider.component.ts | 12 + .../team-member-selector.component.html | 11 +- .../team-member-selector.component.spec.ts | 41 +++ .../team-member-selector.component.ts | 11 + .../app/components/text/text.component.html | 9 +- .../components/text/text.component.spec.ts | 45 ++- .../src/app/components/text/text.component.ts | 2 + 33 files changed, 1071 insertions(+), 124 deletions(-) diff --git a/docs/assessment-flow.md b/docs/assessment-flow.md index 4158a91111..ddb4876abd 100644 --- a/docs/assessment-flow.md +++ b/docs/assessment-flow.md @@ -300,17 +300,25 @@ All follow similar patterns with dual-purpose display for learner/reviewer conte ```html {{ text }} + [attr.aria-busy]="loading ? 'true' : 'false'"> + + {{ text }} + ``` **Button States:** - **Enabled**: Form is valid and user can submit -- **Disabled**: Form has validation errors or submission in progress +- **Disabled**: Form has validation errors or an action is already in progress +- **Loading**: For assessment/review submit actions, starts before the click event is emitted, keeps the disabled button visible with an inline spinner, and clears when `disabled$` emits `false` - **Dynamic Text**: Changes based on context (Submit, Continue, Mark as Read, etc.) +`disabled$` remains the source of truth for whether the action can be triggered. Loading is a distinct, opt-in visual state (`showLoadingOnClick`) so validation-disabled buttons do not incorrectly announce `aria-busy`, and non-submit actions retain their existing behavior. + +During manual submission, the parent page owns the terminal `disabled$ = false` transition. Intermediate assessment/review refetches may update displayed data and the last-saved message, but must not re-enable the action while the assessment component's submission guard is active. The parent clears the state only after the final refresh succeeds or the submission fails. + ## Data Flow Diagrams ### Assessment Submission Flow (Learner) diff --git a/docs/features/slider-rating-implementation.md b/docs/features/slider-rating-implementation.md index 93c74f5428..6dffb2ba14 100644 --- a/docs/features/slider-rating-implementation.md +++ b/docs/features/slider-rating-implementation.md @@ -261,6 +261,7 @@ pinFormatter = (value: number): string => { margin-top: 16px; .label { + // Uses the shared ion-chip.label sizing and typography. &.orange { /* Learner answer styling */ } &.yellow { /* Expert answer styling */ } } diff --git a/docs/fixes/readonly-preview-multiple-selected-only.md b/docs/fixes/readonly-preview-multiple-selected-only.md index 210154fd70..b5b1de452e 100644 --- a/docs/fixes/readonly-preview-multiple-selected-only.md +++ b/docs/fixes/readonly-preview-multiple-selected-only.md @@ -2,36 +2,70 @@ status: stable authority: historical scope: frontend -last_reviewed: 2026-05-21 -supersedes: none +last_reviewed: 2026-07-16 +supersedes: CORE-8277 all-choice checkbox feedback presentation --- -# readonly preview: show selected answers only (multiple & oneof) +# readonly selected answers and reviewer feedback context ## rule -in preview and other readonly display-only states (`isDisplayOnly = true`), question components should render only the choice rows that were selected — by the learner, the reviewer, or both. unselected choices must not appear. +CORE-8225 selected-only rendering remains the rule for read-only checkbox feedback. Show the union +of choices selected by the learner or reviewer; never show unselected choices. If both selected the +same choice, render it once with both ownership labels. -## components affected +Reviewer-only groups are hidden from learner answering and pending-review views. After feedback is +published, every group containing only reviewer-only questions is shown under **Reviewer Feedback**. +The section establishes answer ownership, so its responses do not repeat **Reviewer's Answer**. -| component | question type | answer shape | +## display behaviour + +| context | choices | ownership labels | |---|---|---| -| `app-multiple` | multiple choice (checkboxes) | array of ids | -| `app-oneof` | single choice (radio) | scalar id | +| learner viewing a shared question | selected learner/reviewer values only | **Your Answer** and **Reviewer's Answer** | +| reviewer viewing a shared question | selected learner/reviewer values only | **Learner's Answer** and **Reviewer's Answer** | +| learner viewing **Reviewer Feedback** | reviewer-selected values only | none; the section supplies context | +| learner answering or waiting for review | reviewer-only groups hidden | existing authoring behaviour | +| reviewer authoring or viewing a review | configured groups and order unchanged | existing reviewer behaviour | ## implementation -### app-multiple (`multiple.component.ts` / `multiple.component.html`) -- `displayChoices` getter collects all selected ids from `submission.answer` (array) and `review.answer` (array) into a `Set`, then filters `question.choices` to matching entries. -- handles stringified and nested answer payloads via `_collectSelectedChoiceIds()`. -- template iterates `displayChoices` instead of `question.choices` inside the `*ngIf="isDisplayOnly"` branch. +### assessment groups + +- A group is reviewer-only when it has at least one question and every question is marked + `reviewerOnly`, or has `reviewer` as its sole audience. +- `displayGroups` removes reviewer-only groups before publication and appends them after ordinary + learner groups once feedback is available. +- `isReviewerFeedbackContext` is passed explicitly to question components. Do not infer this only + from `question.reviewerOnly`. It suppresses redundant ownership labels for published learner + feedback and completed reviewer views, while shared groups and pending review authoring retain + their labels. +- Pending reviewers see one neutral **Reviewer-only questions** guidance callout at the start of + each contiguous reviewer-only section, including when pagination begins within that section. +- Empty reviewer arrays, strings, and objects use the neutral **No answer for this question** state. + +### choice questions + +- `app-multiple` collects learner and reviewer answer ids, including stringified or nested payloads, + and filters configured choices to the selected union. +- `app-oneof` remains selected-only and renders both selected rows when learner and reviewer chose + different values. +- In shared-question read-only views, ownership chips in choice and team-member selector questions + render before the selected answer content, matching the text and file question layout. +- Reviewer-only team selectors filter their read-only list to reviewer-selected members. +- Selection inputs remain unchanged during learner and reviewer authoring. + +### other reviewer-only answers -### app-oneof (`oneof.component.ts` / `oneof.component.html`) -- `displayChoices` getter adds `submission.answer` and `review.answer` (both scalars) into a `Set`, then filters `question.choices`. -- if the learner and reviewer selected different options, both rows are shown — each with its own "Learner's Answer" / "Reviewer's Answer" chip. -- if they selected the same option, the Set deduplicates it; the single row shows both chips. -- template iterates `displayChoices` instead of `question.choices` inside the `*ngIf="isDisplayOnly"` branch. +- Text and file/video responses render their content without a reviewer ownership chip. +- Sliders use the review answer as the disabled scale value without a reviewer ownership chip. +- Genuine content labels such as **Feedback** remain visible. ## verification -- `projects/v3/src/app/components/multiple/multiple.component.spec.ts` covers the filtered preview list and the rendered template output for the multiple type. \ No newline at end of file +- Assessment tests cover reviewer-only visibility, ordering, pagination, authoring guidance, + context, and empty answers. +- Choice component tests cover selected unions, shared selections, reviewer-only selections, and + learner/reviewer ownership labels. +- Text, slider, file/video, and selector checks cover contextual ownership suppression in published + learner feedback and completed reviewer views. diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index 4325d5f425..0dc3334f81 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.html +++ b/projects/v3/src/app/components/assessment/assessment.component.html @@ -173,6 +173,26 @@ i18n-aria-label novalidate> +
+

Reviewer Feedback

+

These criteria were completed by your reviewer.

+
+ +
+

Reviewer-only questions

+

Complete these questions as part of your review. Your answers will be shared with the learner after you submit the review.

+
+
@@ -271,23 +291,18 @@ i18n-aria-label>
- - - - No answer for this question. - - + + + No answer for this question. + @@ -315,6 +332,8 @@ [review]="review?.answers[question.id] || {}" [reviewStatus]="review ? review.status : ''" [submissionStatus]="submission ? submission.status : ''" + [viewerRole]="action === 'assessment' ? 'learner' : 'reviewer'" + [isReviewerFeedbackContext]="isReviewerOnlyReadOnlyGroup(group)" [formControlName]="'q-' + question.id" [control]="questionsForm?.controls['q-' + question.id]" [submitActions$]="submitActions"> @@ -330,6 +349,8 @@ [review]="review?.answers[question.id] || {}" [reviewStatus]="review ? review.status : ''" [submissionStatus]="submission ? submission.status : ''" + [viewerRole]="action === 'assessment' ? 'learner' : 'reviewer'" + [isReviewerFeedbackContext]="isReviewerOnlyReadOnlyGroup(group)" [formControlName]="'q-' + question.id" [control]="questionsForm?.controls['q-' + question.id]" [submitActions$]="submitActions"> @@ -345,6 +366,8 @@ [review]="review?.answers[question.id] || {}" [reviewStatus]="review ? review.status : ''" [submissionStatus]="submission ? submission.status : ''" + [viewerRole]="action === 'assessment' ? 'learner' : 'reviewer'" + [isReviewerFeedbackContext]="isReviewerOnlyReadOnlyGroup(group)" [formControlName]="'q-' + question.id" [control]="questionsForm?.controls['q-' + question.id]" [submitActions$]="submitActions"> @@ -361,6 +384,8 @@ [review]="review?.answers[question.id] || {}" [reviewStatus]="review ? review.status : ''" [submissionStatus]="submission ? submission.status : ''" + [viewerRole]="action === 'assessment' ? 'learner' : 'reviewer'" + [isReviewerFeedbackContext]="isReviewerOnlyReadOnlyGroup(group)" [control]="questionsForm?.controls['q-' + question.id]" [videoOnly]="true" [submitActions$]="submitActions"> @@ -377,6 +402,8 @@ [review]="review?.answers[question.id] || {}" [reviewStatus]="review ? review.status : ''" [submissionStatus]="submission ? submission.status : ''" + [viewerRole]="action === 'assessment' ? 'learner' : 'reviewer'" + [isReviewerFeedbackContext]="isReviewerOnlyReadOnlyGroup(group)" [control]="questionsForm?.controls['q-' + question.id]" [submitActions$]="submitActions"> @@ -391,6 +418,8 @@ [review]="review?.answers[question.id] || {}" [reviewStatus]="review ? review.status : ''" [submissionStatus]="submission ? submission.status : ''" + [viewerRole]="action === 'assessment' ? 'learner' : 'reviewer'" + [isReviewerFeedbackContext]="isReviewerOnlyReadOnlyGroup(group)" [formControlName]="'q-' + question.id" [control]="questionsForm?.controls['q-' + question.id]" [submitActions$]="submitActions"> @@ -406,6 +435,8 @@ [review]="review?.answers[question.id] || {}" [reviewStatus]="review ? review.status : ''" [submissionStatus]="submission ? submission.status : ''" + [viewerRole]="action === 'assessment' ? 'learner' : 'reviewer'" + [isReviewerFeedbackContext]="isReviewerOnlyReadOnlyGroup(group)" [formControlName]="'q-' + question.id" [control]="questionsForm?.controls['q-' + question.id]" [submitActions$]="submitActions"> @@ -443,6 +474,7 @@ (handleResubmit)="resubmit()" [text]="btnText" [disabled$]="btnDisabled$" + [showLoadingOnClick]="showSubmitLoadingOnClick" (handleClick)="continueToNextTask()" [hasCustomContent]="isPaginationEnabled && pageCount > 1"> diff --git a/projects/v3/src/app/components/assessment/assessment.component.scss b/projects/v3/src/app/components/assessment/assessment.component.scss index b0eace2ead..46d64bbde4 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.scss +++ b/projects/v3/src/app/components/assessment/assessment.component.scss @@ -138,6 +138,30 @@ ion-footer { margin-bottom: 10px; } +.reviewer-feedback-section, +.reviewer-only-guidance { + margin: 24px 0 8px; + padding: 16px; + background: var(--practera-light-grey-50); + + h3, + p { + margin: 0; + } + + p { + margin-top: 4px; + } +} + +.reviewer-feedback-section { + border-left: 4px solid var(--ion-color-success); +} + +.reviewer-only-guidance { + border-left: 4px solid var(--practera-dark-blue-100); +} + // styles use for due dates and pulsing animation .due-date { margin-bottom: 0; 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 c6bf94fc06..c58344d2c1 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.spec.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.spec.ts @@ -1227,6 +1227,20 @@ describe('AssessmentComponent', () => { }); describe('continueToNextTask()', () => { + it('should enable loading-on-click only for submit actions', () => { + component.doAssessment = true; + component.isPendingReview = false; + expect(component.showSubmitLoadingOnClick).toBeTrue(); + + component.doAssessment = false; + component.isPendingReview = true; + expect(component.showSubmitLoadingOnClick).toBeTrue(); + + component.isPendingReview = false; + component.submission = { ...mockSubmission, status: 'done' } as any; + expect(component.showSubmitLoadingOnClick).toBeFalse(); + }); + it('should submit assessment', async () => { component.doAssessment = true; expect(component.btnText).toEqual('submit answers'); @@ -1658,6 +1672,44 @@ describe('AssessmentComponent', () => { }); describe('ngOnChanges() submitting flag preservation', () => { + it('should keep the review button disabled when an in-progress review is refetched during submit', () => { + component.action = 'review'; + component.assessment = { ...mockAssessment, type: 'moderated' } as any; + component.submission = { ...mockSubmission, status: 'pending review' } as any; + component.review = { ...mockReview, status: 'in progress' } as any; + component['submitting'] = true; + component.btnDisabled$.next(true); + + component.ngOnChanges({ + submission: { + previousValue: component.submission, + currentValue: component.submission, + firstChange: false, + isFirstChange: () => false, + }, + review: { + previousValue: component.review, + currentValue: component.review, + firstChange: false, + isFirstChange: () => false, + }, + } as any); + + expect(component['submitting']).toBeTrue(); + expect(component.btnDisabled$.getValue()).toBeTrue(); + }); + + it('should enable the review button when an in-progress review loads outside submission', () => { + component.isPendingReview = true; + component.review = { ...mockReview, status: 'in progress' } as any; + component['submitting'] = false; + component.btnDisabled$.next(true); + + component['_handleReviewData'](); + + expect(component.btnDisabled$.getValue()).toBeFalse(); + }); + it('should preserve submitting=true when same submission is refetched during submit', () => { // simulate initial state: user clicked submit component.ngOnChanges({ @@ -2377,6 +2429,222 @@ describe('AssessmentComponent', () => { }); }); + describe('CORE-8277: reviewer-only feedback group visibility', () => { + const learnerGroup = { + name: 'Learner Submission', + description: '', + questions: [{ id: 1, audience: ['submitter'], reviewerOnly: false }], + } as any; + const reviewerGroup = { + name: 'Client Criteria', + description: '', + questions: [{ id: 2, audience: ['reviewer'], reviewerOnly: true }], + } as any; + const secondReviewerGroup = { + name: 'Internal Notes', + description: '', + questions: [{ id: 3, audience: ['reviewer'], reviewerOnly: true }], + } as any; + + beforeEach(() => { + component.action = 'assessment'; + component.assessment = { + groups: [reviewerGroup, learnerGroup, secondReviewerGroup], + } as any; + component.submission = { status: 'feedback available' } as any; + }); + + it('should append every reviewer-only group after learner groups', () => { + expect(component.displayGroups.map(group => group.name)).toEqual([ + 'Learner Submission', + 'Client Criteria', + 'Internal Notes', + ]); + }); + + it('should require every question in a non-empty group to be reviewer-only', () => { + const mixedGroup = { + name: 'Mixed Questions', + questions: [ + { id: 4, audience: ['reviewer'], reviewerOnly: true }, + { id: 5, audience: ['submitter', 'reviewer'], reviewerOnly: false }, + ], + } as any; + const emptyGroup = { name: 'Empty', questions: [] } as any; + + expect(component.isReviewerOnlyGroup(reviewerGroup)).toBeTrue(); + expect(component.isReviewerOnlyGroup(mixedGroup)).toBeFalse(); + expect(component.isReviewerOnlyGroup(emptyGroup)).toBeFalse(); + }); + + it('should identify the first group in the dedicated reviewer feedback section', () => { + const groups = component.displayGroups; + + expect(component.isFirstReviewerFeedbackGroup(0, groups)).toBeFalse(); + expect(component.isFirstReviewerFeedbackGroup(1, groups)).toBeTrue(); + }); + + it('should render one dedicated reviewer feedback heading', () => { + fixture.detectChanges(); + + const sections = fixture.nativeElement.querySelectorAll('.reviewer-feedback-section'); + expect(sections.length).toBe(1); + expect(sections[0].textContent).toContain('Reviewer Feedback'); + expect(sections[0].textContent).toContain('These criteria were completed by your reviewer.'); + }); + + it('should hide every reviewer-only group before feedback is published', () => { + component.submission = { status: 'pending review' } as any; + + expect(component.displayGroups.map(group => group.name)).toEqual(['Learner Submission']); + }); + + it('should show a neutral no-answer state for an empty reviewer checkbox answer', () => { + const question = { + id: 2, + type: 'multiple', + audience: ['reviewer'], + reviewerOnly: true, + } as any; + component.doAssessment = false; + component.isPendingReview = false; + component.review = { answers: { 2: { answer: [] } } } as any; + + expect(component.shouldShowNoAnswer(question)).toBeTrue(); + + component.review.answers[2].answer = [1]; + expect(component.shouldShowNoAnswer(question)).toBeFalse(); + }); + + it('should leave slider empty states to the slider component', () => { + const question = { + id: 2, + type: 'slider', + audience: ['reviewer'], + reviewerOnly: true, + } as any; + component.doAssessment = false; + component.review = { answers: { 2: { answer: null } } } as any; + + expect(component.shouldShowNoAnswer(question)).toBeFalse(); + }); + + it('should preserve configured groups and order for reviewer views', () => { + component.action = 'review'; + + expect(component.displayGroups.map(group => group.name)).toEqual([ + 'Client Criteria', + 'Learner Submission', + 'Internal Notes', + ]); + }); + + it('should suppress ownership labels in completed reviewer-only groups for reviewer views', () => { + component.action = 'review'; + component.doAssessment = false; + component.isPendingReview = false; + component.review = { ...mockReview, status: 'done' } as any; + + expect(component.isReviewerOnlyReadOnlyGroup(reviewerGroup)).toBeTrue(); + expect(component.isReviewerOnlyReadOnlyGroup(learnerGroup)).toBeFalse(); + }); + + it('should retain reviewer authoring labels while a review is pending', () => { + component.action = 'review'; + component.doAssessment = false; + component.isPendingReview = true; + component.review = { ...mockReview, status: 'in progress' } as any; + + expect(component.isReviewerOnlyReadOnlyGroup(reviewerGroup)).toBeFalse(); + }); + + it('should keep the reviewer feedback heading learner-specific', () => { + component.action = 'review'; + component.doAssessment = false; + component.isPendingReview = false; + component.review = { ...mockReview, status: 'done' } as any; + + expect(component.isReviewerOnlyReadOnlyGroup(reviewerGroup)).toBeTrue(); + expect(component.isReviewerFeedbackGroup(reviewerGroup)).toBeFalse(); + expect(component.isFirstReviewerFeedbackGroup(0, [reviewerGroup])).toBeFalse(); + }); + + it('should identify the first pending reviewer-only authoring group in a contiguous section', () => { + component.action = 'review'; + component.isPendingReview = true; + const groups = [learnerGroup, reviewerGroup, secondReviewerGroup]; + + expect(component.isFirstReviewerOnlyAuthoringGroup(0, groups)).toBeFalse(); + expect(component.isFirstReviewerOnlyAuthoringGroup(1, groups)).toBeTrue(); + expect(component.isFirstReviewerOnlyAuthoringGroup(2, groups)).toBeFalse(); + }); + + it('should identify each separate pending reviewer-only authoring section', () => { + component.action = 'review'; + component.isPendingReview = true; + const groups = [reviewerGroup, learnerGroup, secondReviewerGroup]; + + expect(component.isFirstReviewerOnlyAuthoringGroup(0, groups)).toBeTrue(); + expect(component.isFirstReviewerOnlyAuthoringGroup(1, groups)).toBeFalse(); + expect(component.isFirstReviewerOnlyAuthoringGroup(2, groups)).toBeTrue(); + }); + + it('should show pending reviewer-only guidance when a paginated page starts within the section', () => { + component.action = 'review'; + component.isPendingReview = true; + + expect(component.isFirstReviewerOnlyAuthoringGroup(0, [secondReviewerGroup])).toBeTrue(); + }); + + it('should hide reviewer-only authoring guidance outside pending reviewer views', () => { + component.action = 'review'; + component.isPendingReview = false; + expect(component.isFirstReviewerOnlyAuthoringGroup(0, [reviewerGroup])).toBeFalse(); + + component.action = 'assessment'; + component.isPendingReview = true; + expect(component.isFirstReviewerOnlyAuthoringGroup(0, [reviewerGroup])).toBeFalse(); + }); + + it('should render one accessible guidance callout for consecutive reviewer-only groups', () => { + component.action = 'review'; + component.assessment = { + groups: [learnerGroup, reviewerGroup, secondReviewerGroup], + } as any; + component.submission = { + ...mockSubmission, + status: 'pending review', + answers: {}, + } as any; + component.review = { ...mockReview, status: 'in progress' } as any; + component.doAssessment = false; + component.isPendingReview = true; + + fixture.detectChanges(); + + const guidance = fixture.nativeElement.querySelectorAll('.reviewer-only-guidance'); + expect(guidance.length).toBe(1); + expect(guidance[0].textContent).toContain('Reviewer-only questions'); + expect(guidance[0].textContent).toContain('Your answers will be shared with the learner'); + const headingId = guidance[0].querySelector('h3').id; + expect(headingId).toBe('reviewer-only-guidance-heading-0-1'); + expect(guidance[0].getAttribute('aria-labelledby')).toBe(headingId); + }); + + it('should use the filtered and reordered groups when building pagination', () => { + component.pageSize = 10; + + const pages = component['splitGroupsByQuestionCount'](); + + expect(pages.length).toBe(1); + expect(pages[0].map(group => group.name)).toEqual([ + 'Learner Submission', + 'Client Criteria', + 'Internal Notes', + ]); + }); + }); + describe('CORE-8182: pagination indicator accuracy in review mode', () => { const reviewAssessment: Assessment = { id: 1, diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index d808bd1a63..6809a74736 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -34,6 +34,8 @@ interface Team360Section { kind: Team360SectionKind; } +type DisplayGroup = Pick & Partial; + /** * Assessment Component with optional pagination feature * @@ -178,7 +180,7 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { pageIndex = 0; // each entry is a page: an array of (partial) groups - pagesGroups: { name: string; description?: string; questions: Question[] }[][] = []; + pagesGroups: DisplayGroup[][] = []; // Feature toggle for pagination get isPaginationEnabled(): boolean { @@ -238,11 +240,96 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { get pagedGroups() { if (!this.isPaginationEnabled) { // Return all groups as a single page when pagination is disabled - return this.assessment?.groups || []; + return this.displayGroups; } return this.pagesGroups[this.pageIndex] || []; } + /** + * Groups visible to the current viewer. + * + * Reviewer-only groups are never part of the learner's answering or pending-review views. Once + * feedback is published, reviewer-only groups are appended after the learner groups so they can + * be presented as a dedicated feedback section. Reviewer views retain configured order. + */ + get displayGroups(): DisplayGroup[] { + const groups = this.assessment?.groups ?? []; + if (this.action !== 'assessment') { + return groups; + } + + const learnerGroups = groups.filter(group => !this.isReviewerOnlyGroup(group)); + if (this.submission?.status !== 'feedback available') { + return learnerGroups; + } + + const reviewerFeedbackGroups = groups.filter(group => this.isReviewerOnlyGroup(group)); + + return [...learnerGroups, ...reviewerFeedbackGroups]; + } + + isReviewerOnlyGroup(group: DisplayGroup): boolean { + return group?.questions?.length > 0 && group.questions.every(question => + question.reviewerOnly === true || + (question.audience?.length === 1 && question.audience.includes('reviewer')) + ); + } + + isReviewerFeedbackGroup(group: DisplayGroup): boolean { + return this.action === 'assessment' + && this.submission?.status === 'feedback available' + && this.isReviewerOnlyGroup(group); + } + + /** + * Whether answers in this group are already identified as reviewer-authored by their context. + * + * Learners reach this state after feedback is published. Reviewers reach it when reopening a + * completed review. Pending reviews remain outside this context so authoring controls are not + * affected, and shared groups retain their answer-ownership labels. + */ + isReviewerOnlyReadOnlyGroup(group: DisplayGroup): boolean { + if (!this.isReviewerOnlyGroup(group) || this.doAssessment || this.isPendingReview) { + return false; + } + + if (this.action === 'assessment') { + return this.submission?.status === 'feedback available'; + } + + return this.action === 'review' && this.review?.status === 'done'; + } + + isFirstReviewerFeedbackGroup(groupIndex: number, groups: DisplayGroup[]): boolean { + return this.isReviewerFeedbackGroup(groups[groupIndex]) + && (groupIndex === 0 || !this.isReviewerFeedbackGroup(groups[groupIndex - 1])); + } + + /** + * Show reviewer-only authoring guidance once per contiguous reviewer-only section. + * + * A reviewer-only group at the start of a paginated page is treated as the start of a section so + * the guidance remains available when the preceding group is rendered on another page. + */ + isFirstReviewerOnlyAuthoringGroup(groupIndex: number, groups: DisplayGroup[]): boolean { + return this.action === 'review' + && this.isPendingReview + && this.isReviewerOnlyGroup(groups[groupIndex]) + && (groupIndex === 0 || !this.isReviewerOnlyGroup(groups[groupIndex - 1])); + } + + shouldShowNoAnswer(question: Question): boolean { + if (this.doAssessment || question?.type === 'slider') { + return false; + } + + if (question?.reviewerOnly) { + return !this.isPendingReview && this.utils.isEmpty(this.review?.answers?.[question.id]?.answer); + } + + return this.utils.isEmpty(this.submission?.answers?.[question.id]?.answer); + } + prevPage() { if (!this.isPaginationEnabled) return; const accessiblePages = this.accessiblePageIndexes; @@ -708,7 +795,12 @@ Best regards`; private _handleReviewData() { if (this.isPendingReview && this.review?.status === 'in progress') { this.savingMessage$.next($localize`Last saved ${this.utils.timeFormatter(this.review.modified)}`); - this.btnDisabled$.next(false); + // An intermediate status-check fetch republishes the same in-progress review while the + // submit request is still running. Keep the action disabled until the parent submission + // workflow explicitly reports completion or failure. + if (!this.submitting) { + this.btnDisabled$.next(false); + } } } @@ -989,6 +1081,10 @@ Best regards`; return 'continue'; } + get showSubmitLoadingOnClick(): boolean { + return this._btnAction === 'submit'; + } + // the text of the button get btnText() { switch (this._btnAction) { @@ -1148,14 +1244,14 @@ Best regards`; */ private splitGroupsByQuestionCount() { if (this.isTeam360Assessment) { - return (this.assessment?.groups ?? []).map(group => [group]); + return this.displayGroups.map(group => [group]); } const pages = []; let currentPage = []; let count = 0; - for (const group of this.assessment.groups) { + for (const group of this.displayGroups) { const qCount = group.questions.length; if (count + qCount <= this.pageSize) { 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 d4e86a950b..3795bbd3b3 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 @@ -11,15 +11,25 @@
{{ text }} + > + + {{ text }} + { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [BottomActionBarComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); }); @@ -31,6 +33,8 @@ describe('BottomActionBarComponent', () => { expect(component.buttonType).toBe(''); expect(component.hasCustomContent).toBe(false); expect(component.disabled$).toBeUndefined(); + expect(component.showLoadingOnClick).toBe(false); + expect(component.loading).toBe(false); }); }); @@ -88,6 +92,63 @@ describe('BottomActionBarComponent', () => { expect(component.handleClick.emit).toHaveBeenCalledWith(clickEvent); }); + + it('should show loading immediately and prevent duplicate clicks when opted in', () => { + const disabled$ = new BehaviorSubject(false); + fixture.componentRef.setInput('disabled$', disabled$); + fixture.componentRef.setInput('showLoadingOnClick', true); + fixture.detectChanges(); + spyOn(component.handleClick, 'emit'); + + const clickEvent = new MouseEvent('click'); + component.onClick(clickEvent); + component.onClick(clickEvent); + fixture.detectChanges(); + + expect(component.loading).toBeTrue(); + expect(component.handleClick.emit).toHaveBeenCalledTimes(1); + expect(fixture.debugElement.query(By.css('ion-spinner.action-spinner'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.button-container.is-loading'))).toBeTruthy(); + + const actionButton = fixture.debugElement.query(By.css('ion-button.action-button')); + expect(actionButton.properties.disabled).toBeTrue(); + expect(actionButton.attributes['aria-busy']).toBe('true'); + }); + + it('should clear loading when disabled$ emits false after processing', () => { + const disabled$ = new BehaviorSubject(false); + fixture.componentRef.setInput('disabled$', disabled$); + fixture.componentRef.setInput('showLoadingOnClick', true); + fixture.detectChanges(); + + component.onClick(new MouseEvent('click')); + disabled$.next(true); + expect(component.loading).toBeTrue(); + + disabled$.next(false); + fixture.detectChanges(); + + expect(component.loading).toBeFalse(); + expect(fixture.debugElement.query(By.css('ion-spinner.action-spinner'))).toBeNull(); + }); + + it('should not enter loading or emit when already disabled', () => { + fixture.componentRef.setInput('disabled$', new BehaviorSubject(true)); + fixture.componentRef.setInput('showLoadingOnClick', true); + fixture.detectChanges(); + spyOn(component.handleClick, 'emit'); + + component.onClick(new MouseEvent('click')); + fixture.detectChanges(); + + expect(component.loading).toBeFalse(); + expect(component.handleClick.emit).not.toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ion-spinner.action-spinner'))).toBeNull(); + + const actionButton = fixture.debugElement.query(By.css('ion-button.action-button')); + expect(actionButton.properties.disabled).toBeTrue(); + expect(actionButton.attributes['aria-busy']).toBe('false'); + }); }); describe('onResubmit()', () => { 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 e1b8df616c..37c9750c6b 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 @@ -1,5 +1,5 @@ -import { Component, Input, Output, EventEmitter, OnChanges } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; +import { Component, Input, Output, EventEmitter, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { BehaviorSubject, Subscription } from 'rxjs'; @Component({ standalone: false, @@ -7,26 +7,51 @@ import { BehaviorSubject } from 'rxjs'; templateUrl: 'bottom-action-bar.component.html', styleUrls: ['./bottom-action-bar.component.scss'], }) -export class BottomActionBarComponent { +export class BottomActionBarComponent implements OnChanges, OnDestroy { @Input() showResubmit: boolean = false; @Input() text: string; @Input() color: string = 'primary'; @Input() disabled$?: BehaviorSubject; // assessment only + @Input() showLoadingOnClick: boolean = false; @Output() handleClick = new EventEmitter(); @Output() handleResubmit = new EventEmitter(); @Input() buttonType: string = ''; @Input() hasCustomContent: boolean = false; + loading = false; + + private disabledSubscription?: Subscription; + constructor() {} + ngOnChanges(changes: SimpleChanges): void { + if (!changes.disabled$) { + return; + } + + this.disabledSubscription?.unsubscribe(); + this.disabledSubscription = this.disabled$?.subscribe(disabled => { + if (disabled === false) { + this.loading = false; + } + }); + } + + ngOnDestroy(): void { + this.disabledSubscription?.unsubscribe(); + } + onClick(clickEvent: Event) { - // if disabled, do nothing - if (this.disabled$?.getValue() === true) { + // if disabled or already processing, do nothing + if (this.disabled$?.getValue() === true || this.loading) { return; } // make sure it's the click event that triggers "handleClick" if (clickEvent.type === 'click') { + if (this.showLoadingOnClick) { + this.loading = true; + } return this.handleClick.emit(clickEvent); } @@ -37,4 +62,3 @@ export class BottomActionBarComponent { return this.handleResubmit.emit(clickEvent); } } - diff --git a/projects/v3/src/app/components/file-upload/file-upload.component.html b/projects/v3/src/app/components/file-upload/file-upload.component.html index 6ca3a6fc39..f5edb34058 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.html +++ b/projects/v3/src/app/components/file-upload/file-upload.component.html @@ -1,9 +1,12 @@
- + -

Learner's Answer

+

+ Your Answer + Learner's Answer +

@@ -16,9 +19,9 @@
- + -

Reviewer's answer

+

Reviewer's Answer

No answer given to this optional question.

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 64873b627a..0298e4ec06 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 @@ -66,6 +66,8 @@ export class FileUploadComponent implements OnInit, OnDestroy { // assessment/review action flags @Input() doAssessment: boolean; @Input() doReview: boolean; + @Input() viewerRole: 'learner' | 'reviewer'; + @Input() isReviewerFeedbackContext = false; // FormControl that is passed in from parent component @Input() control: AbstractControl; 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 5bdd5c5967..e8ae8db4a0 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 @@ -1,21 +1,24 @@

{{question.name}}

- + - - +

- Learner's Answer + + Your Answer + Learner's Answer +

- +

Reviewer's Answer

+
diff --git a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.spec.ts b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.spec.ts index 5a1ee65d50..d213663899 100644 --- a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.spec.ts +++ b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.spec.ts @@ -41,6 +41,52 @@ describe('MultiTeamMemberSelectorComponent', () => { expect(component).toBeDefined(); }); + it('should show only reviewer-selected members in reviewer feedback', () => { + const member1 = JSON.stringify({ userId: 1 }); + const member2 = JSON.stringify({ userId: 2 }); + component.question = { + audience: ['reviewer'], + teamMembers: [ + { key: member1, userName: 'Member 1' }, + { key: member2, userName: 'Member 2' }, + ], + } as any; + component.review = { answer: [member2] }; + component.isReviewerFeedbackContext = true; + + expect(component.displayTeamMembers.map(member => member.key)).toEqual([member2]); + + component.isReviewerFeedbackContext = false; + expect(component.displayTeamMembers.length).toBe(2); + }); + + it('should render the learner ownership chip before the selected member', () => { + const member1 = JSON.stringify({ userId: 1 }); + const member2 = JSON.stringify({ userId: 2 }); + component.question = { + audience: ['submitter', 'reviewer'], + teamMembers: [ + { key: member1, userName: 'Member 1' }, + { key: member2, userName: 'Member 2' }, + ], + } as any; + component.submissionStatus = 'feedback available'; + component.reviewStatus = 'done'; + component.doAssessment = false; + component.doReview = false; + component.viewerRole = 'learner'; + component.submission = { answer: [member1] }; + component.review = { answer: [member2] }; + + fixture.detectChanges(); + + const learnerItem = fixture.nativeElement.querySelector('ion-list ion-item'); + const labelChildren = Array.from(learnerItem.querySelector('ion-label').children); + expect(labelChildren.indexOf(learnerItem.querySelector('p'))) + .toBeLessThan(labelChildren.indexOf(learnerItem.querySelector('.answer-content'))); + expect(learnerItem.textContent).toContain('Your Answer'); + }); + describe('ngOnInit()', () => { it('should call _showSavedAnswers()', () => { // use "any" to bypass ts restriction on type (not recommended, for acceptable for internal implementation) 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 5597b283dd..6fae9877a8 100644 --- a/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts +++ b/projects/v3/src/app/components/multi-team-member-selector/multi-team-member-selector.component.ts @@ -33,6 +33,8 @@ export class MultiTeamMemberSelectorComponent implements ControlValueAccessor, O @Input() doAssessment: Boolean; // this is for doing review or not @Input() doReview: Boolean; + @Input() viewerRole: 'learner' | 'reviewer'; + @Input() isReviewerFeedbackContext = false; // FormControl that is passed in from parent component @Input() control: AbstractControl<{answer: string[], comment: string}>; // answer field for submitter & reviewer @@ -208,6 +210,15 @@ 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); } + get displayTeamMembers(): Array { + const teamMembers = this.question?.teamMembers || []; + if (!this.isReviewerFeedbackContext) { + return teamMembers; + } + + return teamMembers.filter(teamMember => this.isSelectedInReview(teamMember)); + } + /** * checks if a team member is selected using the local working state (innerValue). * reads from innerValue.answer in review mode, or innerValue directly in assessment mode. diff --git a/projects/v3/src/app/components/multiple/multiple.component.html b/projects/v3/src/app/components/multiple/multiple.component.html index 0a72dc4579..f0e5aa69b6 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.html +++ b/projects/v3/src/app/components/multiple/multiple.component.html @@ -3,20 +3,25 @@

{ + [ngClass]="{'item-bottom-border': !(choice.explanation && choice.explanation.changingThisBreaksApplicationSecurity && isSubmissionChoiceSelected(choice.id))}"> -
- Learner's Answer - Reviewer's Answer + + + Your Answer + Learner's Answer + + + Reviewer's Answer + + +
- + diff --git a/projects/v3/src/app/components/multiple/multiple.component.scss b/projects/v3/src/app/components/multiple/multiple.component.scss index 46fc455458..914aca52ec 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.scss +++ b/projects/v3/src/app/components/multiple/multiple.component.scss @@ -64,4 +64,3 @@ ion-item { .feedback-title { --min-height: 1em; } - diff --git a/projects/v3/src/app/components/multiple/multiple.component.spec.ts b/projects/v3/src/app/components/multiple/multiple.component.spec.ts index a0685aa4eb..d9b8112973 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.spec.ts +++ b/projects/v3/src/app/components/multiple/multiple.component.spec.ts @@ -136,47 +136,104 @@ describe('MultipleComponent', () => { }); describe('when testing display-only preview mode', () => { - it('should expose only selected choices', () => { + beforeEach(() => { component.question = { choices: [ { id: 1, name: 'choice1' }, { id: 2, name: 'choice2' }, - { id: 3, name: 'choice3' } + { id: 3, name: 'choice3' }, + { id: 4, name: 'choice4' } ], - audience: [] + audience: ['submitter', 'reviewer'] }; component.submissionStatus = 'feedback available'; + component.reviewStatus = 'done'; component.doAssessment = false; component.doReview = false; - component.submission = { answer: [2] }; + component.viewerRole = 'reviewer'; + component.submission = { answer: [1, 4] }; + component.review = { answer: [2, 4] }; + }); + it('should render only the union of learner and reviewer selections', () => { fixture.detectChanges(); - expect(component.isDisplayOnly).toBeTrue(); - expect(component.displayChoices.map(choice => choice.id)).toEqual([2]); + const items = fixture.nativeElement.querySelectorAll('ion-list ion-item'); + expect(items.length).toBe(3); + expect(items[0].textContent).toContain('choice1'); + expect(items[0].textContent).toContain("Learner's Answer"); + expect(items[1].textContent).toContain('choice2'); + expect(items[1].textContent).toContain("Reviewer's Answer"); + expect(items[2].textContent).toContain('choice4'); + expect(items[2].textContent).toContain("Learner's Answer"); + expect(items[2].textContent).toContain("Reviewer's Answer"); + expect(fixture.nativeElement.textContent).not.toContain('choice3'); + expect(fixture.nativeElement.textContent).not.toContain('Not Selected'); }); - it('should render only selected choices in the template', () => { - component.question = { - choices: [ - { id: 1, name: 'choice1' }, - { id: 2, name: 'choice2' }, - { id: 3, name: 'choice3' } - ], - audience: [] - }; - component.submissionStatus = 'feedback available'; - component.doAssessment = false; - component.doReview = false; - component.submission = { answer: [2] }; + it('should use Your Answer for learner selections in learner view', () => { + component.viewerRole = 'learner'; + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Your Answer'); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + + const learnerItem = fixture.nativeElement.querySelector('ion-list ion-item'); + const labelChildren = Array.from(learnerItem.querySelector('ion-label').children); + expect(labelChildren.indexOf(learnerItem.querySelector('ion-chip'))) + .toBeLessThan(labelChildren.indexOf(learnerItem.querySelector('.answer-content'))); + }); + + it('should render a shared choice once with both ownership labels', () => { + component.submission = { answer: [4] }; + component.review = { answer: [4] }; fixture.detectChanges(); const items = fixture.nativeElement.querySelectorAll('ion-list ion-item'); expect(items.length).toBe(1); - expect(fixture.nativeElement.textContent).toContain('choice2'); + expect(items[0].textContent).toContain("Learner's Answer"); + expect(items[0].textContent).toContain("Reviewer's Answer"); + }); + + it('should show only reviewer selections without ownership labels in reviewer feedback', () => { + component.question.audience = ['reviewer']; + component.submission = {}; + component.isReviewerFeedbackContext = true; + component.viewerRole = 'learner'; + + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('ion-list ion-item'); + expect(items.length).toBe(2); + expect(items[0].textContent).toContain('choice2'); + expect(items[1].textContent).toContain('choice4'); expect(fixture.nativeElement.textContent).not.toContain('choice1'); expect(fixture.nativeElement.textContent).not.toContain('choice3'); + expect(fixture.nativeElement.querySelectorAll('ion-chip').length).toBe(0); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + }); + + it('should render no choices for an empty reviewer-only answer', () => { + component.question.audience = ['reviewer']; + component.submission = {}; + component.review = { answer: [] }; + component.isReviewerFeedbackContext = true; + + fixture.detectChanges(); + + expect(component.displayChoices).toEqual([]); + expect(fixture.nativeElement.querySelectorAll('ion-list ion-item').length).toBe(0); + }); + + it('should normalise stringified selected choice arrays', () => { + component.submission = { answer: '[1]' }; + component.review = { answer: { answer: '[2]' } }; + + fixture.detectChanges(); + + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2]); }); }); diff --git a/projects/v3/src/app/components/multiple/multiple.component.ts b/projects/v3/src/app/components/multiple/multiple.component.ts index 8cd087be04..90f4584c9b 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.ts +++ b/projects/v3/src/app/components/multiple/multiple.component.ts @@ -34,6 +34,9 @@ export class MultipleComponent implements AfterViewInit, ControlValueAccessor, O @Input() doAssessment: Boolean; // this is for doing review or not @Input() doReview: Boolean; + // role of the user viewing completed feedback + @Input() viewerRole: 'learner' | 'reviewer'; + @Input() isReviewerFeedbackContext = false; // FormControl that is passed in from parent component @Input() control: AbstractControl; // comment field for reviewer @@ -259,6 +262,20 @@ export class MultipleComponent implements AfterViewInit, ControlValueAccessor, O return (this.question?.choices || []).filter(choice => selectedChoiceIds.has(choice.id)); } + isReviewerChoiceSelected(choiceId: string | number): boolean { + return this._answerIncludesChoice(this.review?.answer, choiceId); + } + + isSubmissionChoiceSelected(choiceId: string | number): boolean { + return this._answerIncludesChoice(this.submission?.answer, choiceId); + } + + private _answerIncludesChoice(answer: any, choiceId: string | number): boolean { + const selectedChoiceIds = new Set(); + this._collectSelectedChoiceIds(answer, selectedChoiceIds); + return selectedChoiceIds.has(choiceId); + } + private _collectSelectedChoiceIds(answer: any, selectedChoiceIds: Set): void { if (answer === null || answer === undefined) { return; diff --git a/projects/v3/src/app/components/oneof/oneof.component.html b/projects/v3/src/app/components/oneof/oneof.component.html index 53ba85301a..22c4a27e1d 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.html +++ b/projects/v3/src/app/components/oneof/oneof.component.html @@ -6,13 +6,19 @@

{{question. -
- Learner's Answer - Reviewer's Answer + + + Your Answer + Learner's Answer + + Reviewer's Answer + +
diff --git a/projects/v3/src/app/components/oneof/oneof.component.spec.ts b/projects/v3/src/app/components/oneof/oneof.component.spec.ts index 4d63ed46eb..46af932b52 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.spec.ts +++ b/projects/v3/src/app/components/oneof/oneof.component.spec.ts @@ -42,6 +42,54 @@ describe('OneofComponent', () => { expect(component).toBeDefined(); }); + describe('read-only ownership context', () => { + beforeEach(() => { + component.question = { + id: 1, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' }, + ], + audience: ['submitter', 'reviewer'], + }; + component.submissionStatus = 'feedback available'; + component.reviewStatus = 'done'; + component.doAssessment = false; + component.doReview = false; + component.submission = { answer: 1 }; + component.review = { answer: 2 }; + }); + + it('should use ownership labels for a shared question', () => { + component.viewerRole = 'learner'; + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Your Answer'); + expect(fixture.nativeElement.textContent).toContain("Reviewer's Answer"); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + + const learnerItem = fixture.nativeElement.querySelector('ion-list ion-item'); + const labelChildren = Array.from(learnerItem.querySelector('ion-label').children); + expect(labelChildren.indexOf(learnerItem.querySelector('ion-chip'))) + .toBeLessThan(labelChildren.indexOf(learnerItem.querySelector('.answer-content'))); + }); + + it('should show only the selected value without a label in reviewer feedback', () => { + component.question.audience = ['reviewer']; + component.submission = {}; + component.isReviewerFeedbackContext = true; + + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('ion-list ion-item'); + expect(items.length).toBe(1); + expect(items[0].textContent).toContain('choice2'); + expect(fixture.nativeElement.querySelectorAll('ion-chip').length).toBe(0); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + }); + }); + describe('when testing onInit()', () => { it('should get correct data for in progress submission', () => { component.question = { diff --git a/projects/v3/src/app/components/oneof/oneof.component.ts b/projects/v3/src/app/components/oneof/oneof.component.ts index 132d577f99..45e03069fe 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.ts +++ b/projects/v3/src/app/components/oneof/oneof.component.ts @@ -32,6 +32,8 @@ export class OneofComponent implements AfterViewInit, ControlValueAccessor, OnIn @Input() doAssessment: Boolean; // this is for doing review or not @Input() doReview: Boolean; + @Input() viewerRole: 'learner' | 'reviewer'; + @Input() isReviewerFeedbackContext = false; // FormControl that is passed in from parent component @Input() control: AbstractControl; // answer field for submitter & reviewer diff --git a/projects/v3/src/app/components/slider/slider.component.html b/projects/v3/src/app/components/slider/slider.component.html index 87cf64444b..9c0438b06a 100644 --- a/projects/v3/src/app/components/slider/slider.component.html +++ b/projects/v3/src/app/components/slider/slider.component.html @@ -2,8 +2,8 @@

{{question
- -
+ +
{{question [ticks]="true" [pin]="true" [pinFormatter]="pinFormatter" - [value]="getSubmissionSliderValue()" + [value]="getDisplaySliderValue()" disabled [attr.aria-labelledby]="'slider-question-' + question.id" color="medium" @@ -21,20 +21,26 @@

{{question
-
+
- - + +
-

No answer provided

-

The learner has not submitted an answer for this question.

+ +

No reviewer answer provided

+

The reviewer has not submitted an answer for this question.

+
+ +

No answer provided

+

The learner has not submitted an answer for this question.

+
@@ -43,20 +49,19 @@

{{question
-
- - - Learner's Answer: {{ getChoiceNameById(submission.answer) }} +
+ + Your Answer: {{ getChoiceNameById(submission.answer) }} + Learner's Answer: {{ getChoiceNameById(submission.answer) }} - - + Reviewer's Answer: {{ getChoiceNameById(review.answer) }}
-
+
No answers available yet diff --git a/projects/v3/src/app/components/slider/slider.component.scss b/projects/v3/src/app/components/slider/slider.component.scss index fdd5eb9d91..9c365b041f 100644 --- a/projects/v3/src/app/components/slider/slider.component.scss +++ b/projects/v3/src/app/components/slider/slider.component.scss @@ -226,7 +226,6 @@ ion-item { .label { align-self: flex-start; - font-size: 0.9rem; &.orange { --background: var(--ion-color-warning-tint); diff --git a/projects/v3/src/app/components/slider/slider.component.spec.ts b/projects/v3/src/app/components/slider/slider.component.spec.ts index b6b79a00f8..1ffa504658 100644 --- a/projects/v3/src/app/components/slider/slider.component.spec.ts +++ b/projects/v3/src/app/components/slider/slider.component.spec.ts @@ -341,6 +341,69 @@ describe('SliderComponent', () => { }); }); + describe('reviewer-only display', () => { + beforeEach(() => { + component.question = { + ...component.question, + audience: ['reviewer'], + reviewerOnly: true, + }; + component.doAssessment = false; + component.doReview = false; + component.submissionStatus = 'feedback available'; + component.submission = {}; + component.viewerRole = 'learner'; + component.isReviewerFeedbackContext = true; + }); + + it('should use the reviewer answer as the displayed slider value', () => { + component.review = { answer: 3 }; + + fixture.detectChanges(); + + expect(component.hasDisplaySliderAnswer()).toBeTrue(); + expect(component.getDisplaySliderValue()).toBe(3); + expect(fixture.nativeElement.querySelector('ion-range.display-only-slider')).toBeTruthy(); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + expect(fixture.nativeElement.querySelectorAll('ion-chip').length).toBe(0); + expect(fixture.nativeElement.textContent).not.toContain('The learner has not submitted an answer'); + }); + + it('should use reviewer-specific wording when no review answer exists', () => { + component.review = { answer: null }; + + fixture.detectChanges(); + + expect(component.hasDisplaySliderAnswer()).toBeFalse(); + expect(fixture.nativeElement.textContent).toContain('No reviewer answer provided'); + expect(fixture.nativeElement.textContent).toContain('The reviewer has not submitted an answer'); + expect(fixture.nativeElement.textContent).not.toContain('The learner has not submitted an answer'); + expect(fixture.nativeElement.textContent).not.toContain('No answers available yet'); + }); + }); + + it('should use ownership labels for a shared slider in learner view', () => { + component.question = { + ...component.question, + audience: ['submitter', 'reviewer'], + reviewerOnly: false, + }; + component.doAssessment = false; + component.doReview = false; + component.submissionStatus = 'feedback available'; + component.reviewStatus = 'done'; + component.viewerRole = 'learner'; + component.submission = { answer: 2 }; + component.review = { answer: 4 }; + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Your Answer: 2'); + expect(fixture.nativeElement.textContent).toContain("Reviewer's Answer: 4"); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + expect(fixture.nativeElement.querySelectorAll('.answer-indicators ion-icon').length).toBe(0); + }); + describe('onLabelClick guard', () => { it('should not call onChange when control is disabled', () => { component.ngOnInit(); diff --git a/projects/v3/src/app/components/slider/slider.component.ts b/projects/v3/src/app/components/slider/slider.component.ts index 80298a2d28..cac59e277d 100644 --- a/projects/v3/src/app/components/slider/slider.component.ts +++ b/projects/v3/src/app/components/slider/slider.component.ts @@ -33,6 +33,8 @@ export class SliderComponent implements AfterViewInit, ControlValueAccessor, OnI @Input() doAssessment: Boolean; // this is for doing review or not @Input() doReview: Boolean; + @Input() viewerRole: 'learner' | 'reviewer'; + @Input() isReviewerFeedbackContext = false; // FormControl that is passed in from parent component @Input() control: AbstractControl; @@ -267,6 +269,16 @@ export class SliderComponent implements AfterViewInit, ControlValueAccessor, OnI return typeof this.submission.answer === 'number' ? this.submission.answer : this.sliderMin; } + hasDisplaySliderAnswer(): boolean { + return this.question?.reviewerOnly ? this.hasReviewAnswer() : this.hasSubmissionAnswer(); + } + + getDisplaySliderValue(): number { + const answer = this.question?.reviewerOnly ? this.review?.answer : this.submission?.answer; + + return typeof answer === 'number' ? answer : this.sliderMin; + } + // Get slider value for review (Reviewer's answer) getReviewSliderValue(): number { if (!this.innerValue?.answer) return this.sliderMin; diff --git a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html index 993d120af9..14a73be65d 100644 --- a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html +++ b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.html @@ -3,14 +3,17 @@

- + - -

- Learner's Answer +

+ + Your Answer + Learner's Answer + Reviewer's Answer

+
diff --git a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.spec.ts b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.spec.ts index aa8a2d3a4e..cdcafbc4ae 100644 --- a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.spec.ts +++ b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.spec.ts @@ -34,6 +34,47 @@ describe('TeamMemberSelectorComponent', () => { expect(component).toBeDefined(); }); + it('should show only the reviewer-selected member in reviewer feedback', () => { + component.question = { + teamMembers: [ + { key: 'member-1', userName: 'Member 1' }, + { key: 'member-2', userName: 'Member 2' }, + ], + }; + component.review = { answer: 'member-2' }; + component.isReviewerFeedbackContext = true; + + expect(component.displayTeamMembers.map(member => member.key)).toEqual(['member-2']); + + component.isReviewerFeedbackContext = false; + expect(component.displayTeamMembers.length).toBe(2); + }); + + it('should render the learner ownership chip before the selected member', () => { + component.question = { + teamMembers: [ + { key: 'member-1', userName: 'Member 1' }, + { key: 'member-2', userName: 'Member 2' }, + ], + audience: ['submitter', 'reviewer'], + }; + component.submissionStatus = 'feedback available'; + component.reviewStatus = 'done'; + component.doAssessment = false; + component.doReview = false; + component.viewerRole = 'learner'; + component.submission = { answer: 'member-1' }; + component.review = { answer: 'member-2' }; + + fixture.detectChanges(); + + const learnerItem = fixture.nativeElement.querySelector('ion-list ion-item'); + const labelChildren = Array.from(learnerItem.querySelector('ion-label').children); + expect(labelChildren.indexOf(learnerItem.querySelector('p'))) + .toBeLessThan(labelChildren.indexOf(learnerItem.querySelector('.answer-content'))); + expect(learnerItem.textContent).toContain('Your Answer'); + }); + describe('when testing onInit()', () => { it('should get correct data for in progress submission', () => { component.question = { diff --git a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts index 1f27b232f6..1334f95d8f 100644 --- a/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts +++ b/projects/v3/src/app/components/team-member-selector/team-member-selector.component.ts @@ -31,6 +31,8 @@ export class TeamMemberSelectorComponent implements ControlValueAccessor, OnInit @Input() doAssessment: Boolean; // this is for doing review or not @Input() doReview: Boolean; + @Input() viewerRole: 'learner' | 'reviewer'; + @Input() isReviewerFeedbackContext = false; // FormControl that is passed in from parent component @Input() control: AbstractControl; // answer field for submitter & reviewer @@ -180,6 +182,15 @@ export class TeamMemberSelectorComponent implements ControlValueAccessor, OnInit return !this.doAssessment && !this.doReview && (this.submissionStatus === 'feedback available' || this.submissionStatus === 'pending review' || (this.submissionStatus === 'done' && this.reviewStatus === '')) && (this.submission?.answer || this.review?.answer); } + get displayTeamMembers(): Array { + const teamMembers = this.question?.teamMembers || []; + if (!this.isReviewerFeedbackContext) { + return teamMembers; + } + + return teamMembers.filter(teamMember => teamMember.key === this.review?.answer); + } + // innerHTML text toggle - submission onLabelToggle = (id: string): void => { this.onChange(id); diff --git a/projects/v3/src/app/components/text/text.component.html b/projects/v3/src/app/components/text/text.component.html index b33fb38eeb..8cb0b64b76 100644 --- a/projects/v3/src/app/components/text/text.component.html +++ b/projects/v3/src/app/components/text/text.component.html @@ -2,7 +2,12 @@

{{question.n
-

Learner's Answer

+

+ + Your Answer + Learner's Answer + +

@@ -10,7 +15,7 @@

{{question.n -

Reviewer's Answer

+

Reviewer's Answer

diff --git a/projects/v3/src/app/components/text/text.component.spec.ts b/projects/v3/src/app/components/text/text.component.spec.ts index 6700e149d4..79f7215e28 100644 --- a/projects/v3/src/app/components/text/text.component.spec.ts +++ b/projects/v3/src/app/components/text/text.component.spec.ts @@ -49,6 +49,50 @@ describe('TextComponent', () => { expect(component).toBeDefined(); }); + describe('read-only ownership context', () => { + beforeEach(() => { + component.question = { + id: 1, + name: 'Question', + type: 'text', + description: '', + isRequired: false, + canAnswer: true, + canComment: false, + audience: ['submitter', 'reviewer'], + } as any; + component.submissionStatus = 'feedback available'; + component.reviewStatus = 'done'; + component.doAssessment = false; + component.doReview = false; + component.submission = { answer: 'learner response' }; + component.review = { answer: 'reviewer response' }; + }); + + it('should use ownership labels for shared feedback', () => { + component.viewerRole = 'learner'; + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Your Answer'); + expect(fixture.nativeElement.textContent).toContain("Reviewer's Answer"); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + }); + + it('should show reviewer-only text without an ownership label', () => { + component.question.audience = ['reviewer']; + component.question.reviewerOnly = true; + component.submission = {}; + component.isReviewerFeedbackContext = true; + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('reviewer response'); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + expect(fixture.nativeElement.querySelectorAll('ion-chip').length).toBe(0); + }); + }); + describe('when testing onInit()', () => { const dummyQuestion = { id: 1, @@ -576,4 +620,3 @@ describe('TextComponent', () => { }); }); - diff --git a/projects/v3/src/app/components/text/text.component.ts b/projects/v3/src/app/components/text/text.component.ts index b3c7cac3c9..069f8cffe7 100644 --- a/projects/v3/src/app/components/text/text.component.ts +++ b/projects/v3/src/app/components/text/text.component.ts @@ -31,6 +31,8 @@ export class TextComponent implements ControlValueAccessor, OnInit, AfterViewIni @Input() submissionStatus; @Input() doAssessment: Boolean; @Input() doReview: Boolean; + @Input() viewerRole: 'learner' | 'reviewer'; + @Input() isReviewerFeedbackContext = false; @Input() control: AbstractControl; // answer field for submitter & reviewer