Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/assessment-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,25 @@ All follow similar patterns with dual-purpose display for learner/reviewer conte
```html
<ion-button class="action-button"
mode="ios"
[disabled]="disabled$ | async"
[disabled]="loading || (disabled$ | async)"
[color]="color"
(click)="onClick($event)"
>{{ text }}</ion-button>
[attr.aria-busy]="loading ? 'true' : 'false'">
<ion-spinner *ngIf="loading" name="crescent"></ion-spinner>
<span>{{ text }}</span>
</ion-button>
```

**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)
Expand Down
1 change: 1 addition & 0 deletions docs/features/slider-rating-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
}
Expand Down
70 changes: 52 additions & 18 deletions docs/fixes/readonly-preview-multiple-selected-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- 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.
66 changes: 49 additions & 17 deletions projects/v3/src/app/components/assessment/assessment.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,26 @@
i18n-aria-label
novalidate>
<ng-container *ngFor="let group of pagedGroups; let groupIndex = index">
<div class="reviewer-feedback-section"
*ngIf="isFirstReviewerFeedbackGroup(groupIndex, pagedGroups)"
role="region"
aria-labelledby="reviewer-feedback-heading">
<h3 id="reviewer-feedback-heading"
i18n>Reviewer Feedback</h3>
<p class="body-2 gray-2"
i18n>These criteria were completed by your reviewer.</p>
</div>

<div class="reviewer-only-guidance"
*ngIf="isFirstReviewerOnlyAuthoringGroup(groupIndex, pagedGroups)"
role="note"
[attr.aria-labelledby]="'reviewer-only-guidance-heading-' + pageIndex + '-' + groupIndex">
<h3 [id]="'reviewer-only-guidance-heading-' + pageIndex + '-' + groupIndex"
i18n>Reviewer-only questions</h3>
<p class="body-2 gray-2"
i18n>Complete these questions as part of your review. Your answers will be shared with the learner after you submit the review.</p>
</div>

<div [ngClass]="{'ion-padding': isSinglePage, 'ion-padding-top': !isSinglePage}"
role="group"
[attr.aria-labelledby]="'group-heading-' + groupIndex">
Expand Down Expand Up @@ -271,23 +291,18 @@
i18n-aria-label></ion-icon>
</div>

<ng-container *ngIf="question.type !== 'slider'">
<ion-item *ngIf="!doAssessment && (
(!question.reviewerOnly && utils.isEmpty(submission?.answers[question.id]?.answer)) ||
(question.reviewerOnly && !review?.answers[question.id] && !isPendingReview)
)"
class="no-answers item-bottom-border"
lines="none"
role="note">
<ion-icon slot="start"
color="orange"
name="information-circle-outline"
class="ion-margin-end"
aria-hidden="true"></ion-icon>
<ion-label size="small"
i18n>No answer for this question.</ion-label>
</ion-item>
</ng-container>
<ion-item *ngIf="shouldShowNoAnswer(question)"
class="no-answers item-bottom-border"
lines="none"
role="note">
<ion-icon slot="start"
color="orange"
name="information-circle-outline"
class="ion-margin-end"
aria-hidden="true"></ion-icon>
<ion-label size="small"
i18n>No answer for this question.</ion-label>
</ion-item>

<app-text *ngSwitchCase="'text'"
#questionField
Expand All @@ -300,6 +315,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"></app-text>
Expand All @@ -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"></app-slider>
Expand All @@ -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"></app-oneof>
Expand All @@ -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"></app-multiple>
Expand All @@ -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"></app-file-upload>
Expand All @@ -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"></app-file-upload>

Expand All @@ -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"></app-team-member-selector>
Expand All @@ -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"></app-multi-team-member-selector>
Expand Down Expand Up @@ -443,6 +474,7 @@
(handleResubmit)="resubmit()"
[text]="btnText"
[disabled$]="btnDisabled$"
[showLoadingOnClick]="showSubmitLoadingOnClick"
(handleClick)="continueToNextTask()"
[hasCustomContent]="isPaginationEnabled && pageCount > 1">

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading