Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
41f5dc3
[CORE-8270] primary color for all
trtshen Jul 9, 2026
413ec1f
Merge branch 'prerelease' into 2.4.y/CORE-8270/branding-brief-style
trtshen Jul 9, 2026
08a4dc5
[CORE-8277] reviewer-only improvement
trtshen Jul 16, 2026
558c3b7
[CORE-8277] reivewer-only question improvement
trtshen Jul 17, 2026
de794eb
[CORE-8277] Enhance reviewer-only question feedback display
trtshen Jul 21, 2026
5c08cb7
[CORE-8282] improved premature button re-enable for review submission
trtshen Jul 21, 2026
51b3c49
[CORE-8277] reivewer-only question improvement
trtshen Jul 17, 2026
e1fad5a
[CORE-8277] Enhance reviewer-only question feedback display
trtshen Jul 21, 2026
b59cce4
[CORE-8277] reapplid new checkbox readonly for reviewer-view
trtshen Jul 27, 2026
703ca80
Merge remote-tracking branch 'origin/2.4.y/CORE-8277/checkbox-reviewe…
trtshen Jul 27, 2026
7539a04
Merge pull request #2725 from intersective/2.4.y/CORE-8277/reviewer-o…
trtshen Jul 27, 2026
eb375a4
Merge pull request #2700 from intersective/2.4.y/CORE-8270/branding-b…
trtshen Jul 28, 2026
536283b
Merge pull request #2713 from intersective/2.4.y/CORE-8277/checkbox-q…
trtshen Jul 28, 2026
717f131
Merge remote-tracking branch 'origin/prerelease' into 2.4.y/CORE-8277…
trtshen Jul 28, 2026
1ac119c
[CORE-8309] 2.4.y/upload-profile-pic
trtshen Aug 6, 2026
f31e441
[CORE-6152] 2.4.y/accessWidget-blockage
trtshen Aug 7, 2026
f6c4c7a
[CORE-6152] brought back
trtshen Aug 7, 2026
7338707
Merge pull request #2738 from intersective/2.4.y/CORE-6152/accessWidg…
trtshen Aug 7, 2026
261e5f2
Merge pull request #2735 from intersective/2.4.y/CORE-8309/upload-pro…
trtshen Aug 7, 2026
eaa3053
Merge pull request #2718 from intersective/2.4.y/CORE-8277/checkbox-r…
trtshen Aug 7, 2026
4d07f45
[core-6152] mobiley offset
trtshen Aug 7, 2026
3b94daf
Merge pull request #2741 from intersective/2.4.y/CORE-6152/accessWidg…
trtshen Aug 7, 2026
0030e6d
[CORE-8316] signed url and preview url are different from uppy uploader
trtshen Aug 12, 2026
3c4f098
[CORE-8136] preview with proper url
trtshen Aug 12, 2026
217337e
Merge pull request #2746 from intersective/2.4.y/CORE-8316/attachment…
trtshen Aug 12, 2026
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
9 changes: 7 additions & 2 deletions docs/accessibility/WCAG_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ This checklist verifies compliance with WCAG 2.2 Level AA standards for the V3 I
- [x] **COMPLETED**: Added CSS to prevent focus obscuring (scroll-margin: 4px on focus-visible elements)
- [x] Sticky headers/footers have proper z-index (ion-header and ion-footer set to z-index: 1000)
- [x] Modals/overlays configured with backdrop opacity (ion-modal has --backdrop-opacity: 0.4)
- [x] **COMPLETED**: Reserved production-only inline space in the chat action row so accessWidget's floating trigger cannot obscure Attach or Send
- [ ] **RETEST ON DEPLOYED HOST**: Verify accessWidget remains available and the chat controls remain unobscured at desktop, tablet, and mobile viewports

#### 2.4.13 Focus Appearance (Minimum) (Level AA) - NEW in 2.2
- [x] Focus indicators have at least 2px outline (implemented in global.scss)
Expand Down Expand Up @@ -394,7 +396,7 @@ This checklist verifies compliance with WCAG 2.2 Level AA standards for the V3 I
7. Test with keyboard: Tab to element, tooltip should appear; ESC should dismiss it

#### 5. Focus Not Obscured (WCAG 2.4.11)
**Fixed in:** `global.scss`
**Fixed in:** `global.scss`, `styles.scss`, and `index.html`

**Retest Instructions:**
1. Navigate to any page with sticky header/footer
Expand All @@ -404,6 +406,10 @@ This checklist verifies compliance with WCAG 2.2 Level AA standards for the V3 I
5. Open DevTools and check computed styles on focused element:
- `scroll-margin: 4px` should be present
- `z-index` on headers/footers should be 1000
6. On a deployed non-local host, verify accessWidget's floating trigger remains visible and opens the widget
7. Navigate to Messages and select a writable chat room
8. **VERIFY**: Attach and Send remain visible and clickable at 2048x1048, 1366x768, 1024x768, and 390x844
9. **VERIFY**: The floating trigger does not obscure the chat action row

#### 6. Text Spacing Support (WCAG 1.4.12)
**Fixed in:** `global.scss`
Expand Down Expand Up @@ -494,4 +500,3 @@ This checklist verifies compliance with WCAG 2.2 Level AA standards for the V3 I
- Assessment component has loading states with proper ARIA
- Error messages use role="alert" and aria-live="assertive"
- Status messages use role="status" and aria-live="polite"

19 changes: 15 additions & 4 deletions docs/assessment-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
status: stable
authority: canonical
scope: frontend
last_reviewed: 2026-07-13
last_reviewed: 2026-07-16
supersedes: none
---

Expand Down Expand Up @@ -135,6 +135,8 @@ questionsForm: FormGroup = new FormGroup({});

3. **Feedback Available**: Read-only with feedback
- Display learner answers and reviewer feedback
- For reviewer-only `multiple` and `oneof` questions, display every configured choice with a green check and **Selected** status or a subdued **Not selected** status
- Reviewer-only choice feedback is derived only from the review answer and does not display learner-answer labels
- "Mark as Read" button to acknowledge feedback
- Navigation to next task after reading

Expand All @@ -147,6 +149,7 @@ questionsForm: FormGroup = new FormGroup({});

2. **Review Complete**: Read-only mode
- Show completed review
- For reviewer-only `multiple` and `oneof` questions, show every configured choice using the same **Selected** and **Not selected** statuses as the learner's published-feedback view
- No further editing allowed

#### Form Population Logic
Expand Down Expand Up @@ -300,17 +303,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
4 changes: 3 additions & 1 deletion docs/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ This is practera documentation with more informations.
- [Slider Rating Implementation](./features/slider-rating-implementation.md)

### Fixes
- [CORE-8316 Immediate Message Attachment Preview](./fixes/CORE-8316-immediate-attachment-preview.md)
- [CORE-7942 Whitespace Fix](./fixes/CORE-7942-whitespace-fix.md)
- [CORE-8002 Pulse Check Workflow](./fixes/CORE-8002-pulsecheck-workflow.md)
- [CORE-8166/8167 Pagination Answer Persistence](./fixes/CORE-8166-8167-pagination-answer-persistence.md)
- [CORE-8166/8167 Pagination Answer Persistence](./fixes/CORE-8166-8167-pagination-answer-persistence.md)
- [Profile Picture Upload](./fixes/profile-picture-upload.md)
12 changes: 9 additions & 3 deletions docs/features/project-brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ showProjectBrief() → opens ProjectBriefModalComponent
- `project-brief-modal.component.scss` - Component-specific styles
- `project-brief-modal.component.spec.ts` - Unit tests

**Input:**
**Modal property:**
```typescript
@Input() projectBrief: ProjectBrief = {};
projectBrief: ProjectBrief = {};
```
Ionic sets this public property through `componentProps` when the modal is created.

