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 @@