diff --git a/docs/accessibility/WCAG_CHECKLIST.md b/docs/accessibility/WCAG_CHECKLIST.md index 43f84ac1ea..3458bfa9b7 100644 --- a/docs/accessibility/WCAG_CHECKLIST.md +++ b/docs/accessibility/WCAG_CHECKLIST.md @@ -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) @@ -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 @@ -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` @@ -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" - diff --git a/docs/assessment-flow.md b/docs/assessment-flow.md index 4158a91111..d0cd7c734a 100644 --- a/docs/assessment-flow.md +++ b/docs/assessment-flow.md @@ -2,7 +2,7 @@ status: stable authority: canonical scope: frontend -last_reviewed: 2026-07-13 +last_reviewed: 2026-07-16 supersedes: none --- @@ -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 @@ -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 @@ -300,17 +303,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/docs.md b/docs/docs.md index 3e72cd1b3a..5cace35657 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -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) \ No newline at end of file +- [CORE-8166/8167 Pagination Answer Persistence](./fixes/CORE-8166-8167-pagination-answer-persistence.md) +- [Profile Picture Upload](./fixes/profile-picture-upload.md) diff --git a/docs/features/project-brief.md b/docs/features/project-brief.md index 5da253ca93..a19e368fec 100644 --- a/docs/features/project-brief.md +++ b/docs/features/project-brief.md @@ -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 @@ -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 @@ -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 `
`, `
`, 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 @@ -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 diff --git a/docs/fixes/CORE-8316-immediate-attachment-preview.md b/docs/fixes/CORE-8316-immediate-attachment-preview.md new file mode 100644 index 0000000000..ace61d584c --- /dev/null +++ b/docs/fixes/CORE-8316-immediate-attachment-preview.md @@ -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. diff --git a/docs/fixes/profile-picture-upload.md b/docs/fixes/profile-picture-upload.md new file mode 100644 index 0000000000..7a12657e89 --- /dev/null +++ b/docs/fixes/profile-picture-upload.md @@ -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. diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index 4325d5f425..a290536af5 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.html +++ b/projects/v3/src/app/components/assessment/assessment.component.html @@ -443,6 +443,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.spec.ts b/projects/v3/src/app/components/assessment/assessment.component.spec.ts index c6bf94fc06..048b94e69a 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({ diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index d808bd1a63..dcd9f99e35 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -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); + } } } @@ -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) { 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.spec.ts b/projects/v3/src/app/components/file-upload/file-upload.component.spec.ts index 165263004c..f7e24c5b3d 100644 --- a/projects/v3/src/app/components/file-upload/file-upload.component.spec.ts +++ b/projects/v3/src/app/components/file-upload/file-upload.component.spec.ts @@ -9,7 +9,11 @@ describe('FileUploadComponent', () => { let uppyUploaderService: jasmine.SpyObj; beforeEach(() => { - uppyUploaderService = jasmine.createSpyObj('UppyUploaderService', ['createUppyInstance']); + uppyUploaderService = jasmine.createSpyObj('UppyUploaderService', [ + 'createUppyInstance', + 'parseTusUploadResponse', + ]); + uppyUploaderService.parseTusUploadResponse.and.callFake((body) => JSON.parse(body)); component = new FileUploadComponent(uppyUploaderService); component.control = new FormControl(''); component.submitActions$ = new Subject(); @@ -59,6 +63,7 @@ describe('FileUploadComponent', () => { component.onAfterResponse({}, response); + expect(uppyUploaderService.parseTusUploadResponse).toHaveBeenCalledWith(response.getBody()); expect(component.tusResponse).toEqual({ path: '/uploads/a', bucket: 'b', cdnUrl: 'c', directUrl: 'd' }); }); 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..42288de3f3 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 @@ -1,4 +1,9 @@ -import { UppyUploaderService, ALLOWED_FILE_TYPES } from './../uppy-uploader/uppy-uploader.service'; +import { + UppyUploaderService, + ALLOWED_FILE_TYPES, + TusUploadResponse, + UppyUploadSource, +} from './../uppy-uploader/uppy-uploader.service'; import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { AbstractControl } from '@angular/forms'; import { Subject } from 'rxjs'; @@ -41,7 +46,7 @@ export class FileUploadComponent implements OnInit, OnDestroy { // Uppy UI uppyProps = UPPY_PROPS; - @Input() source!: "chat" | "profile" | "assessment" | "any" | "video" | "document" | "image"; + @Input() source!: UppyUploadSource; @Input() submitActions$: Subject; @Input() videoOnly?: boolean; @@ -75,12 +80,7 @@ export class FileUploadComponent implements OnInit, OnDestroy { uploadedFile: TusFileResponse; fileTypes = ''; - tusResponse: { - path: string; - bucket: string; - cdnUrl: string; - directUrl: string; - }; + tusResponse: TusUploadResponse; // the value of answer innerValue: any; @@ -137,7 +137,7 @@ export class FileUploadComponent implements OnInit, OnDestroy { onAfterResponse(req: any, res: any): void { // eslint-disable-next-line no-console console.log('onAfterResponse', req, res); - this.tusResponse = JSON.parse(res.getBody()); + this.tusResponse = this.uppyUploaderService.parseTusUploadResponse(res.getBody()); } initializeEventHandlers(uppy) { diff --git a/projects/v3/src/app/components/multiple/multiple.component.html b/projects/v3/src/app/components/multiple/multiple.component.html index 0a72dc4579..786fc0381d 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.html +++ b/projects/v3/src/app/components/multiple/multiple.component.html @@ -4,16 +4,42 @@

{ - -
- Learner's Answer - Reviewer's Answer + +
+ + + + + Selected by reviewer + + + + + Not selected by reviewer + + + + + 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..0f02e2c75f 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.scss +++ b/projects/v3/src/app/components/multiple/multiple.component.scss @@ -65,3 +65,47 @@ ion-item { --min-height: 1em; } +ion-label.reviewer-feedback-choice { + display: flex !important; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.reviewer-feedback-choice-text { + flex: 1 1 auto; + min-width: 0; +} + +.reviewer-choice-status { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + font-size: 14px; + line-height: 20px; + white-space: nowrap; +} + +.reviewer-choice-status-selected { + color: var(--ion-color-success-shade); + font-weight: 700; + + ion-icon { + --ionicon-stroke-width: 48px; + font-size: 18px; + } +} + +.reviewer-choice-status-not-selected { + color: var(--practera-grey-50); + font-weight: 400; +} + +@media (max-width: 576px) { + ion-label.reviewer-feedback-choice { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } +} 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..c3724d2626 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.spec.ts +++ b/projects/v3/src/app/components/multiple/multiple.component.spec.ts @@ -178,6 +178,94 @@ describe('MultipleComponent', () => { expect(fixture.nativeElement.textContent).not.toContain('choice1'); expect(fixture.nativeElement.textContent).not.toContain('choice3'); }); + + it('should show every reviewer-only choice with its reviewer selection state to the learner', () => { + component.question = { + reviewerOnly: true, + canAnswer: false, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' }, + { id: 3, name: 'choice3' } + ], + audience: ['reviewer'] + }; + component.submissionStatus = 'feedback available'; + component.doAssessment = false; + component.doReview = false; + component.submission = { answer: [1, 3] }; + component.review = { answer: [2] }; + + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('ion-list ion-item'); + const selectedStatuses = fixture.nativeElement.querySelectorAll('.reviewer-choice-status-selected'); + const notSelectedStatuses = fixture.nativeElement.querySelectorAll('.reviewer-choice-status-not-selected'); + + expect(component.isReviewerOnlyChoiceFeedback).toBeTrue(); + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2, 3]); + expect(items.length).toBe(3); + expect(selectedStatuses.length).toBe(1); + expect(selectedStatuses[0].querySelector('ion-icon').getAttribute('name')).toBe('checkmark'); + expect(selectedStatuses[0].querySelector('.reviewer-choice-status-text').textContent.trim()).toBe('Selected'); + expect(selectedStatuses[0].textContent).toContain('Selected by reviewer'); + expect(notSelectedStatuses.length).toBe(2); + expect(Array.from(notSelectedStatuses).every((status: Element) => status.querySelector('ion-icon') === null)).toBeTrue(); + expect(Array.from(notSelectedStatuses).every((status: Element) => status.querySelector('.reviewer-choice-status-text').textContent.trim() === 'Not selected')).toBeTrue(); + expect(Array.from(notSelectedStatuses).every((status: Element) => status.textContent.includes('Not selected by reviewer'))).toBeTrue(); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + }); + + it('should show every reviewer-only choice as not selected when the review answer is empty', () => { + component.question = { + reviewerOnly: true, + canAnswer: false, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' } + ], + audience: ['reviewer'] + }; + component.submissionStatus = 'feedback available'; + component.doAssessment = false; + component.doReview = false; + component.submission = {}; + component.review = { answer: [] }; + + fixture.detectChanges(); + + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2]); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-selected').length).toBe(0); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-not-selected').length).toBe(2); + }); + + it('should show every reviewer-only choice with its selection state in a completed reviewer view', () => { + component.question = { + reviewerOnly: true, + canAnswer: true, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' }, + { id: 3, name: 'choice3' } + ], + audience: ['reviewer'] + }; + component.submissionStatus = 'feedback available'; + component.doAssessment = false; + component.doReview = false; + component.submission = {}; + component.review = { answer: [2] }; + + fixture.detectChanges(); + + expect(component.isReviewerOnlyChoiceFeedback).toBeTrue(); + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2, 3]); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-selected').length).toBe(1); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-not-selected').length).toBe(2); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + }); }); it('when testing writeValue(), it should pass data correctly', () => { diff --git a/projects/v3/src/app/components/multiple/multiple.component.ts b/projects/v3/src/app/components/multiple/multiple.component.ts index 8cd087be04..bfffcaa6f8 100644 --- a/projects/v3/src/app/components/multiple/multiple.component.ts +++ b/projects/v3/src/app/components/multiple/multiple.component.ts @@ -247,8 +247,14 @@ export class MultipleComponent implements AfterViewInit, ControlValueAccessor, O return !this.doAssessment && !this.doReview && (this.submissionStatus === 'feedback available' || this.submissionStatus === 'pending review' || (this.submissionStatus === 'done' && this.reviewStatus === '')); } + get isReviewerOnlyChoiceFeedback(): boolean { + return this.isDisplayOnly + && this.question?.reviewerOnly === true + && this.submissionStatus === 'feedback available'; + } + get displayChoices(): Array { - if (!this.isDisplayOnly) { + if (!this.isDisplayOnly || this.isReviewerOnlyChoiceFeedback) { return this.question?.choices || []; } @@ -259,6 +265,13 @@ export class MultipleComponent implements AfterViewInit, ControlValueAccessor, O return (this.question?.choices || []).filter(choice => selectedChoiceIds.has(choice.id)); } + isReviewChoiceSelected(choiceId: string | number): boolean { + const selectedChoiceIds = new Set(); + this._collectSelectedChoiceIds(this.review?.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..f3600ad41f 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.html +++ b/projects/v3/src/app/components/oneof/oneof.component.html @@ -5,14 +5,40 @@

{{question. - -
- Learner's Answer - Reviewer's Answer + +
+ + + + + Selected by reviewer + + + + + Not selected by reviewer + + + + + Learner's Answer + Reviewer's Answer +
diff --git a/projects/v3/src/app/components/oneof/oneof.component.scss b/projects/v3/src/app/components/oneof/oneof.component.scss index c710298e38..2242e53ca5 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.scss +++ b/projects/v3/src/app/components/oneof/oneof.component.scss @@ -70,3 +70,48 @@ ion-item { .feedback-title { --min-height: 1em; } + +ion-label.reviewer-feedback-choice { + display: flex !important; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.reviewer-feedback-choice-text { + flex: 1 1 auto; + min-width: 0; +} + +.reviewer-choice-status { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + font-size: 14px; + line-height: 20px; + white-space: nowrap; +} + +.reviewer-choice-status-selected { + color: var(--ion-color-success-shade); + font-weight: 700; + + ion-icon { + --ionicon-stroke-width: 48px; + font-size: 18px; + } +} + +.reviewer-choice-status-not-selected { + color: var(--practera-grey-50); + font-weight: 400; +} + +@media (max-width: 576px) { + ion-label.reviewer-feedback-choice { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } +} 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..5fc9a0ee84 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.spec.ts +++ b/projects/v3/src/app/components/oneof/oneof.component.spec.ts @@ -176,6 +176,121 @@ describe('OneofComponent', () => { }); }); + describe('when testing display-only preview mode', () => { + it('should show every reviewer-only choice with its reviewer selection state to the learner', () => { + component.question = { + reviewerOnly: true, + canAnswer: false, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' }, + { id: 3, name: 'choice3' } + ], + audience: ['reviewer'] + }; + component.submissionStatus = 'feedback available'; + component.doAssessment = false; + component.doReview = false; + component.submission = { answer: 1 }; + component.review = { answer: 2 }; + + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('ion-list ion-item'); + const selectedStatuses = fixture.nativeElement.querySelectorAll('.reviewer-choice-status-selected'); + const notSelectedStatuses = fixture.nativeElement.querySelectorAll('.reviewer-choice-status-not-selected'); + + expect(component.isReviewerOnlyChoiceFeedback).toBeTrue(); + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2, 3]); + expect(items.length).toBe(3); + expect(selectedStatuses.length).toBe(1); + expect(selectedStatuses[0].querySelector('ion-icon').getAttribute('name')).toBe('checkmark'); + expect(selectedStatuses[0].querySelector('.reviewer-choice-status-text').textContent.trim()).toBe('Selected'); + expect(selectedStatuses[0].textContent).toContain('Selected by reviewer'); + expect(notSelectedStatuses.length).toBe(2); + expect(Array.from(notSelectedStatuses).every((status: Element) => status.querySelector('ion-icon') === null)).toBeTrue(); + expect(Array.from(notSelectedStatuses).every((status: Element) => status.querySelector('.reviewer-choice-status-text').textContent.trim() === 'Not selected')).toBeTrue(); + expect(Array.from(notSelectedStatuses).every((status: Element) => status.textContent.includes('Not selected by reviewer'))).toBeTrue(); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + }); + + it('should show every reviewer-only choice as not selected when the review answer is empty', () => { + component.question = { + reviewerOnly: true, + canAnswer: false, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' } + ], + audience: ['reviewer'] + }; + component.submissionStatus = 'feedback available'; + component.doAssessment = false; + component.doReview = false; + component.submission = {}; + component.review = {}; + + fixture.detectChanges(); + + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2]); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-selected').length).toBe(0); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-not-selected').length).toBe(2); + }); + + it('should show every reviewer-only choice with its selection state in a completed reviewer view', () => { + component.question = { + reviewerOnly: true, + canAnswer: true, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' }, + { id: 3, name: 'choice3' } + ], + audience: ['reviewer'] + }; + component.submissionStatus = 'feedback available'; + component.doAssessment = false; + component.doReview = false; + component.submission = {}; + component.review = { answer: 2 }; + + fixture.detectChanges(); + + expect(component.isReviewerOnlyChoiceFeedback).toBeTrue(); + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2, 3]); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-selected').length).toBe(1); + expect(fixture.nativeElement.querySelectorAll('.reviewer-choice-status-not-selected').length).toBe(2); + expect(fixture.nativeElement.textContent).not.toContain("Learner's Answer"); + expect(fixture.nativeElement.textContent).not.toContain("Reviewer's Answer"); + }); + + it('should preserve selected-only rendering for shared-audience questions', () => { + component.question = { + reviewerOnly: false, + canAnswer: false, + choices: [ + { id: 1, name: 'choice1' }, + { id: 2, name: 'choice2' }, + { id: 3, name: 'choice3' } + ], + audience: ['submitter', 'reviewer'] + }; + component.submissionStatus = 'feedback available'; + component.doAssessment = false; + component.doReview = false; + component.submission = { answer: 1 }; + component.review = { answer: 2 }; + + fixture.detectChanges(); + + expect(component.isReviewerOnlyChoiceFeedback).toBeFalse(); + expect(component.displayChoices.map(choice => choice.id)).toEqual([1, 2]); + expect(fixture.nativeElement.textContent).toContain("Learner's Answer"); + expect(fixture.nativeElement.textContent).toContain("Reviewer's Answer"); + }); + }); + describe('triggerSave()', () => { beforeEach(() => { component.question = { id: 42, audience: [] }; diff --git a/projects/v3/src/app/components/oneof/oneof.component.ts b/projects/v3/src/app/components/oneof/oneof.component.ts index 132d577f99..a0e4f66567 100644 --- a/projects/v3/src/app/components/oneof/oneof.component.ts +++ b/projects/v3/src/app/components/oneof/oneof.component.ts @@ -210,8 +210,14 @@ export class OneofComponent implements AfterViewInit, ControlValueAccessor, OnIn return !this.doAssessment && !this.doReview && (this.submissionStatus === 'feedback available' || this.submissionStatus === 'pending review' || (this.submissionStatus === 'done' && this.reviewStatus === '')); } + get isReviewerOnlyChoiceFeedback(): boolean { + return this.isDisplayOnly + && this.question?.reviewerOnly === true + && this.submissionStatus === 'feedback available'; + } + get displayChoices(): Array { - if (!this.isDisplayOnly) { + if (!this.isDisplayOnly || this.isReviewerOnlyChoiceFeedback) { return this.question?.choices || []; } @@ -230,6 +236,10 @@ export class OneofComponent implements AfterViewInit, ControlValueAccessor, OnIn return (this.question?.choices || []).filter(choice => selectedIds.has(choice.id)); } + isReviewChoiceSelected(choiceId: string | number): boolean { + return this.review?.answer === choiceId; + } + // innerHTML text toggle onLabelToggle = (id: string): void => { this.onChange(id); diff --git a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.html b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.html index aea21a7a66..a1b0b029e3 100644 --- a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.html +++ b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.html @@ -59,13 +59,13 @@

{{ projectBrief.title }}

- + Technical Skills
{{ skill }} @@ -78,13 +78,13 @@

{{ projectBrief.title }}

- + Professional Skills
{{ skill }} @@ -97,7 +97,7 @@

{{ projectBrief.title }}

- + Deliverables
diff --git a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.spec.ts b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.spec.ts index 8fc024d6a3..03b5e95da9 100644 --- a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.spec.ts +++ b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.spec.ts @@ -117,5 +117,30 @@ describe('ProjectBriefModalComponent', () => { const chips = fixture.nativeElement.querySelectorAll('ion-chip'); expect(chips.length).toBe(4); }); + + it('should use the primary brand color for section accents', () => { + component.projectBrief = { + industry: ['Health'], + technicalSkills: ['Python'], + professionalSkills: ['Leadership'], + deliverables: 'Prototype' + }; + fixture.detectChanges(); + + const accentSelectors = [ + 'ion-icon[name="document-text-outline"]', + 'ion-icon[name="business-outline"]', + 'ion-icon[name="code-slash-outline"]', + 'ion-icon[name="people-outline"]', + 'ion-icon[name="checkbox-outline"]', + 'ion-chip' + ]; + + accentSelectors.forEach((selector) => { + fixture.nativeElement.querySelectorAll(selector).forEach((element: Element) => { + expect(element.getAttribute('color')).toBe('primary'); + }); + }); + }); }); }); diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.spec.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.spec.ts new file mode 100644 index 0000000000..fddb32b16e --- /dev/null +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.spec.ts @@ -0,0 +1,78 @@ +import { ModalController } from '@ionic/angular'; +import { NotificationsService } from '../../services/notifications.service'; +import { BrowserStorageService } from '../../services/storage.service'; +import { UppyUploaderComponent } from './uppy-uploader.component'; +import { TusUploadResponse, UppyUploaderService } from './uppy-uploader.service'; + +describe('UppyUploaderComponent', () => { + let component: UppyUploaderComponent; + let notificationsService: jasmine.SpyObj; + let modalController: jasmine.SpyObj; + let storageService: jasmine.SpyObj; + let uppyUploaderService: jasmine.SpyObj; + + beforeEach(() => { + notificationsService = jasmine.createSpyObj('NotificationsService', ['alert']); + modalController = jasmine.createSpyObj('ModalController', ['dismiss']); + storageService = jasmine.createSpyObj('BrowserStorageService', ['clearByName']); + uppyUploaderService = jasmine.createSpyObj( + 'UppyUploaderService', + ['createUppyInstance', 'parseTusUploadResponse'], + { uppyProps: {} as any } + ); + + component = new UppyUploaderComponent( + notificationsService, + modalController, + storageService, + uppyUploaderService + ); + }); + + it('restricts user profile uploads to images', () => { + component.source = 'user-profile'; + + expect(component.loadAllowedFileTypes()).toEqual(['image/*']); + }); + + it('returns the canonical CDN URL from the TUS response', () => { + const tusResponse: TusUploadResponse = { + bucket: 'profile-images', + path: '/users/profile.png', + cdnUrl: 'https://cdn.example.com/users/profile.png', + directUrl: 'https://files.example.com/users/profile.png', + }; + component.s3Info = tusResponse; + const file = { + name: 'profile.png', + type: 'image/png', + size: 10, + extension: 'png', + } as any; + + component.closeModal(file); + + expect(modalController.dismiss).toHaveBeenCalledWith(jasmine.objectContaining({ + bucket: tusResponse.bucket, + path: tusResponse.path, + url: tusResponse.cdnUrl, + cdnUrl: tusResponse.cdnUrl, + directUrl: tusResponse.directUrl, + })); + }); + + it('reports and rethrows an invalid TUS response', () => { + uppyUploaderService.parseTusUploadResponse.and.throwError( + 'Upload server returned an empty response.' + ); + const response = { getBody: () => '' }; + + expect(() => component.onAfterResponse({}, response)).toThrowError( + 'Upload server returned an empty response.' + ); + expect(notificationsService.alert).toHaveBeenCalledWith({ + header: 'Upload Failed', + message: 'Upload server returned an empty response.', + }); + }); +}); diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts index 3be19ae403..388baa4bef 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts @@ -1,4 +1,10 @@ -import { UppyFileData, UppyUploaderService, ALLOWED_FILE_TYPES } from './uppy-uploader.service'; +import { + UppyFileData, + UppyUploaderService, + ALLOWED_FILE_TYPES, + TusUploadResponse, + UppyUploadSource, +} from './uppy-uploader.service'; import { environment } from '@v3/environments/environment'; import { NotificationsService } from './../../services/notifications.service'; import { Component, OnInit, Input, Output, EventEmitter, OnDestroy } from '@angular/core'; @@ -16,7 +22,7 @@ type FileBody = { [key: string]: any }; styleUrls: ["./uppy-uploader.component.scss"], }) export class UppyUploaderComponent implements OnInit, OnDestroy { - @Input() source!: "chat" | "profile" | "assessment" | "any" | "video" | "document" | "image"; + @Input() source!: UppyUploadSource; @Input() tusEndpoint?: string = environment.uppyConfig.tusUrl; // tusUrl @Output() uploadComplete = new EventEmitter(); @@ -26,11 +32,7 @@ export class UppyUploaderComponent implements OnInit, OnDestroy { // Uppy UI uppyProps: any; - s3Info: { - path: string; - bucket: string; - url: string; - }; + s3Info: TusUploadResponse; constructor( private notificationsService: NotificationsService, @@ -67,6 +69,7 @@ export class UppyUploaderComponent implements OnInit, OnDestroy { loadAllowedFileTypes() { switch(this.source) { case "profile": + case "user-profile": case "image": return ["image/*"]; @@ -92,12 +95,13 @@ export class UppyUploaderComponent implements OnInit, OnDestroy { try { // eslint-disable-next-line no-console console.log("Uploaded files:", req, res); - this.s3Info = JSON.parse(res.getBody()); + this.s3Info = this.uppyUploaderService.parseTusUploadResponse(res.getBody()); } catch(error) { this.notificationsService.alert({ header: "Upload Failed", - message: "No response from server", + message: error.message, }); + throw error; } } @@ -113,12 +117,18 @@ export class UppyUploaderComponent implements OnInit, OnDestroy { } closeModal(file) { + if (!this.s3Info) { + throw new Error('Upload server response is missing required file metadata.'); + } + const data: UppyFileData = { ...file, ...{ - bucket: this.s3Info?.bucket, - path: this.s3Info?.path, - url: this.s3Info?.url, + bucket: this.s3Info.bucket, + path: this.s3Info.path, + url: this.s3Info.cdnUrl, + cdnUrl: this.s3Info.cdnUrl, + directUrl: this.s3Info.directUrl, } }; this.modalController.dismiss(data); diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts index 4c43d8f7a4..73da77e0c5 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts @@ -142,4 +142,43 @@ describe('UppyUploaderService', () => { expect(service.getPatchValue(testId)).toEqual(testValue); }); }); + + describe('parseTusUploadResponse', () => { + it('should parse the upload metadata returned by the TUS server', () => { + const response = service.parseTusUploadResponse(JSON.stringify({ + bucket: 'bucket', + path: '/uploads/profile.png', + cdnUrl: 'https://cdn.example.com/profile.png', + directUrl: 'https://files.example.com/profile.png', + })); + + expect(response).toEqual({ + bucket: 'bucket', + path: '/uploads/profile.png', + cdnUrl: 'https://cdn.example.com/profile.png', + directUrl: 'https://files.example.com/profile.png', + }); + }); + + it('should reject an empty response body', () => { + expect(() => service.parseTusUploadResponse('')).toThrowError( + 'Upload server returned an empty response.' + ); + }); + + it('should reject malformed JSON', () => { + expect(() => service.parseTusUploadResponse('{invalid')).toThrowError( + 'Upload server returned an invalid response.' + ); + }); + + it('should reject incomplete upload metadata', () => { + expect(() => service.parseTusUploadResponse(JSON.stringify({ + bucket: 'bucket', + path: '/uploads/profile.png', + }))).toThrowError( + 'Upload server response is missing required file metadata.' + ); + }); + }); }); diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts index 9407a7f6e2..8d0c20644a 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts @@ -17,6 +17,25 @@ export interface UppyUploaderResponse { size: number; } +export type UppyUploadSource = + | 'chat' + | 'profile' + | 'user-profile' + | 'assessment' + | 'media-manager' + | 'static' + | 'any' + | 'video' + | 'document' + | 'image'; + +export interface TusUploadResponse { + path: string; + bucket: string; + cdnUrl: string; + directUrl: string; +} + export interface UppyFileData { source: string; id: string; @@ -48,6 +67,8 @@ export interface UppyFileData { bucket: string; path: string; url: string; + cdnUrl: string; + directUrl: string; } type FileMetadata = { [key: string]: any }; @@ -113,7 +134,7 @@ export class UppyUploaderService { * @param restrictions * @returns Uppy */ - createUppyInstance(source: "chat" | "profile" | "assessment" | "any" | "video" | "document" | "image", uploadUrl: string, events?: { + createUppyInstance(source: UppyUploadSource, uploadUrl: string, events?: { onAfterResponse: (req: any, res: any) => void, onUploadSuccess: (file: UppyFile, response: any) => void }, options?: { @@ -165,6 +186,25 @@ export class UppyUploaderService { return uppy; } + parseTusUploadResponse(body: string): TusUploadResponse { + if (!body?.trim()) { + throw new Error('Upload server returned an empty response.'); + } + + let response: Partial; + try { + response = JSON.parse(body); + } catch { + throw new Error('Upload server returned an invalid response.'); + } + + if (!response.bucket || !response.path || !response.cdnUrl || !response.directUrl) { + throw new Error('Upload server response is missing required file metadata.'); + } + + return response as TusUploadResponse; + } + private initializeEventHandlers(uppy: Uppy, onUploadSuccess: (file: UppyFile, response: any) => void) { uppy.on('dashboard:file-edit-start', (file: any) => { console.log('file edit start', file); @@ -198,7 +238,7 @@ export class UppyUploaderService { * @param {string} source * @return {Promise} */ - async open(source: 'chat' | 'user-profile' | 'assessment' | 'media-manager' | 'static' | 'any' | 'image' | 'video' | null): Promise { + async open(source: UppyUploadSource | null): Promise { // dynamic import to break circular dependency with UppyUploaderComponent const { UppyUploaderComponent } = await import('./uppy-uploader.component'); const modal = await this.modalController.create({ diff --git a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html index 054c5697a5..c63617a0c3 100644 --- a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html +++ b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html @@ -29,8 +29,8 @@
- - uploaded attachment preview + + uploaded attachment preview @@ -40,7 +40,7 @@ controlsList="nodownload" preload="metadata" playsinline - [src]="file.url" + [src]="previewUrl" (error)="handleVideoError($event)" >

diff --git a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts index 75d262c72b..4a55855f75 100644 --- a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts +++ b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts @@ -49,6 +49,29 @@ describe('ChatPreviewComponent', () => { expect(component.file.url).toBe(TEST_URL); }); + describe('previewUrl', () => { + it('should render the immediate preview URL when it is available', () => { + const directUrl = 'https://uploads.example.com/chat/image.png?token=direct'; + component.file = { + type: 'image/png', + url: 'https://cdn.example.com/chat/image.png', + preview: directUrl, + }; + + fixture.detectChanges(); + + const image = fixture.nativeElement.querySelector('img') as HTMLImageElement; + expect(component.previewUrl).toBe(directUrl); + expect(image.src).toBe(directUrl); + }); + + it('should fall back to the canonical URL for sent attachments', () => { + component.file = { url: TEST_URL }; + + expect(component.previewUrl).toBe(TEST_URL); + }); + }); + describe('download()', () => { it('should open and download from a URL', () => { spyOn(window, 'open'); diff --git a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts index 9609a2bd1e..5c3ef95fe4 100644 --- a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts +++ b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts @@ -16,6 +16,10 @@ export class ChatPreviewComponent { public sanitizer: DomSanitizer ) {} + get previewUrl(): string { + return this.file?.preview || this.file?.url; + } + download(keyboardEvent?: KeyboardEvent) { if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) { keyboardEvent.preventDefault(); diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts index 6b4197c8ec..82f2d14d9a 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts @@ -357,6 +357,89 @@ describe('ChatRoomComponent', () => { preview: undefined }); }); + + it('should broadcast the signed attachment returned by the message API', () => { + const uploadedAttachment = { + bucket: 'chat', + path: '/uploads/image.png', + name: 'image.png', + url: 'https://file.example.com/files/chat/image.png', + extension: 'png', + type: 'image/png', + size: 1024, + preview: 'https://file.example.com/files/chat/image.png', + }; + const signedFile = { + name: 'image.png', + type: 'image/png', + url: 'https://file.example.com/files/chat/image.png?Signature=signed', + }; + const saveMessageRes = { + uuid: 'attachment-message-uuid', + isSender: true, + message: '', + file: signedFile, + created: '2026-08-12 09:30:00', + sentAt: '2026-08-12 09:30:00', + senderUuid: 'sender-uuid', + senderName: 'Sender', + senderRole: 'participant', + senderAvatar: null, + sender: { + uuid: 'sender-uuid', + name: 'Sender', + role: 'participant', + avatar: null, + }, + }; + + component.channelUuid = 'channel-uuid'; + component.chatChannel.pusherChannel = 'private-chat-channel'; + component.messagePageCursor = 'existing-cursor'; + component.selectedAttachments = [uploadedAttachment]; + chatServiceSpy.postNewMessage.and.returnValue(of(saveMessageRes)); + pusherSpy.triggerSendMessage.calls.reset(); + + component.sendMessage(); + + expect(pusherSpy.triggerSendMessage).toHaveBeenCalledTimes(1); + expect(pusherSpy.triggerSendMessage).toHaveBeenCalledWith( + 'private-chat-channel', + jasmine.objectContaining({ + uuid: saveMessageRes.uuid, + file: signedFile, + }) + ); + expect(pusherSpy.triggerSendMessage.calls.mostRecent().args[1].file) + .not.toBe(uploadedAttachment); + }); + }); + + describe('when testing addAttachment()', () => { + it('should preview the direct URL while retaining the canonical message URL', () => { + const upload = { + name: 'cyberpunk.png', + url: 'https://cdn.example.com/files/chat/cyberpunk.png', + directUrl: 'https://uploads.example.com/chat/cyberpunk.png?token=direct', + extension: 'png', + type: 'image/png', + size: 1024, + bucket: 'chat', + path: '/uploads/cyberpunk.png', + tus: { + uploadUrl: 'https://uploads.example.com/tus/cyberpunk.png', + }, + } as any; + + component.addAttachment(upload); + + expect(component.selectedAttachments[0]).toEqual( + jasmine.objectContaining({ + url: upload.url, + preview: upload.directUrl, + }) + ); + }); }); describe('when testing getAvatarClass()', () => { diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts index 8d2704d349..a5cb09c979 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts @@ -561,7 +561,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { .pipe(takeUntil(this.destroy$)) .subscribe( (response) => { - this.afterEventEmission(response, attachment); + this.afterEventEmission(response); this.removeSelectAttachment(attachment); }, (error) => { @@ -574,8 +574,8 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { } // series of after event emission actions (triggered after sending message) - afterEventEmission(response, attachment?) { - this.triggerPusherEvent(response, attachment); + afterEventEmission(response) { + this.triggerPusherEvent(response); this.updateListData(response); this.utils.broadcastEvent("chat:info-update", true); this._scrollToBottom(); @@ -583,13 +583,13 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { } // trigger pusher event with file response - triggerPusherEvent(response, file?: FileResponse) { + triggerPusherEvent(response) { const pusherData: SendMessageParam = { channelUuid: this.channelUuid, uuid: response.uuid, isSender: response.isSender, message: response.message, - file: file || response.file, + file: response.file, created: response.created, senderUuid: response.senderUuid, senderName: response.senderName, @@ -1058,7 +1058,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { // tusd custom fields bucket: uppyRes.bucket, path: uppyRes.path, - preview: uppyRes.url || uppyRes.tus.uploadUrl, + preview: uppyRes.directUrl || uppyRes.url || uppyRes.tus.uploadUrl, }); } diff --git a/projects/v3/src/app/pages/settings/settings.page.spec.ts b/projects/v3/src/app/pages/settings/settings.page.spec.ts index f79fe0b1c7..e0fbea0622 100644 --- a/projects/v3/src/app/pages/settings/settings.page.spec.ts +++ b/projects/v3/src/app/pages/settings/settings.page.spec.ts @@ -57,7 +57,14 @@ describe('SettingsPage', () => { } } as any)); authSpy.logout.and.returnValue(Promise.resolve() as any); - authSpy.updateUserProfile.and.returnValue(of({}) as any); + authSpy.updateUserProfile.and.returnValue(of({ + data: { + updateUserProfile: { + success: true, + message: 'User profile updated successfully', + } + } + }) as any); storageSpy.getUser.and.returnValue({ email: 'user@example.com', @@ -235,6 +242,8 @@ describe('SettingsPage', () => { size: 10, bucket: 'bucket', path: '/uploads/profile', + url: 'https://cdn/profile.png', + directUrl: 'https://files/profile.png', preview: 'https://cdn/profile.png', }; uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve({ @@ -243,12 +252,56 @@ describe('SettingsPage', () => { await component.profileImage(); - expect(authSpy.updateUserProfile).toHaveBeenCalled(); - expect(component.profile.avatar).toBe('https://cdn/profile.png'); - expect(storageSpy.setUser).toHaveBeenCalledWith({ image: 'https://cdn/profile.png' }); + expect(authSpy.updateUserProfile).toHaveBeenCalledWith({ + url: 'https://files/profile.png', + name: 'profile.png', + extension: 'png', + type: 'image/png', + size: 10, + bucket: 'bucket', + path: '/uploads/profile', + }); + expect(component.profile.avatar).toBe('https://files/profile.png'); + expect(storageSpy.setUser).toHaveBeenCalledWith({ + avatar: 'https://files/profile.png', + image: 'https://files/profile.png', + }); expect(notificationsServiceSpy.alert).toHaveBeenCalled(); }); + it('should not update local profile when the backend rejects the file', async () => { + const uploaded = { + tus: { uploadUrl: 'https://upload' }, + name: 'profile.png', + extension: 'png', + type: 'image/png', + size: 10, + bucket: 'bucket', + path: '/uploads/profile', + url: 'https://cdn/profile.png', + directUrl: 'https://files/profile.png', + }; + uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve({ + onDidDismiss: () => Promise.resolve({ data: uploaded }) + } as any)); + authSpy.updateUserProfile.and.returnValue(of({ + data: { + updateUserProfile: { + success: false, + message: 'avatar file object incorrect', + } + } + }) as any); + + await component.profileImage(); + + expect(component.profile.avatar).not.toBe('https://files/profile.png'); + expect(storageSpy.setUser).not.toHaveBeenCalled(); + const alertArgs = notificationsServiceSpy.alert.calls.mostRecent().args[0]; + expect(alertArgs.subHeader).toBe('avatar file object incorrect'); + expect(component.imageUpdating).toBeFalse(); + }); + it('should show upload error subHeader when server returns message', async () => { uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve({ onDidDismiss: () => Promise.resolve({ data: { tus: { uploadUrl: 'u' } } }) diff --git a/projects/v3/src/app/pages/settings/settings.page.ts b/projects/v3/src/app/pages/settings/settings.page.ts index fff0999a3f..eb89367fc5 100644 --- a/projects/v3/src/app/pages/settings/settings.page.ts +++ b/projects/v3/src/app/pages/settings/settings.page.ts @@ -186,8 +186,11 @@ export class SettingsPage implements OnInit, OnDestroy { const file = res.data; if (file) { this.imageUpdating = true; - await firstValueFrom(this.authService.updateUserProfile({ - url: file.tus.uploadUrl, + // User-profile CDN URLs are not directly readable in every environment. + // Match file-display and prefer the TUS direct URL when it is available. + const profileUrl = file.directUrl || file.url; + const response = await firstValueFrom(this.authService.updateUserProfile({ + url: profileUrl, name: file.name, extension: file.extension, type: file.type, @@ -196,9 +199,16 @@ export class SettingsPage implements OnInit, OnDestroy { path: file.path, })); - this.imageUpdating = false; - this.profile.avatar = file.preview; - this.storage.setUser({ image: file.preview }); + const result = response?.data?.updateUserProfile; + if (result?.success !== true) { + throw new Error(result?.message || 'Profile picture could not be updated.'); + } + + this.profile.avatar = profileUrl; + this.storage.setUser({ + avatar: profileUrl, + image: profileUrl, + }); return this.notificationsService.alert({ message: $localize`Profile picture successfully updated!`, @@ -211,8 +221,6 @@ export class SettingsPage implements OnInit, OnDestroy { }); } } catch (error) { - this.imageUpdating = false; - // eslint-disable-next-line no-console console.error('profile image error', error); @@ -227,10 +235,12 @@ export class SettingsPage implements OnInit, OnDestroy { }; // Actual error message from server - if (error?.error?.message || error?.error?.msg) { - alertOpts.subHeader = error?.error?.message || error?.error?.msg; + if (error?.error?.message || error?.error?.msg || error?.message) { + alertOpts.subHeader = error?.error?.message || error?.error?.msg || error?.message; } return this.notificationsService.alert(alertOpts); + } finally { + this.imageUpdating = false; } } diff --git a/projects/v3/src/app/personalised-header/personalised-header.component.scss b/projects/v3/src/app/personalised-header/personalised-header.component.scss index 4b746820e3..05be7e77cf 100644 --- a/projects/v3/src/app/personalised-header/personalised-header.component.scss +++ b/projects/v3/src/app/personalised-header/personalised-header.component.scss @@ -28,7 +28,9 @@ ion-avatar { --padding-end: 1px; --padding-bottom: 1px; --padding-start: 1px; + } + .notify-btn { .hint { background-color: red; position: absolute; diff --git a/projects/v3/src/app/services/auth.service.spec.ts b/projects/v3/src/app/services/auth.service.spec.ts index a29932a99b..9a089b85ff 100644 --- a/projects/v3/src/app/services/auth.service.spec.ts +++ b/projects/v3/src/app/services/auth.service.spec.ts @@ -45,6 +45,7 @@ describe('AuthService', () => { provide: ApolloService, useValue: jasmine.createSpyObj('ApolloService', { 'graphQLFetch': of(), + 'graphQLMutate': of(), 'graphQLWatch': of(), 'getClient': function () { return { @@ -103,6 +104,27 @@ describe('AuthService', () => { expect(service).toBeTruthy(); }); + it('should execute updateUserProfile as a mutation with the avatar variables', () => { + const apolloSpy = TestBed.inject(ApolloService) as jasmine.SpyObj; + const avatar = { + bucket: 'profile-images', + path: '/users/profile.png', + name: 'profile.png', + url: 'https://cdn.example.com/users/profile.png', + extension: 'png', + type: 'image/png', + size: 10, + }; + + service.updateUserProfile(avatar).subscribe(); + + expect(apolloSpy.graphQLMutate).toHaveBeenCalledWith( + jasmine.stringMatching(/mutation updateUserProfile/), + { avatar } + ); + expect(apolloSpy.graphQLFetch).not.toHaveBeenCalled(); + }); + it('when testing directLogin(), it should pass the correct data to API', () => { const apolloSpy = TestBed.inject(ApolloService) as jasmine.SpyObj; apolloSpy.graphQLFetch.and.returnValue(of({ diff --git a/projects/v3/src/app/services/auth.service.ts b/projects/v3/src/app/services/auth.service.ts index 5a132ca8fb..cb871222a4 100644 --- a/projects/v3/src/app/services/auth.service.ts +++ b/projects/v3/src/app/services/auth.service.ts @@ -45,6 +45,12 @@ interface ProfileAvatar { size: number; } +interface UpdateUserProfileResponse { + data?: { + updateUserProfile?: Response; + }; +} + interface RegisterData { password?: string; user_id: number; @@ -669,8 +675,8 @@ export class AuthService { * * @return {} [return description] */ - updateUserProfile(avatar: ProfileAvatar): Observable { - return this.apolloService.graphQLFetch(` + updateUserProfile(avatar: ProfileAvatar): Observable { + return this.apolloService.graphQLMutate(` mutation updateUserProfile($avatar: FileInput) { updateUserProfile(avatar: $avatar) { success @@ -678,9 +684,7 @@ export class AuthService { } } `, { - variables: { - avatar - } + avatar }); } } diff --git a/projects/v3/src/app/services/pusher.service.ts b/projects/v3/src/app/services/pusher.service.ts index d8657a0eaa..fa74068ea8 100644 --- a/projects/v3/src/app/services/pusher.service.ts +++ b/projects/v3/src/app/services/pusher.service.ts @@ -18,7 +18,11 @@ export interface SendMessageParam { channelUuid: string; uuid: string; message: string; - file: string; + file: { + name: string; + type: string; + url: string; + } | string | null; isSender: boolean; created: string; senderUuid: string; diff --git a/projects/v3/src/index.html b/projects/v3/src/index.html index 9cc5fe6018..e660258fc8 100644 --- a/projects/v3/src/index.html +++ b/projects/v3/src/index.html @@ -19,6 +19,57 @@ - + diff --git a/projects/v3/src/styles.scss b/projects/v3/src/styles.scss index 42fe3266fe..db31281897 100644 --- a/projects/v3/src/styles.scss +++ b/projects/v3/src/styles.scss @@ -569,3 +569,15 @@ quill-editor .ql-toolbar.ql-snow { margin-right: 8px !important; } } + +// accessWidget is loaded only on non-local hosts. Reserve enough room in the +// chat composer for its floating trigger without changing unrelated pages. +body.accessibility-widget-enabled app-chat-room ion-row.action-buttons { + padding-inline-end: 64px; +} + +@media (min-width: 768px) { + body.accessibility-widget-enabled app-chat-room ion-row.action-buttons { + padding-inline-end: 88px; + } +}