**Interface:**
```typescript
Expand All @@ -62,6 +63,10 @@ interface ProjectBrief {
- Professional Skills (as chips)
- Deliverables

**Color treatment:**
- Modal section header icons and chips use the primary brand color.
- Do not use the secondary brand color for Technical Skills accents; customer secondary colors can be too light to remain visible on the light modal background.

**Empty Field Handling:**
- All sections show "None specified" when the field is empty or undefined
- Uses `hasValue()` for string fields and `hasItems()` for array fields
Expand Down Expand Up @@ -192,7 +197,7 @@ Button placement - next to experience name:
- Keyboard navigation with `(keydown.enter)` and `(keydown.space)` handlers
- Modal has proper semantic structure with `<main>`, `<section>`, and heading hierarchy
- Close button includes `aria-label="Close project brief"`
- Ion-chips for industry/skills are visually distinct with color coding
- Ion-chips for industry/skills use the primary brand color so labels and outlines remain visible when secondary branding is faint

## Sample Data

Expand Down Expand Up @@ -230,6 +235,7 @@ After parsing:
- Template renders title when provided
- Template shows "None specified" for empty fields
- Template renders chips for industry and skills
- Template keeps section accents on the primary brand color

**HomePage tests (additions needed):**
- Button visible when `projectBrief` is set
Expand Down
66 changes: 66 additions & 0 deletions docs/fixes/CORE-8316-immediate-attachment-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
status: stable
authority: reference
scope: v3
last_reviewed: 2026-08-12
supersedes: none
---

# CORE-8316: Immediate Message Attachment Preview

## Failure

When a user sent an attachment, another user in the same chat received the new
message immediately through Pusher but could not render or preview the file
until reloading the page.

The uploader provides the canonical CDN path immediately after upload. The
`createChatLog` mutation then returns the stored file with an authorized URL.
The sender rendered that API response, but the Pusher event used the original
uploader object instead. Recipients therefore received the canonical path
without the authorization query parameters required to load the file.

Reloading appeared to fix the problem because `getMessageList()` fetched the
message through GraphQL, which returned an authorized file URL.

## Required real-time contract

After `createChatLog` succeeds, all local rendering and real-time delivery must
use the mutation response as the source of truth:

- `response.file` is added to the sender's message list.
- `response.file` is included in the `client-chat-new-message` event.
- The pre-mutation uploader object is used only as the mutation input.

The Pusher attachment payload is either `null` or an object containing `name`,
`type`, and the API-returned `url`.

## Pre-send preview contract

The composer must display a newly uploaded image before the message is sent.
At this point the canonical CDN URL may not be immediately readable, so the
selected attachment uses this fallback order only for its temporary preview:

1. `directUrl`
2. canonical `url`
3. TUS `uploadUrl`

The message mutation continues to receive the canonical `url`; the direct URL
must not replace the value persisted with the chat message.

Both the inline composer thumbnail and the preview modal render the temporary
`preview` URL. The modal download action continues to use the canonical `url`.

## Verification

Automated coverage sends an attachment with an unsigned uploader URL while the
mocked message API returns a signed URL. It verifies that the Pusher event uses
the API-returned file and never the uploader object. Separate coverage verifies
that a selected attachment previews `directUrl` while retaining the canonical
URL for the outgoing message. The preview modal coverage also verifies that its
rendered media uses the immediate preview URL and falls back to the canonical
URL for attachments that have already been sent.

For staging verification, keep two users in the same direct-message channel,
send an image from one browser, and open it immediately in the receiving
browser without reloading. Both the inline image and preview modal must render.
56 changes: 56 additions & 0 deletions docs/fixes/profile-picture-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
status: stable
authority: reference
scope: v3
last_reviewed: 2026-08-06
supersedes: none
---

# Profile Picture Upload

## Failure

The settings page uploaded the image successfully but could send an invalid `FileInput` to the `updateUserProfile` mutation. The upload modal read the TUS response as if it contained `url`, while the upload service returns `cdnUrl`. It therefore dismissed the modal with an undefined `url`, and the settings page replaced that value with the resumable TUS `uploadUrl`. That URL is an upload-session location, not the public image URL expected by the profile API.

The settings page also ignored the mutation result. A response such as `{ success: false, message: "avatar file object incorrect" }` still updated local state and displayed the success alert.

`AuthService.updateUserProfile()` also passed the mutation document to `graphQLFetch()`, which executes `Apollo.query()`. Apollo rejected the request locally with `Running a query requires a graphql query, but a mutation was used instead`, before it could reach the profile resolver. Profile updates must use `graphQLMutate()` with `{ avatar }` as the variables object.

## Upload response contract

The final TUS `PATCH` response must have a JSON body containing non-empty values for:

```json
{
"bucket": "profile-images",
"path": "/users/profile.png",
"cdnUrl": "https://cdn.example.com/users/profile.png",
"directUrl": "https://files.example.com/users/profile.png"
}
```

`UppyUploaderService.parseTusUploadResponse()` is the shared validator used by the modal uploader and assessment file uploader. Empty, malformed, or incomplete response bodies stop the upload flow with a specific user-visible error.

The modal normalizes the response to `UppyFileData` and preserves both `cdnUrl` and `directUrl`. Assessment answers persist the canonical CDN URL but prefer `directUrl || url` for immediate display. Profile avatars follow the same display preference and persist `directUrl` when it is available because the `user-profile` CDN URL may not be directly readable; they fall back to the canonical `url`. Consumers must never use `file.tus.uploadUrl` as stored file metadata.

## Profile update behavior

`SettingsPage.profileImage()` sends the normalized file fields to `AuthService.updateUserProfile()`:

- `bucket`
- `path`
- `name`
- `url` (`directUrl` when available, otherwise the CDN URL)
- `extension`
- `type`
- `size`

The page updates its avatar and browser storage only when `data.updateUserProfile.success` is exactly `true`. A missing result or `success: false` displays the returned message and leaves the previous avatar unchanged. The upload spinner is cleared for success, cancellation, and error paths.

The `user-profile` upload source is image-only.

## Verification and rollout

Automated coverage verifies TUS response validation, assessment uploader integration, image-only profile restrictions, direct-URL preference, successful profile payloads, and rejected mutations.

After deployment, verify one successful PNG/JPEG upload and one rejected/invalid upload in staging. Monitor upload endpoint errors and `updateUserProfile` failures separately. Logs should include the request/correlation identifier, upload source, HTTP status, and a stable error category such as `empty_upload_response`, `invalid_upload_metadata`, or `profile_update_rejected`; they must not include file bytes, API keys, or full signed URLs.
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,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 @@ -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');
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,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);
}
}
}

Expand Down Expand Up @@ -989,6 +994,10 @@ Best regards`;
return 'continue';
}

get showSubmitLoadingOnClick(): boolean {
return this._btnAction === 'submit';
}

// the text of the button
get btnText() {
switch (this._btnAction) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,25 @@
</ng-container>

<div class="button-container"
[class.is-loading]="loading"
[ngClass]="{'with-custom-content': hasCustomContent}">
<ion-button class="action-button"
mode="ios"
[disabled]="disabled$ | async"
[disabled]="loading || (disabled$ | async)"
[color]="color"
(click)="onClick($event)"
[attr.aria-busy]="(disabled$ | async) ? 'true' : 'false'"
[attr.aria-busy]="loading ? 'true' : 'false'"
type="button"
>{{ text }}</ion-button>
>
<ion-spinner *ngIf="loading"
class="action-spinner"
name="crescent"
role="status"
aria-live="polite"
aria-label="Submitting"
i18n-aria-label></ion-spinner>
<span>{{ text }}</span>
</ion-button>

<ion-button class="action-button"
*ngIf="showResubmit === true"
Expand Down
Loading
Loading