From 307b5e8b2d4fce22e7baba31003294ad055bce09 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 20:04:21 +1000 Subject: [PATCH 1/3] feat(submissions): submission processing, DOCX feedback and upload rules Brings the T2 2026 submissions work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit Co-authored-by: David Tenni Co-authored-by: Swyam Khare Co-authored-by: Maple Fox Co-authored-by: Freddy Co-authored-by: Reem-997 Co-authored-by: Tan Tai Co-authored-by: Sujay-Deakin Co-authored-by: SandilBandara Co-authored-by: sakethsram888 Co-authored-by: Roeenk27 Co-authored-by: owensmac Co-authored-by: Niethin Co-authored-by: mudith-perera Co-authored-by: lgr2002 Co-authored-by: Gaurav Myana Co-authored-by: dylancleal Co-authored-by: baohuy-tran --- docs/FILE-A01-safe-upload-policy-matrix.md | 849 ++++++++++++++++++ docs/english-only-submission-notice.md | 21 + docs/safe-upload-and-chat-guide.md | 114 +++ .../SLR-E01-policy-proposal.md | 118 +++ .../student-history-and-deadline-controls.md | 51 ++ .../models/task-comment/attachment-policy.ts | 43 + .../api/models/task-comment/task-comment.ts | 8 +- src/app/api/models/task-definition.ts | 2 + .../submission-history.service.spec.ts | 64 ++ .../services/submission-history.service.ts | 57 +- .../services/task-definition.service.spec.ts | 69 ++ .../api/services/task-definition.service.ts | 5 +- .../edit-profile-form.component.html | 216 +++-- .../edit-profile-form.component.scss | 53 +- .../edit-profile-form.component.spec.ts | 326 ++++++- .../edit-profile-form.component.ts | 146 ++- .../file-downloader.service.spec.ts | 151 ++++ .../file-downloader.service.ts | 105 +-- .../common/file-drop/file-drop.component.html | 45 +- .../file-drop/file-drop.component.spec.ts | 117 +++ .../common/file-drop/file-drop.component.ts | 26 +- .../common/file-uploader/file-upload-types.ts | 37 + .../file-uploader.accessibility.spec.ts | 88 ++ .../file-uploader.component.html | 21 +- .../file-uploader.component.spec.ts | 41 + .../file-uploader/file-uploader.component.ts | 54 +- .../file-viewer/file-viewer.component.html | 4 +- .../file-viewer/file-viewer.component.spec.ts | 50 +- .../file-viewer/file-viewer.component.ts | 14 +- .../pdf-viewer-panel.component.html | 16 +- .../pdf-viewer-panel.component.scss | 2 +- .../pdf-viewer-panel.component.spec.ts | 45 +- .../pdf-viewer/pdf-viewer.component.html | 28 +- .../pdf-viewer/pdf-viewer.component.spec.ts | 65 ++ ...s-download.component.accessibility.spec.ts | 59 ++ .../submission-files-download.component.html | 4 +- ...te-portfolio-task-list-item.component.html | 6 +- .../previous-submissions.component.html | 66 ++ .../previous-submissions.component.scss | 18 + .../previous-submissions.component.spec.ts | 117 +++ .../previous-submissions.component.ts | 95 ++ .../submission-files-modal.component.html | 4 +- .../task-submission-card.component.html | 4 +- .../task-submission-card.tpl.html | 67 -- ...portfolio-grade-select-step.component.html | 2 +- ...earning-summary-report-step.component.html | 6 +- .../portfolio-included-tasks.component.html | 10 +- ...portfolio-included-tasks.component.spec.ts | 105 +++ .../task-upload-requirements.component.html | 58 ++ .../task-upload-requirements.component.scss | 63 ++ ...task-upload-requirements.component.spec.ts | 142 +++ .../task-upload-requirements.component.ts | 57 ++ .../upload-category.ts | 41 + .../upload-submission-modal.component.html | 57 +- .../upload-submission-modal.component.spec.ts | 221 +++++ .../upload-submission-modal.component.ts | 55 +- ...-confirmation-dialog.accessibility.spec.ts | 73 ++ ...achment-confirmation-dialog.component.html | 15 +- ...ment-confirmation-dialog.component.spec.ts | 46 + ...ttachment-confirmation-dialog.component.ts | 5 +- .../task-comment-composer.component.ts | 252 ++++-- ...ask-comment-composer.touch-targets.spec.ts | 145 +++ .../task-definition-options.component.html | 13 + .../task-definition-upload.component.html | 82 +- .../task-definition-upload.component.scss | 3 +- .../d2l-transfer.component.ts | 2 +- .../portfolios-list.component.html | 318 +++---- .../portfolios-list.component.spec.ts | 88 ++ .../portfolios-project-progress.component.ts | 5 +- .../portfolios/portfolios.component.html | 4 +- .../states/portfolios/portfolios.component.ts | 76 +- 71 files changed, 4793 insertions(+), 642 deletions(-) create mode 100644 docs/FILE-A01-safe-upload-policy-matrix.md create mode 100644 docs/english-only-submission-notice.md create mode 100644 docs/safe-upload-and-chat-guide.md create mode 100644 docs/submission-lifecycle/SLR-E01-policy-proposal.md create mode 100644 docs/submission-lifecycle/student-history-and-deadline-controls.md create mode 100644 src/app/api/models/task-comment/attachment-policy.ts create mode 100644 src/app/api/services/submission-history.service.spec.ts create mode 100644 src/app/api/services/task-definition.service.spec.ts create mode 100644 src/app/common/file-downloader/file-downloader.service.spec.ts create mode 100644 src/app/common/file-drop/file-drop.component.spec.ts create mode 100644 src/app/common/file-uploader/file-upload-types.ts create mode 100644 src/app/common/file-uploader/file-uploader.accessibility.spec.ts create mode 100644 src/app/common/file-uploader/file-uploader.component.spec.ts create mode 100644 src/app/common/pdf-viewer/pdf-viewer.component.spec.ts create mode 100644 src/app/common/submission-files-download/submission-files-download.component.accessibility.spec.ts create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.html create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.spec.ts create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.ts delete mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.tpl.html create mode 100644 src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.spec.ts create mode 100644 src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html create mode 100644 src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss create mode 100644 src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts create mode 100644 src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts create mode 100644 src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts create mode 100644 src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts create mode 100644 src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.accessibility.spec.ts create mode 100644 src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.spec.ts create mode 100644 src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts create mode 100644 src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts diff --git a/docs/FILE-A01-safe-upload-policy-matrix.md b/docs/FILE-A01-safe-upload-policy-matrix.md new file mode 100644 index 0000000000..2a16381ba7 --- /dev/null +++ b/docs/FILE-A01-safe-upload-policy-matrix.md @@ -0,0 +1,849 @@ +> Implementation update: see [the tested upload and chat guide](safe-upload-and-chat-guide.md) for the current contract, spreadsheet pipeline correction, chat XLS exclusion and exact limits. This document retains the earlier design record. + +# FILE-A01 Current vs Approved Format and Limit Matrix + +## Policy Principles + +- Task submissions and task-chat attachments have separate policies because they serve different purposes. +- The API is the final authority for all upload decisions. +- The frontend may guide file selection but must not override API rejection. +- Uploads use explicit allowlists. Arbitrary file extensions are not permitted. +- New specialist formats require a demonstrated unit need and policy review. + +## Format Matrix + +| Category | Current State | Proposed Approved Formats | Context | Classification | Preview Policy | +|---|---|---|---|---|---| +| Spreadsheet | Internal `csv` category supports CSV, XLS and XLSX | `.csv`, `.xls`, `.xlsx` | Task submission | MVP | Download-only unless a dedicated safe spreadsheet viewer is added | +| PDF | API validates PDF structure. Task chat accepts PDF. Existing frontend `document` category includes PDF | `.pdf` | Task submission and task chat | MVP | Submission: PDF viewer. Chat confirmation: filename and PDF indicator | +| Document | API has dedicated DOCX structural validation through `word_document` and comment attachment handling | `.docx` | Task submission where explicitly required | MVP | Download-only | +| Code | Existing curated Code category contains common development and text formats | Existing reviewed Code allowlist only | Task submission | MVP | Safe text/code preview through Monaco where supported | +| Image | Existing image category supports common raster image formats | `.png`, `.bmp`, `.tiff`, `.tif`, `.jpeg`, `.jpg`, `.gif` | Task submission and task chat | MVP | Inline image preview | +| Audio | Task chat currently accepts audio and API validates audio MIME types | Browser-compatible API-approved audio formats | Task chat | MVP | Inline audio player | +| Archive | API supports ZIP, TAR, TAR.GZ and TGZ with additional archive validation | `.zip`, `.tar`, `.tar.gz`, `.tgz` | Task submission only when explicitly required | Controlled MVP | Download-only | +| Video | Limited backend support exists but ordinary task chat does not expose video upload | No general MVP approval | Specialist / future | Future | Download-only unless a dedicated safe viewer is approved | +| Macro-enabled Office | Not part of the approved upload path | None | All contexts | Excluded | None | +| Encrypted files | Encrypted PDF, ZIP and Word packages are rejected | None | All contexts | Excluded | None | +| Executables / installers | Not present in the approved extension allowlist | None | All contexts | Excluded | None | +| Disk images | Not present in the approved extension allowlist | None | All contexts | Excluded | None | +| Nested archives | Explicitly rejected by archive validation | None | All contexts | Excluded | None | +| Unknown / arbitrary formats | Explicit allowlists are already used | None | All contexts | Excluded | None | + +## Display Names + +The approved user-facing category names are: + +- Spreadsheet +- PDF +- Document +- Code +- Image +- Audio +- Archive + +Internal identifiers do not need to match the user-facing labels. Existing identifiers should remain stable where required for backward compatibility. + +In particular: + +- Internal `csv` remains supported while displaying as `Spreadsheet`. +- Legacy `archive` requirements continue to map to the approved archive / `zip` policy. +- Existing task definitions must not require migration simply to improve display terminology. + +## Task Submission Limits + +Current behaviour: + +- The number of submitted files must exactly match the task definition's configured `upload_requirements`. +- Each file is validated independently by the API. +- File size is controlled by `Doubtfire::Application.config.max_file_size`. +- The existing fallback when no valid configured value is present is 10 MB per file. + +Proposed policy: + +- Preserve the configured per-file limit model. +- Retain 10 MB as the safe fallback. +- Require exactly one uploaded file for each configured upload requirement. +- Extra or missing files are rejected. +- Archive uploads remain subject to additional expanded-size, entry-count and compression-ratio limits. + +## Task Chat Limits + +Current behaviour: + +- The frontend permits multiple file selection. +- Each accepted file is confirmed and posted separately. +- The API enforces a 30 MB maximum per attachment. +- No explicit frontend attachment-count cap was found. + +Proposed policy: + +- Retain 30 MB as the maximum per task-chat attachment. +- Introduce a maximum of 5 attachments per user selection as an implementation safeguard, subject to reviewer approval. +- Each selected attachment continues to become its own task comment and receives independent API validation. +- The count limit is a usability and abuse-control measure and does not replace server-side file validation. + +## High-Risk Format Decisions + +### Macro-enabled documents + +Reject macro-enabled formats such as DOCM and XLSM. They are not part of the current approved extension path and introduce executable macro content that is unnecessary for the common MVP use cases. + +### Encrypted files + +Reject encrypted PDFs, Office packages and archives. The API cannot inspect encrypted contents reliably, so accepting them would bypass content validation. + +### Archives + +Archives are permitted only for task submissions that explicitly require them. Supported formats are ZIP, TAR, TAR.GZ and TGZ. Nested archives, encrypted entries, unsafe paths and symbolic links remain prohibited. + +### Executables and installers + +Reject executable and installer formats including EXE, MSI and similar binary packages. They are outside the educational upload MVP and create unnecessary execution risk. + +### Disk images + +Reject ISO, DMG and similar disk-image formats. They are not required for the common task-submission or task-chat use cases. + +### Unknown formats + +Do not provide an arbitrary extension override. New formats must be added through an approved policy change backed by a real teaching or assessment requirement. + +## Preview Policy + +### Inline or specialised preview + +- Image: inline image rendering. +- Audio: controlled audio player in task chat. +- PDF: dedicated PDF viewer for submitted files. +- Code and text: Monaco-based text/code preview where supported. + +### Metadata-only confirmation + +- PDF in the current task-chat upload confirmation dialog is represented by filename and PDF indicator rather than rendering its contents. + +### Download-only + +- Spreadsheet files. +- DOCX documents. +- Archives. +- Specialist approved formats without an explicitly approved renderer. + +A format must not be rendered generically merely because a browser may support it. Preview requires an intentional application-controlled rendering path. + +## Acceptance Rule + +Passing frontend filtering does not mean a file is accepted. + +Every upload must still pass API-side checks for the applicable category, including extension, MIME type, structure, encryption status, archive safety, size and authorisation before it is stored. + +# FILE-A01 Frontend and API Upload Policy Contract + +## 1. Authority + +The Doubtfire API is the final authority for every upload decision. + +Frontend checks exist to improve usability by showing appropriate file categories, picker filters, limits and validation messages. A file passing a frontend check does not mean that it is accepted. + +The API must independently validate every uploaded file before permanent storage. + +Server-side validation must include, where applicable: + +- authorisation +- upload context +- configured upload requirement +- approved category +- file extension +- MIME type +- structural validation +- encryption status +- archive safety +- file size +- attachment count or requirement count + +A frontend decision must never override an API rejection. + +## 2. Policy Source + +The frontend must not maintain an independently evolving security allowlist. + +The approved policy should be owned by the API or by a single versioned policy definition consumed by the API. + +A later implementation ticket should expose the frontend-safe portion of that policy through an API policy response or equivalent generated contract. + +The frontend may use that contract to populate: + +- category display names +- accepted file-picker extensions +- upload-context availability +- maximum file size +- maximum attachment count +- preview capability +- user-facing guidance + +The frontend must treat this information as guidance only. Server-side validation remains mandatory. + +## 3. Proposed Frontend-Safe Policy Shape + +A future policy response should expose only information required by the client, for example: + +```json +{ + "version": "1", + "contexts": { + "task_submission": { + "categories": ["spreadsheet", "pdf", "document", "code", "image", "archive"], + "default_max_file_size": 10000000 + }, + "task_chat": { + "categories": ["pdf", "image", "audio"], + "max_file_size": 30000000, + "max_selection_count": 5 + } + } +} +``` + +Category information may include: + +- stable internal identifier +- display name +- approved extensions +- context availability +- preview mode + +Security implementation details do not need to be exposed to the browser. + +## 4. Stable Internal Identifiers + +Existing task definitions must remain compatible. + +Current identifiers should be preserved where changing them would require migration. + +Examples: + +- Internal `csv` continues to represent the spreadsheet upload category. +- The user-facing label for `csv` is `Spreadsheet`. +- Existing `archive` requirements continue to resolve to the approved `zip` / archive policy. +- Existing `document`, `code`, `image` and `zip` requirements continue to work. + +Display terminology may improve without rewriting stored task definitions. + +## 5. Filename Rules + +User-provided filenames are untrusted input. + +The API must: + +- remove directory components before using a supplied filename +- reject or replace unsafe path characters +- remove control characters and header-injection material +- normalise invalid UTF-8 safely +- use a safe fallback name when the supplied name is blank or unsafe +- limit human-readable display names to 255 characters +- avoid using the original filename as a server storage path + +Server-managed or ID-based storage names should be used wherever possible. + +The original safe display name may be retained for user-facing download metadata where appropriate. + +## 6. Display-Name Rules + +The interface should use clear user-facing names: + +- Spreadsheet +- PDF +- Document +- Code +- Image +- Audio +- Archive + +Display names are presentation metadata and must not determine whether the API accepts a file. + +## 7. Error Contract + +Upload rejection should use consistent machine-readable error codes together with safe user-facing messages. + +Proposed error codes: + +- `UPLOAD_UNAUTHORISED` +- `UPLOAD_EMPTY` +- `UPLOAD_TOO_LARGE` +- `UPLOAD_COUNT_MISMATCH` +- `UPLOAD_EXTENSION_NOT_ALLOWED` +- `UPLOAD_MIME_INVALID` +- `UPLOAD_CORRUPT` +- `UPLOAD_ENCRYPTED` +- `UPLOAD_ARCHIVE_UNSAFE` +- `UPLOAD_ARCHIVE_LIMIT` +- `UPLOAD_CATEGORY_NOT_ALLOWED` +- `UPLOAD_UNKNOWN_POLICY` + +User-facing messages should explain the corrective action without exposing sensitive server implementation details. + +Example: + +`UPLOAD_TOO_LARGE` -> `The selected file exceeds the permitted upload size.` + +## 8. Logging Rules + +Server logs should record enough information to diagnose rejected uploads without recording file contents. + +Appropriate logging includes: + +- validation stage +- rejection reason/error code +- upload context +- category requested +- authenticated user identifier where operationally required +- task/project identifiers where required +- processing failures +- cleanup failures + +Logs must not include: + +- uploaded file contents +- authentication tokens +- unnecessary sensitive user information + +Expected validation failures should normally use appropriate debug or structured operational logging. Unexpected processing failures should be logged as errors. + +## 9. Temporary File and Cleanup Rules + +Rejected uploads must not be moved into permanent student-work storage. + +Temporary files created during validation, compression or conversion must be removed when processing completes or fails. + +Processing should: + +1. receive the upload in temporary storage +2. perform authorisation and policy checks +3. validate the file +4. perform required safe processing +5. move only accepted output into managed storage +6. remove temporary artefacts + +Existing cleanup behaviour using `FileUtils.rm_f`, `FileUtils.rm_rf`, Tempfile cleanup and server-managed working directories should be preserved. + +## 10. Backward Compatibility + +The approved policy applies to new upload decisions. + +Existing task definitions must continue to function using their stored upload requirement identifiers. + +Previously accepted attachments must not be retrospectively deleted or made inaccessible solely because the approved policy changes. + +Compatibility rules: + +- preserve existing internal identifiers where possible +- preserve `csv` as the spreadsheet-category identifier +- continue mapping legacy `archive` requirements to the archive policy +- preserve authorised access to existing stored attachments +- apply current validation policy when a user uploads or replaces a file +- do not require migration solely to change a display label +- document any future category removal before enforcement + +If an older attachment uses a format no longer approved for new uploads, authorised users may retain download access unless a separate security decision requires quarantine or removal. + +## 11. Implementation Ownership + +Later frontend tickets should implement: + +- policy-driven picker filters +- category labels +- client-side size/count guidance +- preview behaviour +- accessible validation messaging + +Later backend tickets should implement: + +- authoritative policy representation +- policy response/contract +- server-side category enforcement +- consistent error codes +- size/count enforcement +- security validation + +Later deployment and test tickets should verify: + +- configured limits +- policy availability +- rejection behaviour +- storage isolation +- cleanup behaviour + +FILE-A01 defines the policy. It does not make these production changes. + + +# FILE-A01 Code Category Review + +## Repository Evidence + +The API repository confirms that `code` is an established task-upload requirement. + +Evidence includes: + +- API comment tests using a `code` upload requirement +- unit API tests using Code-file requirements +- unit factory data using an Imported Code requirement +- submission-history tests using `main.rb` +- task-definition tests using `main.rb` +- similarity cleanup tests using a Code source requirement + +The strongest concrete extension-level evidence found in the repository is Ruby through repeated `main.rb` examples. + +The repository does not provide enough real unit-definition data to demonstrate that every extension in the current Code allowlist is actively required by teaching units. + +## FILE-A01 Decision + +For the MVP: + +- preserve the existing curated Code allowlist for backward compatibility +- do not introduce arbitrary extensions +- do not expand the Code allowlist solely because a file is text-based +- require a documented teaching or assessment use case before adding new extensions +- treat removal of existing extensions as a compatibility-sensitive change requiring separate review +- continue to require API-side extension and MIME validation + +FILE-A01 therefore does not claim that every existing Code extension has been independently justified by current unit evidence. + +The policy establishes a maintainable rule: preserve current compatibility now, then make future additions or removals through reviewed evidence rather than uncontrolled allowlist growth. + + +# FILE-A01 Data Flow and Trust Boundaries + +```mermaid +flowchart TD + U[Student or Staff User] + FE[Angular Frontend] + API[Authenticated Doubtfire API] + AUTH[Authorisation Check] + REQ[Upload Requirement Matching] + VAL[FileHelper Validation] + TMP[Temporary Processing Area] + STORE[Student Work Storage] + VIEW[Authorised Preview or Download] + DENY[Reject Request] + + U -->|Select file or attachment| FE + FE -->|Multipart upload request| API + + subgraph TB1["Trust Boundary 1 - User Device / Browser"] + U + FE + end + + subgraph TB2["Trust Boundary 2 - Doubtfire Application / API"] + API + AUTH + REQ + VAL + TMP + end + + subgraph TB3["Trust Boundary 3 - Managed Student Work Storage"] + STORE + end + + API --> AUTH + AUTH -->|Authorised| REQ + AUTH -->|Not authorised| DENY + REQ --> VAL + VAL -->|Rejected extension, MIME, structure, encryption or size| DENY + VAL -->|Accepted| TMP + TMP --> STORE + STORE --> VIEW + VIEW -->|Authorised response| FE +``` + +## Submission Upload Flow + +1. The user selects the files required by the task definition. +2. Angular sends the files to `/projects/{projectId}/task_def_id/{taskDefinitionId}/submission`. +3. The API checks `authorise?(current_user, project, :make_submission)`. +4. `scope_files(...)` ensures the submitted files match the configured upload requirements. +5. `task.accept_submission(...)` processes the submission. +6. Each file is checked by `FileHelper.accept_file(...)`. +7. Extension, MIME type, structure, encryption, archive safety and size rules are enforced server-side. +8. Accepted files pass through temporary processing before being placed in managed student-work storage. +9. Stored files are exposed only through authorised preview or download paths. + +## Task Chat Attachment Flow + +1. The user selects image, audio or PDF attachments in the task-comment composer. +2. The frontend filters supported selections and provides appropriate confirmation or preview behaviour. +3. The attachment is sent through the task-comment API. +4. The API enforces the 30 MB per-file attachment limit. +5. `FileHelper.accept_file(..., 'comment_attachment')` makes the final format decision. +6. Accepted attachments are stored in server-managed, ID-based paths. +7. Authorised users can later retrieve the attachment. + +## Trust Decisions + +- Frontend validation is advisory and exists for usability. +- The API is the final authority for every upload decision. +- User-provided filenames are not trusted as storage paths. +- Uploaded content is not trusted based on file extension alone. +- MIME and structural validation occur inside the trusted API boundary. +- Managed student-work storage is separated from the browser boundary. +- Rejected files must not proceed into managed student-work storage. +- Existing authorisation rules continue to control upload and retrieval access. + + +# FILE-A01 Security and Test Acceptance Requirements + +## Security Acceptance Requirements + +The implementation that follows FILE-A01 must satisfy all of the following requirements. + +### Authorisation + +- Every upload endpoint must require the existing authenticated user context. +- Task submissions must continue to enforce the existing `:make_submission` authorisation rule. +- Attachment retrieval must remain subject to the existing project and task access controls. +- Client-side validation must never substitute for server-side authorisation. + +### File-Type Validation + +- Every new upload must be checked against an explicit approved category. +- Extension checks and MIME checks must both be applied where supported. +- A valid extension alone is not sufficient for acceptance. +- Unknown or arbitrary file categories must be rejected. +- Macro-enabled Office formats must remain excluded unless separately approved. +- Executables, installers and disk-image formats must be rejected. + +### Document Validation + +- PDF files must be structurally readable before acceptance. +- Corrupt PDFs must be rejected. +- Encrypted PDFs must be rejected. +- DOCX files must be validated as genuine OOXML packages. +- Required DOCX package entries must be present. +- Encrypted DOCX content, unsafe paths and link entries must be rejected. + +### Archive Validation + +Approved archive uploads must: + +- use an explicitly supported archive format +- contain at least one file +- reject unsafe internal paths +- reject nested archives +- reject encrypted entries +- reject symbolic links +- enforce an entry-count limit +- enforce a total uncompressed-size limit +- enforce a compression-ratio limit + +The existing default protections of 1,000 entries, 10 times the configured upload size for total uncompressed content, and 100:1 compression ratio should be preserved unless separately reviewed. + +### Size and Count Limits + +Task submissions: + +- must contain exactly the files required by `upload_requirements` +- must reject missing or additional files +- must enforce the configured per-file maximum +- must retain 10 MB as the fallback when no valid configured maximum exists + +Task chat: + +- must enforce 30 MB per attachment +- should enforce the approved attachment-selection count once finalised +- must validate every selected attachment independently + +### Filename and Storage Safety + +- Original filenames must not be used as trusted storage paths. +- Directory components must be removed. +- Control characters and unsafe filename characters must be removed or replaced. +- Invalid UTF-8 must be handled safely. +- Display names must be limited to 255 characters. +- Server-managed or ID-based storage paths should be used. +- Rejected uploads must not reach permanent student-work storage. + +### Logging and Cleanup + +- Validation failures must produce a useful server-side reason. +- Unexpected processing failures must be logged as errors. +- Logs must not contain uploaded file contents or authentication secrets. +- Temporary files must be removed after successful processing or failure. +- Partial processing artefacts must not remain indefinitely. + +## Functional Test Acceptance Requirements + +### Task Submission Tests + +Verify that: + +1. CSV is accepted when a Spreadsheet requirement permits it. +2. XLS and XLSX are accepted under the Spreadsheet category. +3. The existing internal `csv` requirement still displays as Spreadsheet. +4. Approved Code formats remain accepted. +5. Unsupported Code extensions are rejected. +6. Valid PDF submissions are accepted. +7. Corrupt PDFs are rejected. +8. Encrypted PDFs are rejected. +9. Valid images are accepted where Image is required. +10. Valid approved archives are accepted where Archive is required. +11. Nested archives are rejected. +12. Encrypted archives are rejected. +13. Archives containing unsafe paths are rejected. +14. Files above the configured size limit are rejected. +15. Missing or extra files produce an upload-requirements mismatch. +16. Legacy `archive` requirements continue to resolve to the archive policy. + +### Task Chat Tests + +Verify that: + +1. Approved images can be selected and uploaded. +2. Approved audio can be selected and uploaded. +3. PDF attachments can be selected and uploaded. +4. Unsupported file types are rejected. +5. Attachments above 30 MB are rejected by the API. +6. Multiple selected attachments are processed individually. +7. Each attachment receives independent API validation. +8. Image confirmation displays an image preview. +9. Audio confirmation provides an audio player. +10. PDF confirmation displays filename/type metadata without unsafe generic rendering. + +### Security Regression Tests + +Verify rejection of: + +- `.exe` +- `.msi` +- `.iso` +- `.dmg` +- `.docm` +- `.xlsm` +- encrypted PDF +- encrypted DOCX +- encrypted archive +- nested archive +- archive path traversal +- archive symbolic link +- corrupted PDF +- invalid DOCX package +- mismatched MIME and extension where detectable + +### Preview Tests + +Verify that: + +- code/text preview uses the controlled Monaco path +- submitted PDFs use the dedicated PDF viewer +- images use controlled image rendering +- task-chat audio uses the audio player +- formats without an approved renderer display a no-preview state +- download-only formats are never passed into an arbitrary generic renderer + +### Backward-Compatibility Tests + +Verify that: + +- existing task definitions using `csv` continue to function +- `csv` displays as Spreadsheet +- existing `archive` aliases continue to resolve correctly +- existing stored attachments remain retrievable by authorised users +- changing display labels does not require rewriting task definitions +- new policy enforcement applies to new uploads without silently deleting historical attachments + +## Contract Tests + +The later implementation should include tests confirming that: + +- frontend policy data matches the authoritative API contract +- frontend picker restrictions are derived from the approved policy +- the frontend cannot override API rejection +- policy changes can be versioned without breaking existing clients +- missing or invalid policy data fails safely + +## Completion Standard + +A later implementation ticket is not complete until: + +- approved formats succeed in their intended contexts +- excluded formats fail predictably +- size and count limits are enforced +- security checks cannot be bypassed from the frontend +- existing task definitions remain compatible +- temporary processing is cleaned up correctly +- required automated tests pass + + +# FILE-A01 Stakeholder and User-Request Mapping + +## Purpose + +This mapping connects the proposed upload policy to the people who use, implement, secure and maintain the feature. + +## Student Needs + +### Task submissions + +Students need to upload the common file formats required by assessment tasks without having to understand internal Doubtfire file-category names. + +Required common use cases: + +- submit PDF reports +- submit CSV and Excel spreadsheets +- submit source-code files for programming tasks +- submit images where evidence or visual work is required +- submit approved archives where a task explicitly requires multiple related files +- submit DOCX documents where the task explicitly requires a Word document + +Policy response: + +- use clear display names such as Spreadsheet, PDF, Document, Code, Image and Archive +- preserve existing task-definition compatibility +- reject files with a clear reason when they do not meet the approved policy + +### Task chat + +Students need to provide supporting evidence or communicate with teaching staff using lightweight attachments. + +Required common use cases: + +- attach screenshots or other approved images +- attach PDF evidence +- attach or record audio + +Policy response: + +- task chat has a smaller approved format set than task submissions +- task-chat attachments remain independently validated by the API +- images and audio use controlled previews +- PDF attachments use safe metadata/controlled viewing behaviour + +## Teaching Staff Needs + +Teaching staff need: + +- predictable file categories when configuring task requirements +- confidence that student uploads match the task definition +- continued access to previously accepted submissions and attachments +- clear filenames and download behaviour +- safe preview paths for common formats +- actionable error information when uploads are rejected + +Policy response: + +- internal identifiers remain stable for compatibility +- the API remains authoritative +- existing task definitions continue to work +- historical attachments are not invalidated solely because policy changes + +## Frontend Team Needs + +The frontend team needs: + +- one approved set of display names +- approved extensions for picker guidance +- context-specific size/count information +- preview capability information +- predictable error codes from the API + +Policy response: + +- frontend policy data should come from an API-owned or single versioned contract +- the frontend must not maintain an independently evolving security allowlist +- frontend filtering remains advisory + +## Backend Team Needs + +The backend team needs: + +- authoritative category definitions +- explicit extension and MIME rules +- clear size and count limits +- consistent rejection behaviour +- backward-compatibility requirements +- storage and cleanup requirements + +Policy response: + +- API owns final acceptance decisions +- all uploads pass server-side validation +- consistent machine-readable errors should be introduced by later implementation tickets + +## Security Review Needs + +Security reviewers need explicit decisions for: + +- encrypted files +- macro-enabled documents +- executable files +- installers +- disk images +- archives and nested archives +- filename/path safety +- decompression limits +- temporary-file cleanup +- preview behaviour + +Policy response: + +- encrypted, macro-enabled, executable, installer, disk-image and arbitrary formats are excluded from the common MVP +- approved archives receive additional structural controls +- generic browser rendering is not treated as an approved preview mechanism + +## Documentation Team Needs + +Documentation needs: + +- stable user-facing category names +- clear accepted-format guidance +- clear size limits +- clear rejection guidance +- distinction between task submission and task-chat policies + +Policy response: + +Approved user-facing names are: + +- Spreadsheet +- PDF +- Document +- Code +- Image +- Audio +- Archive + +Documentation should describe user-facing categories rather than exposing legacy internal identifiers unless needed for technical documentation. + +## Objective Lead Needs + +The Objective 4 lead needs confirmation that: + +- the policy is implementable by later frontend/backend tickets +- production code is not changed by FILE-A01 +- cross-objective ownership remains respected +- MISC-X01 governs branch/shared-ownership decisions +- SLR-R01 remains separate from this upload-policy work +- unresolved decisions are clearly identified before implementation begins + +## Review Mapping + +The final FILE-A01 review should obtain acknowledgement from: + +| Reviewer | Review Focus | +|---|---| +| Frontend | picker policy, display names, previews, client contract | +| Backend | authoritative validation, limits, storage and API contract | +| Security | exclusions, archive safety, filename handling, cleanup | +| Documentation | terminology and user guidance | +| Objective lead | scope, ownership, approval and implementation readiness | + +## Outstanding Review Decisions + +The following items require reviewer agreement before the policy is considered final: + +1. Whether 5 attachments per task-chat selection is the approved count limit. +2. Whether DOCX should be part of the common task-submission MVP or remain available only where explicitly required. +3. Whether the current Code extension list should be reduced before implementation or preserved initially and reviewed separately. +4. Whether an API policy endpoint or generated shared policy contract is preferred. +5. Location of the documentation-team notes referenced by the FILE-A01 card. +6. Final branch/repository location under the MISC-X01 shared-ownership process. + diff --git a/docs/english-only-submission-notice.md b/docs/english-only-submission-notice.md new file mode 100644 index 0000000000..68169dd27a --- /dev/null +++ b/docs/english-only-submission-notice.md @@ -0,0 +1,21 @@ +# English-Only Assessment Submission Notice + +## Proposed Notice + +All assessment submissions must be written and submitted in English. Translation tools may be used to help understand the assessment instructions, but the final submitted work must remain in English. + +## Recommended Placement + +The notice should appear in the following locations: + +1. **Assessment task details page** — near the assessment requirements. +2. **Submission upload page** — above the upload or submit button. +3. **Final submission confirmation window** — before the student confirms the submission. + +## Behaviour When Translation Is Enabled + +The notice should remain visible in English when the language or translation option is active. Translation must not hide or change the requirement that the final assessment submission must be in English. + +## Scope + +This document provides the proposed wording and recommended placement only. No application or interface changes have been implemented. diff --git a/docs/safe-upload-and-chat-guide.md b/docs/safe-upload-and-chat-guide.md new file mode 100644 index 0000000000..06931e260b --- /dev/null +++ b/docs/safe-upload-and-chat-guide.md @@ -0,0 +1,114 @@ +# Task uploads and chat attachments + +This guide covers the companion Safe Uploads changes under review. They become +available after the web and API PRs are reviewed and merged. It builds on +FILE-A01 by Sandil Sithmaka Bandara Weganthale and the existing FILE-F01 upload +requirements component. The earlier design matrix remains a design record; +this guide records the implemented boundaries and corrects audit assumptions. + +## Students and staff + +Before choosing a task submission, read its required categories, exact formats, +file count and size limit. Expand the list for Code. Provide one file for each +configured requirement. Spreadsheet means CSV, XLS or XLSX and keeps the original +file and all worksheets for download. The PDF contains a download notice. Code +uses the existing curated extension list; rename tricks do not bypass checks. +The task's PDF category means PDF. PostScript was advertised by the browser but +rejected by the API, so it is no longer advertised as accepted. + +In task chat, requirements appear above the composer before selection. Use +**Attach a file** or drop a file onto the discussion. The server supplies the +exact formats. Document accepts DOCX; Spreadsheet accepts CSV or XLSX. Images, +PDF and supported audio remain available. Each attachment must be smaller than +30 MB (30,000,000 bytes), and you may select up to five at once. + +Review the name, category and size, then **Post Attachment**. **Cancel** removes +that selection and leaves written text unchanged. Each file is posted as its +own comment. Your written draft is separate: use Send when ready to post it. +Uploading status appears while requests are pending. A failure keeps the draft; +choose the file again to retry. Policy errors explain why the server refused it. +A proxy size rejection asks you to use a smaller file. + +Paste a screenshot while typing to enter the same image confirmation flow. +Only approved raster images are accepted from the clipboard. Ordinary text paste +still works. Duplicate paste/beforeinput browser events produce one confirmation. + +Documents and spreadsheets appear as download cards with the filename and size. +**Download** uses the authenticated task endpoint. Office documents are never +rendered as active content in OnTrack. Existing attachments remain accessible to +authorized users. A removed or unavailable file produces an error, not access to +another task's file. + +| Message / situation | Action | +| -------------------------------------- | ------------------------------------------------------------------------------- | +| Requirements unavailable | Reload and try again; text comments still work | +| Unsupported format | Save in one of the formats listed before selection | +| Empty file | Open and save the file again; check it contains data | +| File too large | Reduce size below the displayed limit | +| Malformed, encrypted or active content | Remove password protection/macros/embedded objects; export a clean copy | +| Legacy XLS in chat | Save as CSV or macro-free XLSX; task Spreadsheet requirements still accept XLS | +| Permission or missing-file error | Check the current task and contact teaching staff if access should be available | + +## Unit chairs + +In the task editor's upload requirements, choose a category rather than entering +extensions: PDF, Code, Image, Spreadsheet or Archive. Spreadsheet stores the +stable `csv` identifier and displays CSV/XLS/XLSX to students. Existing `archive` +values retain their meaning. No saved-task migration is needed. + +The previous audit found a browser CSV/XLS/XLSX list and assumed the task pipeline +already supported it. The API actually omitted `csv` from category validation +and archive handling. The companion API PR completes these paths. It retains +spreadsheet originals and does not silently convert a workbook to its first sheet. + +## Contributors and reviewers + +- API authority: `app/helpers/comment_attachment_policy.rb`, + `spreadsheet_upload_policy.rb`, `file_helper.rb`, and `task_comments_api.rb`. +- Browser policy interface: `src/app/api/models/task-comment/attachment-policy.ts`. +- Composer owns picker, drop and clipboard confirmation. The viewer delegates + drops to that flow so there is no second permissive allowlist. +- `file-upload-types.ts` owns task browser categories; `upload-category.ts` + supplies requirement labels. The unit-chair selector uses the same stable keys. +- API validates bytes independently; browser MIME is not trusted. Office files + undergo ZIP/XML, active-content and external-content checks and remain download-only. +- Task size: configured API limit, 10,000,000-byte fallback, inclusive. Chat size: + strictly below 30,000,000 bytes. The production proxy default remains 1g; see + [deploy PR 37](https://github.com/ontrack-features-t2-2026/doubtfire-deploy/pull/37) + for finite-limit validation, JSON 413 and real API boundary probe commands. + +To change a format, first update and review the API policy and validators. Add +success, renamed-content, malformed, size, authorization and safe-download tests. +The browser automatically receives chat picker extensions. For task categories, +update both the API and browser contract and verify original archive retention. +Do not add arbitrary unit-chair extension entry. New formats need a teaching use +case and a security decision; a browser MIME string is not that decision. + +Run with the repository's supported Node version (22.22.3 or newer): + +```sh +npm run test:ci -- --include='src/app/tasks/task-comment-composer/**/*.spec.ts' --include='src/app/tasks/task-comments-viewer/task-comments-viewer.component.spec.ts' --include='src/app/tasks/modals/upload-submission-modal/task-upload-requirements/*.spec.ts' --include='src/app/api/services/spec/task-comment.service.spec.ts' +npm run typecheck +npm run lint +npm run build -- --configuration development +``` + +The API regression matrix is in its `docs/uploads/safe-upload-contract.md`. +Tests cover clipboard duplication separately from file selection, failure draft +preservation, exclusion/size/count checks, drag/drop delegation and requirements. +API tests cover upload/download policy, cleanup, safe headers and access denial. + +Repeat these browser checks at desktop and a narrow mobile width before release: + +1. Create a Spreadsheet requirement and confirm CSV/XLS/XLSX in student guidance. +2. Select, drop and paste an image; confirm exactly once and cancel without losing text. +3. Select DOCX, CSV and XLSX, post, and download from an authorized account. +4. Try excluded, empty and oversize files; verify clear feedback and unchanged draft. +5. Tab through Attach, confirmation Cancel/Post and Download; verify visible focus, + accessible names and readable requirements at 200% zoom and narrow width. +6. Confirm images/audio/PDF and text-only comments retain their existing behavior. + +No malware scanning, cloud-drive links, active Office preview, chat XLS, macro +files, encrypted packages, executables or arbitrary archives are added. Existing +FILE-S01 follow-ups about aggregate quotas and worker recovery remain explicit. +Non-GitHub evidence collection, leadership approval and merging are outside this PR. diff --git a/docs/submission-lifecycle/SLR-E01-policy-proposal.md b/docs/submission-lifecycle/SLR-E01-policy-proposal.md new file mode 100644 index 0000000000..6b255035c1 --- /dev/null +++ b/docs/submission-lifecycle/SLR-E01-policy-proposal.md @@ -0,0 +1,118 @@ +# SLR-E01: proposed post-feedback deadline rule + +**Status: Proposed; awaiting stakeholder approval.** This is a decision input, not +approved policy, an implementation specification, or evidence that SLR-E01 is complete. +Merging this document does not authorise changes to student deadlines. + +This readable version preserves the proposal contributed by ReneeSleepy in +[web #248](https://github.com/ontrack-features-t2-2026/doubtfire-web/pull/248). +The [original PDF](https://github.com/ontrack-features-t2-2026/doubtfire-web/blob/53f6d11340eba4d53e245184507ff3f91743761b/SLR-E01%20.pdf) +remains in that commit for provenance. It ended partway through its acceptance list; +the complete candidate checks and unresolved decisions are recorded below. + +## Existing implementation and duplication check + +The API already implements a resubmission extension. Reuse its named methods and +[existing rule record](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/bb360dfa/docs/submission-lifecycle/effective-resubmission-deadline.md) +before considering new work. This was checked against API `11.0.x` at `bb360dfa` +on 20 September 2026; API #94 is already included in that history. + +| Area | Existing API behavior | Original proposal below | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| Eligible statuses | Fix and Resubmit, Discuss, Rediscuss, Demonstrate | Same four statuses | +| Eligibility window | Effective deadline less than seven calendar days after assessment | Every eligible feedback event, without that window | +| New date | Adds configured `extension_weeks_on_resubmit_request` to the existing deadline, subject to limits | Seven calendar days after feedback was recorded | +| Repeat handling | Guard targets one extension per submission round; known concurrency gaps remain | Each new eligible feedback event may recalculate | +| Flexible dates | Excluded by existing extension eligibility | Proposed to participate | +| Time zone | Student campus zone, falling back to application zone; effective deadline uses end-of-day anywhere on earth | Described only as the task/unit system time zone | +| Configuration | Unit-level extension configuration | Proposed task-level opt-out, enabled by default | + +These are material policy differences, not missing frontend features. The existing +API record also identifies concurrency, transaction and group-comment risks; this +document does not claim to fix them. No API, web behavior, mobile behavior, deploy +configuration, migration or notification delivery changes are included here. + +## Original proposed rule + +When feedback is recorded with **Fix and Resubmit**, **Discuss**, **Rediscuss**, or +**Demonstrate**, calculate a candidate deadline seven calendar days after the +recorded feedback timestamp. Do not calculate the candidate by adding seven days +to the old deadline or by adding a fixed 168 hours. + +The author's proposed interactions are: + +- Preserve any later approved extension rather than reducing it. +- Include tasks with flexible dates. +- Respect final and maximum dates; an authorised exception to a final deadline + requires explicit approval. +- Recalculate for each genuinely new eligible feedback event, including another + event before the current extended deadline. +- Apply a consistent result to the relevant group submission and its member tasks. +- Enable the behavior for existing and new tasks, with an opt-out available to + authorised staff at task level. +- Apply only to future eligible feedback. Neither deployment, setting changes nor + old feedback should automatically rewrite historical deadlines. +- Preserve manually authorised changes and explain the actual resulting deadline + to the student, including any limiting final/maximum date. + +The original suggested message was: "Your task deadline has been extended to +[date and time] because you received feedback requiring further action." +It is suitable only when the deadline actually moves later. Wording for no change, +a cap, or an opt-out needs acceptance alongside the final rule. + +## Decisions required before implementation + +| Decision | Why it remains open | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Deadline precedence | Early feedback plus seven days may be earlier than the original deadline. Decide explicitly whether the original deadline, a manual deadline, an approved extension, and an automatic date may ever be shortened. A possible no-shortening rule is a proposal, not an approved formula. | +| Conflicting caps | A later approved extension can exceed a stated final/maximum date. Decide which authority wins and what exception record is required; do not silently truncate an approved extension. | +| Authoritative time zone | Choose campus, unit or another named IANA zone and a fallback. Define whether the result is a wall-clock instant or an end-of-day date; current API semantics must not be changed implicitly. | +| DST transitions | Define the handling of nonexistent or repeated local times, in addition to using calendar days. Include forward and backward clock changes in tests. | +| Feedback identity | Define a genuinely new event versus a retry, re-save, repeated status change, or resubmission round. Decide whether the current one-extension-per-round rule changes. Duplicate delivery must remain idempotent. | +| Flexible and group tasks | Confirm the flexible-date change and how per-member deadlines, campus zones and manual extensions interact for groups. | +| Settings and rollout | Confirm default-on behavior, staff permissions, unit-level configuration interaction, effective start time and treatment of existing extension records. | +| Communication | Decide the authoritative API fields and whether the explanation is a discussion entry, in-app notice, push/email, or a combination. A visible message does not prove notification delivery. | + +No stakeholder approval was supplied with this PR. The authorised policy owner +must record their name/role, decision date, chosen rules, rationale and approval +reference before SLR-E02 to SLR-E05 can treat this as an accepted contract. + +## Worked examples from the proposal + +All dates below illustrate the proposed local-calendar calculation. The zone and +deadline precedence still require the decisions above; these are not assertions +about current API output. + +| Case | Input | Proposed candidate / result | +| -------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| Normal | Original 20 Sep 2026 17:00; Fix and Resubmit recorded 15 Sep 14:00 | 22 Sep 14:00, before applying any authorised caps | +| Feedback after original deadline | Original 15 Sep 17:00; Discuss recorded 16 Sep 10:00 | 23 Sep 10:00, before caps | +| New feedback event | First feedback 10 Sep 09:00; next eligible event 14 Sep 15:00 | Candidates 17 Sep 09:00 then 21 Sep 15:00; a replay of either event is not a new event | +| Approved extension | Approved extension dated 25 Sep; feedback 18 Sep 10:00 | Candidate 25 Sep 10:00; the approved extension's exact time/zone is missing, so the later value cannot be selected yet | +| Task opt-out | Setting disabled; feedback 18 Sep 10:00 | No automatic deadline change under the proposal | + +Add an early-feedback example (original 30 September, feedback 15 September, +candidate 22 September) to the policy decision: blindly replacing the original +would shorten the available time by eight days. + +## Candidate acceptance checks after policy approval + +- Each eligible outcome follows the accepted formula; ineligible outcomes do not + change the deadline. +- Early, on-time and late feedback follow the explicit precedence rules. +- Later approved/manual deadlines and final/maximum caps follow the recorded + authority decision, including a conflict between a later extension and a cap. +- A genuinely new event follows the repeat policy; retries, duplicate delivery, + concurrent assessments and failed transactions do not stack extensions. +- Spring/autumn DST, missing time zones, and cross-zone group members produce the + agreed date and offset; seven calendar days are not assumed to be 168 hours. +- Flexible dates, group propagation, unit settings and task opt-out follow the + accepted configuration and permission rules. +- Deployment and configuration changes leave historical feedback/deadlines intact + unless a separately approved migration explicitly says otherwise. +- Desktop web and the installed mobile PWA show the same server result, including + a readable date/time/zone, accessible feedback explanation, and any cap/no-change + wording. Do not recalculate the policy separately in each client. + +Use synthetic accounts and dates for evidence. Link the exact API/web/deploy +revisions and test results after implementation; none are claimed by this proposal. diff --git a/docs/submission-lifecycle/student-history-and-deadline-controls.md b/docs/submission-lifecycle/student-history-and-deadline-controls.md new file mode 100644 index 0000000000..50f20427a5 --- /dev/null +++ b/docs/submission-lifecycle/student-history-and-deadline-controls.md @@ -0,0 +1,51 @@ +# Student submission lifecycle + +This implements SLR-H02/H03, the web part of SLR-E03/E04, and frontend regressions +for SLR-E05. It consumes the paired API change; deploy the API first for the new +setting and explanatory deadline metadata. + +- Task Details now includes Previous submissions, including when the current + submission has no PDF. A task never submitted shows an empty history without + issuing a request for a nonexistent Task. +- The view preserves backend version order and explicit availability, labels the + current version only when the API confirms it, and identifies the latest + retained archive. Unknown legacy timestamps have plain-language fallback text. +- Loading and processing states use a polite live region. Failed loads and + downloads have readable errors and a keyboard-operable refresh action. +- Unavailable versions have no download control. Available archives use the + existing authenticated HttpClient download service. Changing task clears the + old list and cancels its request; a late download is discarded after navigation. +- The UI exposes neither staff comparison controls nor internal archive paths. + Each row displays the autogenerated download name `submission-.zip` + as plain text, without exposing original filenames or storage paths. + The API still rechecks access on every download, including group membership + and substituted identifiers. +- Unit task Options contains Allow automatic post-feedback extensions with + future-only help text. The API's existing convenor/admin authorization is the + authority; normal students and tutors cannot save task-definition changes. +- Task Details shows the API's canonical effective date and reason, including + the anywhere-on-earth day-end convention. Flexible dates are labelled as the + student's planned submission date. No frontend deadline calculation is added. + +The already merged API E02 policy remains unchanged. The proposal in web PR #249 +is explicitly awaiting stakeholder approval and differs from that rule. This +setting therefore refers to the unit's existing resubmission weeks and fixed +date behavior rather than claiming an unapproved seven-days-from-feedback rule. + +The new view uses semantic ordered lists, native buttons, accessible download +names, Material theme surfaces and wrapping content without fixed widths. Theme +work may restyle those surfaces without changing history or deadline behavior. + +## Checks + +```sh +npm run typecheck +npm run lint +npm run test:ci -- --include='src/app/api/services/submission-history.service.spec.ts' --include='src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.spec.ts' --include='src/app/api/services/task-definition.service.spec.ts' --include='src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.spec.ts' +npm run build -- --configuration production +``` + +The API PR covers access controls, corrupt and removed files, group entitlement, +opt-out permissions, notification replay, deadline rollback and the existing +calendar feed. Human review and real calendar-client refresh sign-off remain +outside this code change. diff --git a/src/app/api/models/task-comment/attachment-policy.ts b/src/app/api/models/task-comment/attachment-policy.ts new file mode 100644 index 0000000000..41db0543e4 --- /dev/null +++ b/src/app/api/models/task-comment/attachment-policy.ts @@ -0,0 +1,43 @@ +export interface AttachmentCategory { + id: string; + name: string; + extensions: string[]; + preview: string; +} + +export interface AttachmentPolicy { + version: number; + max_bytes_exclusive: number; + max_selection_count: number; + categories: AttachmentCategory[]; +} + +export function attachmentCategory(policy: AttachmentPolicy, file: File): AttachmentCategory { + const extension = file.name.split('.').pop()?.toLowerCase(); + return policy?.categories.find((category) => category.extensions.includes(extension)); +} + +export function attachmentError(error: { + status?: number; + error?: unknown; + message?: string; +}): string { + if (error?.status === 413) { + return 'The upload is too large. Choose a smaller file and try again. Your draft is unchanged.'; + } + const details = error?.error; + if (typeof details === 'string' && !details.trim().startsWith('<')) { + return details; + } + if ( + details && + typeof details === 'object' && + 'error' in details && + typeof details.error === 'string' + ) { + return details.error; + } + return ( + error?.message || 'The attachment could not be uploaded. Your draft is unchanged; try again.' + ); +} diff --git a/src/app/api/models/task-comment/task-comment.ts b/src/app/api/models/task-comment/task-comment.ts index 90e83e6e72..62f0d45943 100644 --- a/src/app/api/models/task-comment/task-comment.ts +++ b/src/app/api/models/task-comment/task-comment.ts @@ -22,6 +22,9 @@ export class TaskComment extends Entity { recipientReadTime: string; commentType: string = 'text'; isNew: boolean; + attachmentFileName: string; + attachmentMimeType: string; + attachmentByteSize: number; replyToId: number; // Data calculated from the above @@ -32,7 +35,6 @@ export class TaskComment extends Entity { shouldShowAvatar: boolean = false; firstInSeries: boolean = false; lastRead: boolean = false; - hover?: boolean; /** * Create a new TaskComment @@ -56,7 +58,9 @@ export class TaskComment extends Entity { } public get isBubbleComment(): boolean { - return ['text', 'discussion', 'audio', 'image', 'pdf'].includes(this.commentType); + return ['text', 'discussion', 'audio', 'image', 'pdf', 'document', 'spreadsheet'].includes( + this.commentType, + ); } public get isStaffAuthored(): boolean { diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 823a5bb8dd..07d2b01f93 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -87,6 +87,8 @@ export class TaskDefinition extends Entity { public readonly overseerStepsCache: EntityCache = new EntityCache(); + resubmissionExtensionsEnabled = true; + readonly unit: Unit; constructor(unit: Unit) { diff --git a/src/app/api/services/submission-history.service.spec.ts b/src/app/api/services/submission-history.service.spec.ts new file mode 100644 index 0000000000..d9941ad28f --- /dev/null +++ b/src/app/api/services/submission-history.service.spec.ts @@ -0,0 +1,64 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {provideHttpClient} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import {Task} from '../models/task'; +import {SubmissionHistoryService} from './submission-history.service'; + +describe('Student submission history contract', () => { + let service: SubmissionHistoryService; + let http: HttpTestingController; + const task = {project: {id: 8}, definition: {id: 9}} as Task; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), SubmissionHistoryService], + }); + service = TestBed.inject(SubmissionHistoryService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + + it('keeps processing and explicit availability from the student response', () => { + service.queryStudentHistory(task).subscribe((result) => { + expect(result.processing).toBe(true); + expect(result.versions[0]).toEqual({ + id: 3, + versionOrder: 1, + timestamp: new Date(1750000000000), + current: true, + available: true, + }); + expect(result.versions[1].available).toBe(false); + expect(result.versions[1].timestamp).toBeNull(); + }); + http + .expectOne((request) => + request.url.endsWith('/projects/8/task_def_id/9/submission_histories'), + ) + .flush( + [ + { + id: 3, + version_order: 1, + submission_timestamp: '1750000000', + current: true, + status: 'available', + }, + {id: 2, version_order: 2, submission_timestamp: 'legacy', status: 'unavailable'}, + ], + {status: 202, statusText: 'Accepted'}, + ); + }); + + it('does not infer permission from an absent availability flag', () => { + service + .queryStudentHistory(task) + .subscribe((result) => expect(result.versions[0].available).toBe(false)); + http + .expectOne((request) => request.url.includes('/submission_histories')) + .flush([{id: 1, submission_timestamp: '1750000000'}]); + expect(service.studentArchiveUrl(task, 3)).toContain( + '/projects/8/task_def_id/9/submission_histories/3/files', + ); + }); +}); diff --git a/src/app/api/services/submission-history.service.ts b/src/app/api/services/submission-history.service.ts index e65d1053ce..8a5120abc3 100644 --- a/src/app/api/services/submission-history.service.ts +++ b/src/app/api/services/submission-history.service.ts @@ -1,18 +1,31 @@ import {EntityService} from 'ngx-entity-service'; import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; -import {Observable} from 'rxjs'; +import {Observable, map} from 'rxjs'; import {SubmissionHistory} from 'src/app/api/models/submission-history'; import {Task} from 'src/app/api/models/task'; import API_URL from 'src/app/config/constants/apiUrl'; +export interface StudentSubmissionVersion { + id: number; + versionOrder: number; + timestamp: Date | null; + current: boolean; + available: boolean; +} + +export interface StudentSubmissionHistory { + versions: StudentSubmissionVersion[]; + processing: boolean; +} + @Injectable() export class SubmissionHistoryService extends EntityService { protected readonly endpointFormat = 'projects/:project_id:/task_def_id/:td_id:/submission_histories/:id:'; - constructor(httpClient: HttpClient) { - super(httpClient, API_URL); + constructor(private historyHttp: HttpClient) { + super(historyHttp, API_URL); this.mapping.addKeys( 'id', @@ -32,6 +45,44 @@ export class SubmissionHistoryService extends EntityService { return new SubmissionHistory(task); } + public queryStudentHistory(task: Task): Observable { + return this.historyHttp + .get< + Array<{ + id: number; + version_order?: number; + submission_timestamp: string; + current?: boolean; + status?: string; + has_submission_files?: boolean; + }> + >( + `${API_URL}/projects/${task.project.id}/task_def_id/${task.definition.id}/submission_histories`, + { + observe: 'response', + }, + ) + .pipe( + map((response) => ({ + processing: response.status === 202, + versions: (response.body ?? []).map((version, index) => { + const timestamp = Number(version.submission_timestamp) * 1000; + return { + id: version.id, + versionOrder: version.version_order ?? index + 1, + timestamp: Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp) : null, + current: version.current === true, + available: version.status === 'available' || version.has_submission_files === true, + }; + }), + })), + ); + } + + public studentArchiveUrl(task: Task, versionId: number): string { + return `${API_URL}/projects/${task.project.id}/task_def_id/${task.definition.id}/submission_histories/${versionId}/files`; + } + public queryForTask(task: Task): Observable { return this.query( { diff --git a/src/app/api/services/task-definition.service.spec.ts b/src/app/api/services/task-definition.service.spec.ts new file mode 100644 index 0000000000..91e49120f0 --- /dev/null +++ b/src/app/api/services/task-definition.service.spec.ts @@ -0,0 +1,69 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {Unit} from '../models/unit'; +import {TaskDefinitionService} from './task-definition.service'; + +describe('TaskDefinitionService grade due-date mapping', () => { + let originalTimezone: string | undefined; + + beforeEach(() => { + originalTimezone = process.env.TZ; + process.env.TZ = 'America/New_York'; + }); + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + }); + + it('preserves nested grade dates serialized as UTC-midnight timestamps', () => { + const service = new TaskDefinitionService(null, null, null, null); + const taskDefinition = service.buildInstance( + { + grade_due_dates: [ + { + target_grade: 1, + target_due_date: '2026-09-15T00:00:00.000Z', + start_date: '2026-09-08T00:00:00.000Z', + }, + ], + }, + {constructorParams: new Unit()}, + ); + + const [gradeDate] = taskDefinition.gradeDueDates; + expect([ + gradeDate.targetDueDate.getFullYear(), + gradeDate.targetDueDate.getMonth(), + gradeDate.targetDueDate.getDate(), + gradeDate.targetDueDate.getHours(), + ]).toEqual([2026, 8, 15, 0]); + expect([ + gradeDate.startDate.getFullYear(), + gradeDate.startDate.getMonth(), + gradeDate.startDate.getDate(), + gradeDate.startDate.getHours(), + ]).toEqual([2026, 8, 8, 0]); + }); +}); + +describe('Task definition resubmission setting', () => { + it('keeps the default for older responses and maps a task-level opt out', () => { + const service = new TaskDefinitionService(null, null, null, null); + const unit = new Unit(); + expect(service.buildInstance({}, {constructorParams: unit}).resubmissionExtensionsEnabled).toBe( + true, + ); + const definition = service.buildInstance( + {resubmission_extensions_enabled: false}, + {constructorParams: unit}, + ); + expect(definition.resubmissionExtensionsEnabled).toBe(false); + definition.resubmissionExtensionsEnabled = true; + expect(definition.toJson(service.mapping)['task_def']['resubmission_extensions_enabled']).toBe( + true, + ); + }); +}); diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index ed24d68bff..2e213eebc0 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -40,6 +40,7 @@ export class TaskDefinitionService extends CachedEntityService { 'hasJplagReport', 'assessInPortfolioOnly', 'requiresDiscussion', + 'resubmissionExtensionsEnabled', { keys: 'targetDate', toEntityFn: MappingFunctions.mapDateToEndOfDay, @@ -169,10 +170,10 @@ export class TaskDefinitionService extends CachedEntityService { return (data[key] ?? []).map((gradeDate) => ({ targetGrade: gradeDate.target_grade, targetDueDate: gradeDate.target_due_date - ? MappingFunctions.mapDateToDay(gradeDate, 'target_due_date', null) + ? MappingFunctions.mapCivilDateToDay(gradeDate, 'target_due_date', null) : undefined, startDate: gradeDate.start_date - ? MappingFunctions.mapDateToDay(gradeDate, 'start_date', null) + ? MappingFunctions.mapCivilDateToDay(gradeDate, 'start_date', null) : undefined, })); }, diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.html b/src/app/common/edit-profile-form/edit-profile-form.component.html index a51b79fee2..dd97afe9d7 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.html +++ b/src/app/common/edit-profile-form/edit-profile-form.component.html @@ -1,7 +1,6 @@
@@ -21,57 +20,71 @@

{{ user?.firstName }}

-
- - +
+ Username - +
-
- +
+ First Name - + + @if (firstModel.hasError('required')) { + First name is required + } -
+
- + Second Name - + + @if (lastModel.hasError('required')) { + Second name is required + }
-
- - +
+ Preferred Name - +
- + Custom Pronouns {{ user?.firstName }} @if (user.systemRole === 'Student') { - + Student ID - + + @if (studentIdModel.hasError('required')) { + Student ID is required + } } - + Email - + + @if (emailModel.hasError('required')) { + Email is required + } + @if (emailModel.hasError('email')) { + Enter a valid email address + } @if (canSeeSystemRole) { - + System Role {{ user?.firstName }} } -
- Receive notifications for new messages -
- + +
+

Peer progress

+ Receive notifications when your portfolio is ready -
- + + This controls whether you see the privacy-protected widget below your submission details. + It does not opt your anonymous task status out of authorised cohort aggregation. + +
+ + + + +
+ + @if (pushBlocker !== null) { + {{ pushBlockerMessage }} + @if (pushBlocker === 'permission-denied') { +
    + @for (step of pushBlockerInstructions; track step) { +
  1. {{ step }}
  2. + } +
+ } + } @else if (pushSubscribed) { + This device will receive push notifications. + }
-
+
Send anonymous research statistics
@if (tiiEnabled) { -
+
{{ user?.firstName }}
} -
+
-
+
@if (mode === 'create') { } @if (mode === 'edit') {
@if (file?.name) { - upload_file + } {{ file?.name || message }} {{ uploadProgress }}
- @if (uploadProgress) { - + @if (uploading) { + + } - @if (!uploadProgress) { + @if (!uploading) { }
+

{{ uploadStatus }}

diff --git a/src/app/common/file-drop/file-drop.component.spec.ts b/src/app/common/file-drop/file-drop.component.spec.ts new file mode 100644 index 0000000000..f8a7500a7e --- /dev/null +++ b/src/app/common/file-drop/file-drop.component.spec.ts @@ -0,0 +1,117 @@ +import {HttpEventType, HttpResponse, provideHttpClient} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressBarModule} from '@angular/material/progress-bar'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {AlertService} from '../services/alert.service'; +import {FileDropComponent} from './file-drop.component'; + +describe('FileDropComponent accessible upload actions', () => { + let fixture: ComponentFixture; + let http: HttpTestingController; + const alert = {success: vi.fn(), error: vi.fn()}; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FileDropComponent], + imports: [MatButtonModule, MatIconModule, MatProgressBarModule, MatProgressSpinnerModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + {provide: AlertService, useValue: alert}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + fixture = TestBed.createComponent(FileDropComponent); + fixture.componentRef.setInput('mode', 'endpoint'); + fixture.componentRef.setInput('endpoint', '/demo-upload'); + fixture.detectChanges(); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + const status = () => fixture.nativeElement.querySelector('[role="status"]').textContent; + const action = () => + fixture.nativeElement.querySelector('button[aria-label]') as HTMLButtonElement; + const choose = () => fixture.nativeElement.querySelector('button') as HTMLButtonElement; + + function selectFile(): File { + const file = new File(['demo'], 'example.txt', {type: 'text/plain'}); + const input = fixture.nativeElement.querySelector('input[type="file"]'); + Object.defineProperty(input, 'files', {configurable: true, value: [file]}); + input.dispatchEvent(new Event('change')); + fixture.detectChanges(); + return file; + } + + it('names the action before selection and allows cancellation while progress is still zero', () => { + expect(action().getAttribute('aria-label')).toBe('Upload file'); + expect(action().disabled).toBe(true); + selectFile(); + expect(status()).toContain('example.txt selected'); + expect(action().disabled).toBe(false); + action().click(); + fixture.detectChanges(); + const request = http.expectOne('/demo-upload'); + expect(action().getAttribute('aria-label')).toBe('Cancel upload'); + expect(choose().disabled).toBe(true); + expect(choose().textContent).toContain('Choose file'); + expect(status()).toContain('Uploading example.txt'); + expect(fixture.nativeElement.querySelector('mat-progress-bar').getAttribute('aria-label')).toBe( + 'File upload progress', + ); + action().click(); + fixture.detectChanges(); + expect(request.cancelled).toBe(true); + expect(status()).toBe('Upload cancelled.'); + expect(choose().disabled).toBe(false); + expect(action().getAttribute('aria-label')).toBe('Upload file'); + }); + + it('announces completion without repeating an announcement for every progress tick', () => { + const success = vi.fn(); + fixture.componentInstance.uploadSuccess.subscribe(success); + selectFile(); + action().click(); + const request = http.expectOne('/demo-upload'); + fixture.detectChanges(); + const uploadingMessage = status(); + request.event({type: HttpEventType.UploadProgress, loaded: 5, total: 10}); + fixture.detectChanges(); + expect(status()).toBe(uploadingMessage); + request.flush({ok: true}); + fixture.detectChanges(); + expect(success).toHaveBeenCalledWith(expect.any(HttpResponse)); + expect(status()).toBe('File uploaded successfully.'); + expect(choose().disabled).toBe(false); + }); + + it('keeps a failure message available and gives a retry path', () => { + selectFile(); + action().click(); + http.expectOne('/demo-upload').flush({}, {status: 500, statusText: 'Error'}); + fixture.detectChanges(); + expect(status()).toContain('Upload failed. Choose the file again to retry.'); + selectFile(); + expect(action().disabled).toBe(false); + expect(status()).toContain('example.txt selected'); + }); + + it('retains the file event used by local consumers and never emits an absent file', () => { + fixture.componentRef.setInput('mode', 'event'); + const dropped = vi.fn(); + fixture.componentInstance.filesDropped.subscribe(dropped); + fixture.componentInstance.upload(); + expect(dropped).not.toHaveBeenCalled(); + const file = selectFile(); + action().click(); + fixture.detectChanges(); + expect(dropped).toHaveBeenCalledExactlyOnceWith([file]); + expect(status()).toBe('example.txt selected.'); + http.expectNone('/demo-upload'); + }); +}); diff --git a/src/app/common/file-drop/file-drop.component.ts b/src/app/common/file-drop/file-drop.component.ts index 610dc4cbe3..a79fdbe916 100644 --- a/src/app/common/file-drop/file-drop.component.ts +++ b/src/app/common/file-drop/file-drop.component.ts @@ -32,6 +32,8 @@ export class FileDropComponent { @Output() uploadSuccess: EventEmitter> = new EventEmitter(); protected uploadProgress: number; + protected uploading = false; + protected uploadStatus = ''; protected uploadSub: Subscription; /** * Have a singe file that can be set @@ -60,10 +62,16 @@ export class FileDropComponent { if (file) { this.file = file; + this.uploadStatus = `${file.name} selected. Activate Upload file to continue.`; + // Re-selecting the same file after cancellation/failure must emit change again. + event.target.value = ''; } } public onFileDragOver(files: FileList) { + if (this.uploading) { + return; + } // iterate over FileList const droppedFiles = Array.from(files as ArrayLike); for (const f of droppedFiles) { @@ -76,20 +84,28 @@ export class FileDropComponent { } public upload() { + if (!this.file || this.uploading) { + return; + } + if (this.mode == 'endpoint') { if (this.file) { const formData = new FormData(); formData.append('file', this.file); - this.http + this.uploading = true; + this.uploadProgress = 0; + this.uploadStatus = `Uploading ${this.file.name}.`; + this.uploadSub = this.http .post(this.endpoint, formData, {reportProgress: true, observe: 'events'}) .subscribe( (data) => { - if (data.type == HttpEventType.UploadProgress) { + if (data.type == HttpEventType.UploadProgress && data.total) { this.uploadProgress = Math.round(100 * (data.loaded / data.total)); } if (data.type == HttpEventType.Response) { if (data.ok) { + this.uploadStatus = 'File uploaded successfully.'; this.alert.success(`File uploaded successfully`); this.uploadSuccess.emit(data as HttpResponse); } @@ -100,6 +116,7 @@ export class FileDropComponent { ); } } else { + this.uploadStatus = `${this.file.name} selected.`; this.filesDropped.emit([this.file]); this.reset(); } @@ -107,17 +124,20 @@ export class FileDropComponent { private handleError(error: HttpErrorResponse) { const errorMessage = `Error uploading file: ${error}`; + this.uploadStatus = 'Upload failed. Choose the file again to retry.'; this.alert.error(errorMessage); this.reset(); return throwError(() => new Error(errorMessage)); } cancelUpload() { - this.uploadSub.unsubscribe(); + this.uploadSub?.unsubscribe(); + this.uploadStatus = 'Upload cancelled.'; this.reset(); } reset() { + this.uploading = false; this.file = null; this.uploadProgress = null; this.uploadSub = null; diff --git a/src/app/common/file-uploader/file-upload-types.ts b/src/app/common/file-uploader/file-upload-types.ts new file mode 100644 index 0000000000..7d3a09a1a4 --- /dev/null +++ b/src/app/common/file-uploader/file-upload-types.ts @@ -0,0 +1,37 @@ +export const ACCEPTED_TYPES = { + document: { + extensions: ['pdf'], + // icon: 'picture_as_pdf', + icon: 'article_outlined', + name: 'PDF', + }, + csv: { + extensions: ['csv', 'xls', 'xlsx'], + icon: 'insert_chart_outlined', + name: 'Spreadsheet', + }, + code: { + // prettier-ignore + extensions: [ + 'pas', 'cpp', 'c', 'cs', 'csv', 'h', 'hpp', 'java', 'py', 'js', 'html', 'coffee', 'rb', 'css', + 'scss', 'yaml', 'yml', 'xml', 'json', 'ts', 'r', 'rmd', 'rnw', 'rhtml', 'rpres', 'tex', + 'vb', 'sql', 'txt', 'md', 'jack', 'hack', 'asm', 'hdl', 'tst', 'out', 'cmp', 'vm', 'sh', 'bat', + 'dat', 'ipynb', 'pml', 'vue' + ], + // icon: 'code', + // icon: 'code', + icon: 'integration_instructions_outlined', + name: 'Code', + }, + image: { + extensions: ['png', 'bmp', 'tiff', 'tif', 'jpeg', 'jpg', 'gif'], + // icon: 'image', + icon: 'image_outlined', + name: 'image', + }, + zip: { + extensions: ['zip', 'tar.gz', 'tgz', 'tar'], + icon: 'folder_zip', + name: 'zip', + }, +} as const; diff --git a/src/app/common/file-uploader/file-uploader.accessibility.spec.ts b/src/app/common/file-uploader/file-uploader.accessibility.spec.ts new file mode 100644 index 0000000000..c0e82c8e0f --- /dev/null +++ b/src/app/common/file-uploader/file-uploader.accessibility.spec.ts @@ -0,0 +1,88 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {CommonModule} from '@angular/common'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCardModule} from '@angular/material/card'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressBarModule} from '@angular/material/progress-bar'; +import {of} from 'rxjs'; +import {UserService} from 'src/app/api/services/user.service'; +import {expectAccessible} from 'src/app/common/testing/accessibility'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {FileUploaderComponent} from './file-uploader.component'; + +describe('File uploader accessible controls', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FileUploaderComponent], + imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatProgressBarModule], + providers: [ + {provide: UserService, useValue: {}}, + {provide: DoubtfireConstants, useValue: {ExternalName: of('OnTrack')}}, + ], + }).compileComponents(); + fixture = TestBed.createComponent(FileUploaderComponent); + fixture.componentRef.setInput('files', [{name: 'Submission evidence', type: 'document'}]); + }); + + function selectFile(): void { + const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type="file"]'); + Object.defineProperty(fileInput, 'files', { + value: [new File(['Demonstration evidence'], 'evidence.pdf', {type: 'application/pdf'})], + }); + fileInput.dispatchEvent(new Event('change', {bubbles: true})); + fixture.detectChanges(); + } + + it.each([false, true])( + 'names the removal action and permits changing the selected file (single drop zone: %s)', + async (singleDropZone) => { + fixture.componentRef.setInput('singleDropZone', singleDropZone); + fixture.detectChanges(); + selectFile(); + + const remove: HTMLButtonElement = + fixture.nativeElement.querySelector('button[mat-icon-button]'); + expect(remove.getAttribute('aria-label')).toBe('Remove evidence.pdf'); + expect(remove.type).toBe('button'); + expect(remove.tabIndex).toBe(0); + await fixture.whenStable(); + await expectAccessible(fixture.nativeElement); + remove.focus(); + expect(document.activeElement).toBe(remove); + remove.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.uploadZones[0].model).toBeNull(); + expect(fixture.componentInstance.isReady).toBe(false); + const dropZone: HTMLButtonElement = + fixture.nativeElement.querySelector('button.file-drop-zone'); + expect(dropZone).not.toBeNull(); + expect(dropZone.closest('[hidden]')).toBeNull(); + expect(dropZone.textContent).toContain('Drop PDF file here'); + }, + ); + + it('exposes a named progress bar with the current upload percentage', async () => { + fixture.detectChanges(); + selectFile(); + fixture.componentInstance.isUploading = true; + fixture.componentInstance.uploadingInfo = { + progress: 42, + success: false, + error: null, + complete: false, + }; + fixture.detectChanges(); + + const progress: HTMLElement = fixture.nativeElement.querySelector('[role="progressbar"]'); + expect(progress.getAttribute('aria-label')).toBe('File upload progress'); + expect(progress.getAttribute('aria-valuemin')).toBe('0'); + expect(progress.getAttribute('aria-valuemax')).toBe('100'); + expect(progress.getAttribute('aria-valuenow')).toBe('42'); + await fixture.whenStable(); + await expectAccessible(fixture.nativeElement); + }); +}); diff --git a/src/app/common/file-uploader/file-uploader.component.html b/src/app/common/file-uploader/file-uploader.component.html index f1f93c5526..3a37a27e6a 100644 --- a/src/app/common/file-uploader/file-uploader.component.html +++ b/src/app/common/file-uploader/file-uploader.component.html @@ -1,4 +1,4 @@ - + @if (!showUploader) { } -
+
@if (showUploader && uploadingInfo === null && shownUploadZones.length) {
@for (upload of shownUploadZones; track upload) { @@ -26,6 +26,7 @@
Select {{ upload.display.name }}
@@ -68,7 +70,12 @@
Select {{ upload.display.name }}
{{ upload.display.icon }} {{ upload.model[0].name }} -
@@ -90,7 +97,12 @@
Upload Summary
@if (upload.model?.length > 0) { {{ upload.model[0].name }} - } @else { @@ -129,6 +141,7 @@
Upload Summary
diff --git a/src/app/common/file-uploader/file-uploader.component.spec.ts b/src/app/common/file-uploader/file-uploader.component.spec.ts new file mode 100644 index 0000000000..aa3a1bb504 --- /dev/null +++ b/src/app/common/file-uploader/file-uploader.component.spec.ts @@ -0,0 +1,41 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {FileUploaderComponent} from './file-uploader.component'; + +// SJ-11: the upload type check must match a real file extension, not any +// filename that merely ends with the accepted letters (e.g. figure.eps +// must not pass a `ps` zone). +describe('FileUploaderComponent validateFiles', () => { + let component: FileUploaderComponent; + + beforeEach(() => { + component = new FileUploaderComponent({} as never, {} as never); + (component as never as {refreshShownUploadZones: unknown}).refreshShownUploadZones = vi.fn(); + (component as never as {updateReadyState: unknown}).updateReadyState = vi.fn(); + (component as never as {readyToUpload: unknown}).readyToUpload = vi.fn(() => true); + }); + + const zoneFor = (filename: string) => ({ + model: [{name: filename}] as unknown, + accepts: ['pdf', 'ps'], + display: {error: null as unknown}, + }); + + it('rejects a file whose name only ends with the accepted letters', () => { + const zone = zoneFor('figure.eps'); + (component as never as {shownUploadZones: unknown}).shownUploadZones = [zone]; + + component.validateFiles(); + + expect(zone.model).toBeNull(); + expect(zone.display.error).toBe(true); + }); + + it('accepts a file with a real accepted extension', () => { + const zone = zoneFor('report.pdf'); + (component as never as {shownUploadZones: unknown}).shownUploadZones = [zone]; + + component.validateFiles(); + + expect(zone.model).not.toBeNull(); + }); +}); diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index a39be59f3b..1780c9b9d3 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -10,6 +10,9 @@ import { } from '@angular/core'; import {UserService} from 'src/app/api/services/user.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {ACCEPTED_TYPES} from './file-upload-types'; + +export {ACCEPTED_TYPES} from './file-upload-types'; export interface FileData { name: string; @@ -40,44 +43,6 @@ interface UploadingInfo { complete: boolean; } -export const ACCEPTED_TYPES = { - document: { - extensions: ['pdf', 'ps'], - // icon: 'picture_as_pdf', - icon: 'article_outlined', - name: 'PDF', - }, - csv: { - extensions: ['csv', 'xls', 'xlsx'], - icon: 'insert_chart_outlined', - name: 'CSV', - }, - code: { - // prettier-ignore - extensions: [ - 'pas', 'cpp', 'c', 'cs', 'csv', 'h', 'hpp', 'java', 'py', 'js', 'html', 'coffee', 'rb', 'css', - 'scss', 'yaml', 'yml', 'xml', 'json', 'ts', 'r', 'rmd', 'rnw', 'rhtml', 'rpres', 'tex', - 'vb', 'sql', 'txt', 'md', 'jack', 'hack', 'asm', 'hdl', 'tst', 'out', 'cmp', 'vm', 'sh', 'bat', - 'dat', 'ipynb', 'pml', 'vue' - ], - // icon: 'code', - // icon: 'code', - icon: 'integration_instructions_outlined', - name: 'code', - }, - image: { - extensions: ['png', 'bmp', 'tiff', 'tif', 'jpeg', 'jpg', 'gif'], - // icon: 'image', - icon: 'image_outlined', - name: 'image', - }, - zip: { - extensions: ['zip', 'tar.gz', 'tgz', 'tar'], - icon: 'folder_zip', - name: 'zip', - }, -} as const; - @Component({ selector: 'f-file-uploader', templateUrl: './file-uploader.component.html', @@ -89,6 +54,7 @@ export class FileUploaderComponent implements OnInit, OnChanges { @Input() files: FileUploadSpec; @Input() url: string; @Input() method = 'POST'; + @Input() describedBy: string | null = null; @Input() payload?: unknown; @Input() onBeforeUpload?: () => void; @@ -197,8 +163,10 @@ export class FileUploaderComponent implements OnInit, OnChanges { for (const upload of this.shownUploadZones) { if (upload.model?.length) { const name: string = upload.model[0].name.toLowerCase(); - const accepts: string[] = upload.accepts.map((ext: string) => ext.toLowerCase()); - const valid = accepts.some((ext) => name.endsWith(ext)); + const accepts: string[] = upload.accepts.map((ext: string) => + ext.toLowerCase().replace(/^\./, ''), + ); + const valid = accepts.some((ext) => name.endsWith(`.${ext}`)); if (!valid) { upload.model = null; upload.display.error = true; @@ -292,7 +260,11 @@ export class FileUploaderComponent implements OnInit, OnChanges { } catch (e) { console.error(e); if (xhr.status === 0) { - response = {error: `Could not connect to ${this.externalName} the server`}; + if (navigator.onLine === false) { + response = {error: 'You appear to be offline. Your files have not been submitted.'}; + } else { + response = {error: `Could not connect to the ${this.externalName} server.`}; + } } else { response = xhr.responseText; } diff --git a/src/app/common/file-viewer/file-viewer.component.html b/src/app/common/file-viewer/file-viewer.component.html index 88ad9162d4..15568e8515 100644 --- a/src/app/common/file-viewer/file-viewer.component.html +++ b/src/app/common/file-viewer/file-viewer.component.html @@ -2,7 +2,7 @@ @if (blobUrl && fileType === 'pdf') { @if (!loaded) { } @@ -21,7 +21,7 @@ } @if (fileType === 'html') {
- +
}
diff --git a/src/app/common/file-viewer/file-viewer.component.spec.ts b/src/app/common/file-viewer/file-viewer.component.spec.ts index 21eeaf7660..82ae214573 100644 --- a/src/app/common/file-viewer/file-viewer.component.spec.ts +++ b/src/app/common/file-viewer/file-viewer.component.spec.ts @@ -1,5 +1,9 @@ import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FileDownloaderService} from '../file-downloader/file-downloader.service'; +import {SafePipe} from '../pipes/safe.pipe'; +import {AlertService} from '../services/alert.service'; import {FileViewerComponent} from './file-viewer.component'; describe('FileViewerComponent', () => { @@ -8,7 +12,12 @@ describe('FileViewerComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [FileViewerComponent], + declarations: [FileViewerComponent, SafePipe], + providers: [ + {provide: FileDownloaderService, useValue: {}}, + {provide: AlertService, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(FileViewerComponent); @@ -19,4 +28,43 @@ describe('FileViewerComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('names the HTML preview even when no filename is available', () => { + component.fileType = 'html'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('iframe')?.title).toBe('Submission preview'); + }); + + it('does not expose an unrelated frame when displaying a PDF', () => { + component.fileType = 'pdf'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('iframe')).toBeNull(); + }); + + // WEBUX-08: the load bar reads a 0-100 percentage, not a 0..1 fraction. + it('scales pdf load progress to a 0-100 percentage', () => { + const c = component as never as { + onProgress: (p: {loaded: number; total: number}) => void; + pdfLoadingProgressPercentage: number; + pdfLoadingTotalKnown: boolean; + }; + + c.onProgress({loaded: 500, total: 1000}); + + expect(c.pdfLoadingProgressPercentage).toBe(50); + expect(c.pdfLoadingTotalKnown).toBe(true); + }); + + it('falls back to an indeterminate bar when the total is unknown', () => { + const c = component as never as { + onProgress: (p: {loaded: number; total: number}) => void; + pdfLoadingProgressPercentage: number; + pdfLoadingTotalKnown: boolean; + }; + + c.onProgress({loaded: 100, total: 0}); + + expect(c.pdfLoadingProgressPercentage).toBe(0); + expect(c.pdfLoadingTotalKnown).toBe(false); + }); }); diff --git a/src/app/common/file-viewer/file-viewer.component.ts b/src/app/common/file-viewer/file-viewer.component.ts index c034f471d3..f4f68be793 100644 --- a/src/app/common/file-viewer/file-viewer.component.ts +++ b/src/app/common/file-viewer/file-viewer.component.ts @@ -48,9 +48,21 @@ export class FileViewerComponent implements OnDestroy, OnChanges { public loaded = false; protected pdfLoadingProgressPercentage = 0; + protected pdfLoadingTotalKnown = false; protected onProgress(progress: PDFProgressData) { - this.pdfLoadingProgressPercentage = Math.round(progress.loaded / progress.total); + if (progress.total > 0) { + this.pdfLoadingTotalKnown = true; + this.pdfLoadingProgressPercentage = Math.min( + 100, + Math.round((progress.loaded / progress.total) * 100), + ); + } else { + // Some progress events arrive before the total size is known; show an + // indeterminate bar rather than a bar stuck at zero. + this.pdfLoadingTotalKnown = false; + this.pdfLoadingProgressPercentage = 0; + } } /** diff --git a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.html b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.html index 2e65ecfe24..9a6e4548f5 100644 --- a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.html +++ b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.html @@ -11,12 +11,18 @@
@if (resourcesUrl) { - - download Resources - + } -
diff --git a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss index 1a00bb118f..58be23efbc 100644 --- a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss +++ b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss @@ -5,7 +5,7 @@ padding: 0; position: relative; overflow: inherit !important; - background-color: #d1d1d1; + background-color: var(--ot-color-page); min-height: 75vh; @include doubtfire-scroll-panel; } diff --git a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.spec.ts b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.spec.ts index ff24c419cf..0ffc3b5425 100644 --- a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.spec.ts +++ b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.spec.ts @@ -11,21 +11,56 @@ describe('PdfViewerPanelComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { + // The real template is rendered here on purpose. The accessibility fix + // (A11Y-KB05) lives in the footer markup, so the tests need the actual DOM + // rather than a blank template. await TestBed.configureTestingModule({ declarations: [PdfViewerPanelComponent], providers: [{provide: FileDownloaderService, useValue: emptyProvider}], schemas: [NO_ERRORS_SCHEMA], - }) - .overrideComponent(PdfViewerPanelComponent, {set: {template: ''}}) - .compileComponents(); - }); + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(PdfViewerPanelComponent); component = fixture.componentInstance; }); + const footer = (): HTMLElement => fixture.nativeElement as HTMLElement; + it('should create', () => { expect(component).toBeTruthy(); }); + + it('renders the resources control as a real button so keyboard users can reach it', () => { + component.resourcesUrl = 'https://example.test/resources.zip'; + fixture.detectChanges(); + + // Before the fix this control was an anchor with no href, which is not + // focusable and carries no link role, so the keyboard tab order skipped it. + expect(footer().querySelector('a.btn-primary')).toBeNull(); + + const resourcesButton = footer().querySelector('button.btn-primary'); + expect(resourcesButton).not.toBeNull(); + expect(resourcesButton!.getAttribute('type')).toBe('button'); + }); + + it('keeps a name on the PDF button when the visible label is hidden', () => { + // With no resources the " PDF" text is inside a hidden span, so the button + // needs an aria-label to keep an accessible name instead of announcing only + // "button". + component.resourcesUrl = undefined; + fixture.detectChanges(); + + const pdfButton = footer().querySelector('button.btn-success'); + expect(pdfButton).not.toBeNull(); + expect(pdfButton!.getAttribute('aria-label')).toBe('Download submission PDF'); + }); + + it('hides both download icons from assistive technology', () => { + component.resourcesUrl = 'https://example.test/resources.zip'; + fixture.detectChanges(); + + const icons = footer().querySelectorAll('mat-icon'); + expect(icons.length).toBe(2); + icons.forEach((icon) => expect(icon.getAttribute('aria-hidden')).toBe('true')); + }); }); diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.html b/src/app/common/pdf-viewer/pdf-viewer.component.html index c509ce5fdd..c279de74ed 100644 --- a/src/app/common/pdf-viewer/pdf-viewer.component.html +++ b/src/app/common/pdf-viewer/pdf-viewer.component.html @@ -15,6 +15,7 @@
+ Search PDF search - - @@ -51,7 +40,9 @@ @if (pdfBlobUrl) { @if (useNativePdfViewer) { @if (pdfBlobUrl) { - PDF Preview + + PDF Preview + } } @else { } } @else { - + }
diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.spec.ts b/src/app/common/pdf-viewer/pdf-viewer.component.spec.ts new file mode 100644 index 0000000000..e649491e2d --- /dev/null +++ b/src/app/common/pdf-viewer/pdf-viewer.component.spec.ts @@ -0,0 +1,65 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {SafePipe} from '../pipes/safe.pipe'; +import {fPdfViewerComponent} from './pdf-viewer.component'; + +vi.mock('ng2-pdf-viewer', () => ({ + PdfViewerComponent: class {}, + PDFDocumentProxy: class {}, +})); + +describe('fPdfViewerComponent', () => { + let component: fPdfViewerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [fPdfViewerComponent, SafePipe], + imports: [FormsModule], + providers: [ + {provide: FileDownloaderService, useValue: {releaseBlob: () => {}}}, + {provide: AlertService, useValue: {error: () => {}}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(fPdfViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('labels the two zoom buttons with different accessible names instead of both announcing "Zoom in PDF button"', () => { + const buttons = fixture.nativeElement.querySelectorAll('#pdfActions button'); + expect(buttons.length).toBe(2); + + const zoomOutButton = buttons[0] as HTMLButtonElement; + const zoomInButton = buttons[1] as HTMLButtonElement; + + expect(zoomOutButton.getAttribute('aria-label')).toBe('Zoom out'); + expect(zoomInButton.getAttribute('aria-label')).toBe('Zoom in'); + expect(zoomOutButton.getAttribute('aria-label')).not.toBe( + zoomInButton.getAttribute('aria-label'), + ); + }); + + it('gives the search field a mat-label and the loading spinner an accessible name, instead of leaving them unnamed', () => { + const matLabel = fixture.nativeElement.querySelector('mat-form-field mat-label'); + expect(matLabel?.textContent?.trim()).toBe('Search PDF'); + + const spinner = fixture.nativeElement.querySelector('mat-spinner'); + expect(spinner.getAttribute('aria-label')).toBe('Loading PDF'); + }); + + it('titles the embedded PDF object so the native viewer is not an untitled embed', () => { + component.pdfBlobUrl = 'blob:http://localhost/fake-pdf'; + component.useNativePdfViewer = true; + fixture.detectChanges(); + + const object = fixture.nativeElement.querySelector('object'); + expect(object.getAttribute('title')).toBe('Submission PDF'); + }); +}); diff --git a/src/app/common/submission-files-download/submission-files-download.component.accessibility.spec.ts b/src/app/common/submission-files-download/submission-files-download.component.accessibility.spec.ts new file mode 100644 index 0000000000..87ea72e70f --- /dev/null +++ b/src/app/common/submission-files-download/submission-files-download.component.accessibility.spec.ts @@ -0,0 +1,59 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {HttpResponse} from '@angular/common/http'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {ActivatedRoute, convertToParamMap} from '@angular/router'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {FileDownloaderService} from '../file-downloader/file-downloader.service'; +import {expectAccessible} from '../testing/accessibility'; +import {SubmissionFilesDownloadComponent} from './submission-files-download.component'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-max-inline-declarations -- Host the real download template in the shell landmark. + template: '
', + standalone: false, +}) +class DownloadRouteHost {} + +describe('Submission download landmark', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DownloadRouteHost, SubmissionFilesDownloadComponent], + imports: [MatButtonModule, MatIconModule, MatProgressSpinnerModule], + providers: [ + { + provide: ActivatedRoute, + useValue: {snapshot: {paramMap: convertToParamMap({projectId: '1', taskDefId: '2'})}}, + }, + {provide: DoubtfireConstants, useValue: {API_URL: '/api'}}, + { + provide: FileDownloaderService, + useValue: { + downloadBlob: vi.fn((_url, success) => + success('blob:synthetic-files', new HttpResponse({body: new Blob()})), + ), + downloadBlobToFile: vi.fn(), + }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(DownloadRouteHost); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('renders download feedback inside the shell main without another main landmark', async () => { + const element = fixture.nativeElement as HTMLElement; + expect(element.querySelectorAll('main, [role="main"]').length).toBe(1); + const content = element.querySelector('f-submission-files-download'); + expect(content.querySelector('main, [role="main"]')).toBeNull(); + expect(content.textContent).toContain('Submitted files downloaded.'); + expect(content.querySelector('.min-h-screen')).not.toBeNull(); + await expectAccessible(element); + }); +}); diff --git a/src/app/common/submission-files-download/submission-files-download.component.html b/src/app/common/submission-files-download/submission-files-download.component.html index f3348a38bd..8319b2e42d 100644 --- a/src/app/common/submission-files-download/submission-files-download.component.html +++ b/src/app/common/submission-files-download/submission-files-download.component.html @@ -1,4 +1,4 @@ -
+
@switch (downloadState) { @case ('downloading') { @@ -23,4 +23,4 @@ } } -
+ diff --git a/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html b/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html index 6ec126d3a2..5fa5e5595d 100644 --- a/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html +++ b/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html @@ -8,8 +8,10 @@
Create Portfolio
- person -
+ person +
Create and submit your portfolio
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.html new file mode 100644 index 0000000000..9cab3e52e7 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.html @@ -0,0 +1,66 @@ + + Previous submissions + +

Retained versions, newest first. Only files selected for submission history are included.

+
+ @if (loading) { +

Loading previous submissions…

+ } + @if (processing) { +

A new archive is still processing. Refresh to check its availability.

+ } + @if (!loading && !error && versions.length === 0) { +

No previous submissions have been retained for this task.

+ } +
+ @if (error) { +

{{ error }}

+ } +
    + @for (version of versions; track version.id) { +
  1. +
    + {{ version.current ? 'Current submission' : 'Previous submission' }} + @if (!version.current && version.versionOrder === 1) { + — latest retained archive + } +

    Archive file: submission-{{ version.id }}.zip

    +

    + @if (version.timestamp) { + + } @else { + Submission time unavailable for this legacy archive + } +

    + @if (!version.available) { +

    + Files unavailable. This archive may have been removed, may be incomplete, or may use + an older format. +

    + } +
    + @if (version.available) { + + } +
  2. + } +
+
+ +
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss new file mode 100644 index 0000000000..de5c4c04aa --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss @@ -0,0 +1,18 @@ +:host { + display: block; + margin-top: 1rem; +} +.history-list { + padding-inline-start: 1.5rem; +} +.history-list li { + padding-block: 0.75rem; + border-bottom: 1px solid var(--mat-sys-outline-variant, currentColor); + overflow-wrap: anywhere; +} +.history-list p { + margin-block: 0.5rem; +} +.history-list button { + max-width: 100%; +} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.spec.ts new file mode 100644 index 0000000000..6895b98285 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.spec.ts @@ -0,0 +1,117 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {Subject, of, throwError} from 'rxjs'; +import {Task} from 'src/app/api/models/task'; +import { + StudentSubmissionHistory, + SubmissionHistoryService, +} from 'src/app/api/services/submission-history.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {PreviousSubmissionsComponent} from './previous-submissions.component'; + +describe('Previous submissions', () => { + const task = {id: 1, project: {id: 2}, definition: {id: 3}} as Task; + const available = { + id: 4, + versionOrder: 1, + timestamp: new Date('2026-09-20T03:00:00Z'), + current: true, + available: true, + }; + let history: { + queryStudentHistory: ReturnType; + studentArchiveUrl: ReturnType; + }; + let downloader: { + downloadBlob: ReturnType; + downloadBlobToFile: ReturnType; + releaseBlob: ReturnType; + }; + beforeEach(() => { + history = { + queryStudentHistory: vi.fn().mockReturnValue(of({versions: [], processing: false})), + studentArchiveUrl: vi.fn().mockReturnValue('/authorised/history/4'), + }; + downloader = {downloadBlob: vi.fn(), downloadBlobToFile: vi.fn(), releaseBlob: vi.fn()}; + TestBed.configureTestingModule({ + imports: [PreviousSubmissionsComponent], + providers: [ + {provide: SubmissionHistoryService, useValue: history}, + {provide: FileDownloaderService, useValue: downloader}, + ], + }); + }); + + function render() { + const fixture = TestBed.createComponent(PreviousSubmissionsComponent); + fixture.componentRef.setInput('task', task); + fixture.detectChanges(); + return fixture; + } + + it('announces loading, processing, empty results and API failure with a retry', () => { + const pending: Subject = new Subject(); + history.queryStudentHistory.mockReturnValue(pending); + const fixture = render(); + expect(fixture.nativeElement.textContent).toContain('Loading previous submissions'); + pending.next({versions: [], processing: true}); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('still processing'); + expect(fixture.nativeElement.textContent).toContain('No previous submissions'); + history.queryStudentHistory.mockReturnValue(throwError(() => new Error('offline'))); + fixture.componentInstance.refresh(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'could not be loaded', + ); + history.queryStudentHistory.mockReturnValue(of({versions: [], processing: false})); + fixture.componentInstance.refresh(); + expect(fixture.componentInstance.error).toBe(''); + }); + + it('labels current and previous versions and only offers downloads for available files', () => { + history.queryStudentHistory.mockReturnValue( + of({ + versions: [ + available, + {...available, id: 5, versionOrder: 2, current: false, available: false}, + ], + processing: false, + }), + ); + const fixture = render(); + const rows = fixture.nativeElement.querySelectorAll('li'); + expect(rows[0].textContent).toContain('Current submission'); + expect(rows[0].textContent).toContain('Archive file: submission-4.zip'); + expect(rows[1].textContent).toContain('Previous submission'); + expect(rows[1].textContent).toContain('Files unavailable'); + expect(rows[0].querySelector('button').getAttribute('aria-label')).toContain( + 'Download archived submission', + ); + expect(rows[1].querySelector('button')).toBeNull(); + fixture.componentInstance.download({...available, available: false}); + expect(downloader.downloadBlob).not.toHaveBeenCalled(); + rows[0].querySelector('button').click(); + expect(downloader.downloadBlob).toHaveBeenCalled(); + downloader.downloadBlob.mock.calls[0][2](); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'no longer available', + ); + }); + + it('clears the previous task and ignores a late response or download after navigation', () => { + const pending: Subject = new Subject(); + history.queryStudentHistory.mockReturnValue(pending); + const fixture = render(); + fixture.componentInstance.download(available); + history.queryStudentHistory.mockReturnValue(of({versions: [], processing: false})); + fixture.componentRef.setInput('task', {...task, id: 2}); + fixture.detectChanges(); + pending.next({versions: [available], processing: false}); + downloader.downloadBlob.mock.calls[0][1]('blob:old-task'); + expect(fixture.componentInstance.versions).toEqual([]); + expect(downloader.downloadBlobToFile).not.toHaveBeenCalled(); + expect(downloader.releaseBlob).toHaveBeenCalledWith('blob:old-task'); + }); +}); diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.ts new file mode 100644 index 0000000000..e9b4688b8a --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.ts @@ -0,0 +1,95 @@ +import {DatePipe} from '@angular/common'; +import {ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCardModule} from '@angular/material/card'; +import {Subscription} from 'rxjs'; +import {Task} from 'src/app/api/models/task'; +import { + StudentSubmissionVersion, + SubmissionHistoryService, +} from 'src/app/api/services/submission-history.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; + +@Component({ + selector: 'f-previous-submissions', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: true, + imports: [DatePipe, MatButtonModule, MatCardModule], + templateUrl: './previous-submissions.component.html', + styleUrls: ['./previous-submissions.component.scss'], +}) +export class PreviousSubmissionsComponent implements OnChanges, OnDestroy { + @Input() task: Task; + versions: StudentSubmissionVersion[] = []; + loading = false; + processing = false; + error = ''; + downloadingId: number | null = null; + private request?: Subscription; + private generation = 0; + + constructor( + private history: SubmissionHistoryService, + private downloader: FileDownloaderService, + ) {} + + ngOnChanges(): void { + this.refresh(); + } + + ngOnDestroy(): void { + this.generation++; + this.request?.unsubscribe(); + } + + refresh(): void { + this.request?.unsubscribe(); + this.generation++; + this.versions = []; + this.processing = false; + this.loading = false; + this.error = ''; + this.downloadingId = null; + if (!this.task?.id) { + return; + } + this.loading = true; + this.request = this.history.queryStudentHistory(this.task).subscribe({ + next: (result) => { + this.loading = false; + this.versions = result.versions; + this.processing = result.processing; + }, + error: () => { + this.loading = false; + this.error = 'Previous submissions could not be loaded. Try again.'; + }, + }); + } + + download(version: StudentSubmissionVersion): void { + if (!version.available || this.downloadingId !== null) { + return; + } + const generation = this.generation; + this.downloadingId = version.id; + this.error = ''; + this.downloader.downloadBlob( + this.history.studentArchiveUrl(this.task, version.id), + (url) => { + if (generation === this.generation) { + this.downloader.downloadBlobToFile(url, `submission-${version.id}.zip`); + this.downloadingId = null; + } + this.downloader.releaseBlob(url); + }, + () => { + if (generation === this.generation) { + this.downloadingId = null; + this.error = + 'These archived files are no longer available, or you no longer have access. Refresh the list or try again.'; + } + }, + ); + } +} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html index 0b5d7505e9..597a3efaab 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html @@ -9,7 +9,9 @@

{{ data.assessment.task.definition.abbreviation }} - {{ data.assessment.task.definition.name }} ({{ data.assessment.task.project.student.name }}) + {{ data.assessment.task.definition.name }} ({{ + data.assessment.task.project.student.displayName + }})
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html index 90dd00e1d5..9719fb9fa8 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html @@ -43,7 +43,7 @@ > more_vert -
+
@@ -71,3 +71,5 @@
+ + diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.tpl.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.tpl.html deleted file mode 100644 index ec25e0f0d2..0000000000 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.tpl.html +++ /dev/null @@ -1,67 +0,0 @@ -
-
-

- Submission - Processing -

-
- -
-

- Your submission is being processed and will be avaliable to view soon. You will also be able - to download your most recently submitted files. -

-

- You can choose to download your previous - submission or the files you uploaded below. -

-

- You uploaded this submission {{task.submissionDate | date: 'dd/MM/yyyy'}}. -

-

- If you feel there has been an error in your submission, you can request to regenerate your - submission under the "Actions" dropdown menu. -

-

- If you would like to submit alternate evidence for use in your portfolio, you can upload - alternate files under the "Actions" dropdown menu. -

-
- - - -
- diff --git a/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html b/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html index 008b8109a3..d10135b3df 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html @@ -70,7 +70,7 @@

Select Grade

} -

+

Make sure your Learning Summary Report justifies how your portfolio demonstrates you have met all unit learning outcomes to a {{ targetGrade }} level diff --git a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html index 38681c7334..a5e9195be2 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html @@ -36,7 +36,9 @@ Learning Summary Report Submitted @if (draftTaskDefinitionWasUsed()) { -

+
Your learning summary report was automatically submitted from your {{ unit.draftTaskDefinition.abbreviation }} {{ unit.draftTaskDefinition.name }} submission. @@ -74,7 +76,7 @@
@if (!projectHasDraftLearningSummaryReport) { -
+
You're missing a Learning Summary Report. Upload one to continue.
} diff --git a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html index 3944af9a70..7ac829a53e 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html @@ -1,11 +1,13 @@ @if (loadingIncludedTasks) { -
-
Loading tasks...
- +
+ Loading tasks... +
} @else { @if (tasksInPortfolio.length === 0) { -
+
assignment_late

No tasks found

diff --git a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.spec.ts b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.spec.ts new file mode 100644 index 0000000000..aa41d01d93 --- /dev/null +++ b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.spec.ts @@ -0,0 +1,105 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {Subject} from 'rxjs'; +import {Project} from 'src/app/api/models/project'; +import {Task} from 'src/app/api/models/task'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {SkeletonLoaderComponent} from 'src/app/common/skeleton-loader/skeleton-loader.component'; +import {PortfolioIncludedTasksComponent} from './portfolio-included-tasks.component'; + +describe('PortfolioIncludedTasksComponent', () => { + let component: PortfolioIncludedTasksComponent; + let fixture: ComponentFixture; + let includedTasks: Subject; + let processingTasks: Subject; + + const unit = new Unit(); + unit.id = 1; + + const buildTask = (id: number): Task => { + const definition = new TaskDefinition(unit); + definition.id = id; + definition.abbreviation = `${id}.1P`; + definition.name = `Task ${id}`; + const task = new Task(unit); + task.id = id; + task.definition = definition; + task.status = 'not_started'; + return task; + }; + + const buildProject = (tasks: Task[]): Project => { + const project = new Project(unit); + tasks.forEach((task) => project.taskCache.add(task)); + project.getTasksIncludedInPortfolio = vi.fn().mockReturnValue(includedTasks); + project.getTasksStillProcessing = vi.fn().mockReturnValue(processingTasks); + return project; + }; + + const skeleton = (): SkeletonLoaderComponent | null => + fixture.debugElement.query(By.css('f-skeleton-loader'))?.componentInstance ?? null; + + beforeEach(async () => { + includedTasks = new Subject(); + processingTasks = new Subject(); + + await TestBed.configureTestingModule({ + declarations: [PortfolioIncludedTasksComponent], + imports: [SkeletonLoaderComponent], + providers: [{provide: AlertService, useValue: {error: vi.fn()}}], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(PortfolioIncludedTasksComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows a 4-row skeleton while the included tasks are still loading', () => { + component.project = buildProject([buildTask(1)]); + fixture.detectChanges(); + + const loader = skeleton(); + expect(loader).not.toBeNull(); + expect(loader.count).toBe(4); + expect(loader.shape).toBe('row'); + expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain( + 'Loading tasks', + ); + }); + + // Discriminating: this fails if the skeleton were left on screen once the + // tasks have actually arrived, which is exactly the bug a hard-coded + // "always show the skeleton" edit would introduce. + it('replaces the skeleton with the task list once loading finishes', () => { + component.project = buildProject([buildTask(1)]); + fixture.detectChanges(); + expect(skeleton()).not.toBeNull(); + + includedTasks.next([1]); + processingTasks.next([]); + fixture.detectChanges(); + + expect(skeleton()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Task 1'); + }); + + it('does not show the skeleton once loading has finished with nothing included', () => { + component.project = buildProject([]); + fixture.detectChanges(); + + includedTasks.next([]); + processingTasks.next([]); + fixture.detectChanges(); + + expect(skeleton()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('No tasks found'); + }); +}); diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html new file mode 100644 index 0000000000..19ed440ed1 --- /dev/null +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html @@ -0,0 +1,58 @@ +
+ @if (hasRequirements) { +

Files required: {{ requiredFileCount }}

+ +
    + @for (summary of summaries; track $index) { +
  • +

    + Required file type: {{ summary.categoryLabel }} +

    + @if (summary.name) { +

    {{ summary.name }}

    + } +

    + Accepted formats: + @if (summary.extensions.length) { + {{ summary.previewExtensions.join(', ') }} + @if (summary.hasMoreExtensions) { + , … + } + } @else { + Not specified for this task + } +

    +

    + Maximum size: Not provided by the server. Upload limits still apply. +

    + + @if (summary.hasMoreExtensions) { + +
      + @for (extension of summary.extensions; track extension) { +
    • {{ extension }}
    • + } +
    + } +
  • + } +
+ } @else { +

+ Upload requirements for this task are not currently available. Contact your teaching team if + this message persists. +

+ } +
diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss new file mode 100644 index 0000000000..37a5354fe8 --- /dev/null +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss @@ -0,0 +1,63 @@ +.task-upload-requirements { + margin-bottom: 1em; + margin-top: 1em; + min-width: 0; +} + +.requirements-list { + list-style: none; + margin: 0; + padding: 0; +} + +.requirement { + padding: 0.5em 0.75em; + margin-bottom: 0.5em; + border: 1px solid #ddd; + border-radius: 4px; + word-break: break-word; +} + +.requirements-missing { + color: inherit; + font-style: italic; +} + +.extensions-toggle { + background: none; + border: none; + padding: 0; + color: inherit; + text-decoration: underline; + cursor: pointer; + font-size: inherit; + + &:hover, + &:focus { + outline: 2px solid currentColor; + outline-offset: 3px; + } +} + +.extensions-list { + display: flex; + flex-wrap: wrap; + gap: 0.25em 0.75em; + list-style: none; + margin: 0.5em 0 0; + padding: 0; + + li { + border: 1px solid currentColor; + border-radius: 3px; + padding: 0.1em 0.5em; + } +} + +// Narrow screens: stack extension chips full-width instead of relying on wrapping alone +@media (max-width: 480px) { + .extensions-list { + flex-direction: column; + gap: 0.25em; + } +} diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts new file mode 100644 index 0000000000..7f0e44163f --- /dev/null +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts @@ -0,0 +1,142 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {UploadRequirement} from 'src/app/api/models/task-definition'; +import {TaskUploadRequirementsComponent} from './task-upload-requirements.component'; + +describe('TaskUploadRequirementsComponent', () => { + let component: TaskUploadRequirementsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TaskUploadRequirementsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskUploadRequirementsComponent); + component = fixture.componentInstance; + }); + + function setRequirements(requirements: UploadRequirement[] | null | undefined) { + component.requirements = requirements; + component.ngOnChanges({ + requirements: { + currentValue: requirements, + previousValue: undefined, + firstChange: true, + isFirstChange: () => true, + }, + }); + fixture.detectChanges(); + } + + it('should create', () => { + setRequirements([]); + expect(component).toBeTruthy(); + }); + + describe('normal state', () => { + it('shows the category, accepted formats and required file count for each requirement', () => { + setRequirements([ + {key: 'file0', name: 'Report', type: 'document'}, + {key: 'file1', name: 'Data', type: 'csv'}, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent; + expect(text).toContain('Files required:'); + expect(text).toContain('2'); + expect(text).toContain('PDF'); + expect(text).toContain('PDF'); + expect(text).toContain('Spreadsheet'); + expect(text).toContain('CSV'); + expect(text).toContain('XLS'); + expect(text).toContain('XLSX'); + }); + + it('does not show an expand control when the extension list is short', () => { + setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); + + const toggle = fixture.nativeElement.querySelector('.extensions-toggle'); + expect(toggle).toBeNull(); + }); + }); + + describe('long extension list state', () => { + it('truncates a long extension list behind a collapsed, keyboard-operable toggle', () => { + setRequirements([{key: 'file0', name: 'Source code', type: 'code'}]); + + const toggle: HTMLButtonElement = fixture.nativeElement.querySelector('.extensions-toggle'); + expect(toggle).not.toBeNull(); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + const list: HTMLUListElement = fixture.nativeElement.querySelector('.extensions-list'); + expect(list.hidden).toBe(true); + + toggle.click(); + fixture.detectChanges(); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(list.hidden).toBe(false); + expect(list.textContent).toContain('JSON'); + expect(list.textContent).toContain('IPYNB'); + }); + }); + + describe('missing policy state', () => { + it('shows a safe message when there are no upload requirements', () => { + setRequirements([]); + + const text = (fixture.nativeElement as HTMLElement).textContent; + expect(text).toContain('not currently available'); + expect(text).not.toContain('undefined'); + expect(text).not.toContain('NaN'); + }); + + it('shows a safe message when upload requirements are null', () => { + setRequirements(null); + + const text = (fixture.nativeElement as HTMLElement).textContent; + expect(text).toContain('not currently available'); + }); + + it('does not invent a size limit when the API does not expose one', () => { + setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); + + const text = (fixture.nativeElement as HTMLElement).textContent; + expect(text).toContain('Maximum size:'); + expect(text).toContain('Not provided by the server'); + }); + }); + + it('uses current uploader extensions including Vue and compressed tar aliases', () => { + setRequirements([ + {key: 'file0', name: 'Source', type: 'code'}, + {key: 'file1', name: 'Archive', type: 'archive'}, + ]); + expect(component.summaries[0].extensions).toContain('VUE'); + expect(component.summaries[1].categoryLabel).toBe('Archive'); + expect(component.summaries[1].extensions).toContain('TGZ'); + }); + + it('uses unique description ids when two instances are rendered', () => { + const second = TestBed.createComponent(TaskUploadRequirementsComponent); + expect(second.componentInstance.elementId).not.toBe(component.elementId); + second.destroy(); + }); + + it('clears expanded state when the requirements change', () => { + setRequirements([{key: 'file0', name: 'Source', type: 'code'}]); + component.toggleExpanded(component.summaries[0]); + setRequirements([{key: 'file0', name: 'New source', type: 'code'}]); + expect(component.isExpanded(component.summaries[0])).toBe(false); + }); + + describe('invalid/unrecognised requirement type state', () => { + it('falls back to a safe label instead of crashing or showing undefined', () => { + setRequirements([{key: 'file0', name: 'Mystery file', type: 'not-a-real-type'}]); + + const text = (fixture.nativeElement as HTMLElement).textContent; + expect(text).toContain('not-a-real-type'); + expect(text).toContain('Not specified for this task'); + expect(text).not.toContain('undefined'); + }); + }); +}); diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts new file mode 100644 index 0000000000..3e9652f0e5 --- /dev/null +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts @@ -0,0 +1,57 @@ +import {ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges} from '@angular/core'; +import {UploadRequirement} from 'src/app/api/models/task-definition'; +import {UploadRequirementSummary, summariseUploadRequirement} from './upload-category'; + +let nextRequirementsId = 0; + +/** + * Shows the accepted file category, formats and required file count for a task's + * upload requirements before a student opens the file picker or drops a file. + * + * This is a display-only component: file selection, drag-and-drop and rejected-file + * feedback all remain owned by the existing `f-file-uploader` component. + */ +@Component({ + selector: 'f-task-upload-requirements', + standalone: false, + changeDetection: ChangeDetectionStrategy.Eager, + templateUrl: './task-upload-requirements.component.html', + styleUrls: ['./task-upload-requirements.component.scss'], +}) +export class TaskUploadRequirementsComponent implements OnChanges { + @Input() requirements: UploadRequirement[] | null | undefined; + + public readonly elementId = `task-upload-requirements-${nextRequirementsId++}`; + + public summaries: UploadRequirementSummary[] = []; + private expandedKeys: Set = new Set(); + + ngOnChanges(changes: SimpleChanges): void { + if (changes.requirements) { + this.summaries = (this.requirements ?? []).map((requirement) => + summariseUploadRequirement(requirement), + ); + this.expandedKeys.clear(); + } + } + + public get hasRequirements(): boolean { + return this.summaries.length > 0; + } + + public get requiredFileCount(): number { + return this.summaries.length; + } + + public isExpanded(summary: UploadRequirementSummary): boolean { + return this.expandedKeys.has(summary.key); + } + + public toggleExpanded(summary: UploadRequirementSummary): void { + if (this.expandedKeys.has(summary.key)) { + this.expandedKeys.delete(summary.key); + } else { + this.expandedKeys.add(summary.key); + } + } +} diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts new file mode 100644 index 0000000000..9d9fa79be6 --- /dev/null +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts @@ -0,0 +1,41 @@ +import {UploadRequirement} from 'src/app/api/models/task-definition'; +import {ACCEPTED_TYPES} from 'src/app/common/file-uploader/file-upload-types'; + +const CATEGORY_LABELS: Record = { + document: 'PDF', + csv: 'Spreadsheet', + code: 'Code', + image: 'Image', + zip: 'Archive', +}; + +export const EXTENSION_PREVIEW_LIMIT = 8; + +export interface UploadRequirementSummary { + key: string; + name: string; + categoryLabel: string; + extensions: string[]; + previewExtensions: string[]; + hasMoreExtensions: boolean; +} + +export function summariseUploadRequirement( + requirement: UploadRequirement, +): UploadRequirementSummary { + // Match the uploader's archive alias and read the same extension policy. + const type = requirement.type === 'archive' ? 'zip' : requirement.type; + const knownType = Object.hasOwn(ACCEPTED_TYPES, type); + const category = knownType ? ACCEPTED_TYPES[type as keyof typeof ACCEPTED_TYPES] : undefined; + const extensions = (category?.extensions ?? []).map((extension) => extension.toUpperCase()); + return { + key: requirement.key, + name: requirement.name, + categoryLabel: knownType + ? CATEGORY_LABELS[type as keyof typeof ACCEPTED_TYPES] + : type || 'File', + extensions, + previewExtensions: extensions.slice(0, EXTENSION_PREVIEW_LIMIT), + hasMoreExtensions: extensions.length > EXTENSION_PREVIEW_LIMIT, + }; +} diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index f3800531a9..e5449332cb 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -1,6 +1,11 @@ -
-

Submit {{ task.definition.abbreviation }} {{ task.definition.name }}

- +
+

+ Submit {{ task.definition.abbreviation }} {{ task.definition.name }} +

+ Submission type Submit {{ task.definition.abbreviation }} {{ task.definitio
+

+ {{ uploadAnnouncement }} +

@if (isGroupStage && !uploadStarted) {
-

Team Contribution

+

Team Contribution

Please rate each team member's effort on {{ task.definition.name }}.

@@ -42,7 +50,7 @@

Team Contribution

@if (!uploadStarted) {
-

+

Select {{ submissionType === 'need_help' ? 'files' : 'evidence' }} to upload

@@ -60,12 +68,20 @@

} + +
@if (submissionType === 'need_help') { -

What do you need help with?

+

+ What do you need help with? +

Add a short comment so your tutor knows what help you need on this task.

} @else { -

Final comments

+

Final comments

@if (task.definition.assessInPortfolioOnly) {

Please specify which areas of your submission you would like feedback on. @@ -103,22 +121,30 @@

Final comments

}
- + Comment + + @if (requiresComment) { + Required: at least {{ minCommentLength }} characters, excluding leading and + trailing spaces. + } @else { + Optional comment. + } + Character count: {{ comment.trim().length }}. + + Enter at least {{ minCommentLength }} characters, excluding leading and trailing + spaces. - -
- Character count: {{ comment.length }} - @if (requiresComment) { - (Min. {{ minCommentLength }}) - } -
@@ -135,6 +161,7 @@

Declaration

color="primary" mat-stroked-button type="button" + [attr.aria-expanded]="showPlagiarism" (click)="showPlagiarism = !showPlagiarism" > Plagiarism and collusion diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts new file mode 100644 index 0000000000..0de1180b80 --- /dev/null +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -0,0 +1,221 @@ +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatSelectModule} from '@angular/material/select'; +import {By} from '@angular/platform-browser'; +import {BehaviorSubject} from 'rxjs'; +import {ProjectService, TaskService} from 'src/app/api/models/doubtfire-model'; +import {UserService} from 'src/app/api/services/user.service'; +import {FileUploaderComponent} from 'src/app/common/file-uploader/file-uploader.component'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {EmojiService} from 'src/app/common/services/emoji.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {PrivacyPolicy} from 'src/app/config/privacy-policy/privacy-policy'; +import {TaskUploadRequirementsComponent} from './task-upload-requirements/task-upload-requirements.component'; +import {UploadSubmissionModalComponent} from './upload-submission-modal.component'; + +describe('UploadSubmissionModalComponent upload guidance', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], + declarations: [ + UploadSubmissionModalComponent, + FileUploaderComponent, + TaskUploadRequirementsComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + isTestSubmission: true, + task: { + definition: { + abbreviation: '1.1P', + assessInPortfolioOnly: false, + name: 'Upload evidence', + uploadRequirements: [{key: 'file0', name: 'Source code', type: 'code'}], + }, + testSubmissionUrl: () => '/api/test-submission', + isGroupTask: () => false, + }, + }, + }, + {provide: MatDialogRef, useValue: {}}, + {provide: TaskService, useValue: {}}, + {provide: ProjectService, useValue: {}}, + {provide: PrivacyPolicy, useValue: {}}, + {provide: AlertService, useValue: {}}, + {provide: EmojiService, useValue: {}}, + { + provide: UserService, + useValue: {currentUser: {authenticationToken: 'demo-token', username: 'demo'}}, + }, + {provide: DoubtfireConstants, useValue: {ExternalName: new BehaviorSubject('OnTrack')}}, + ], + }).compileComponents(); + fixture = TestBed.createComponent(UploadSubmissionModalComponent); + fixture.detectChanges(); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('shows guidance before selection and describes the real file picker controls', () => { + const root: HTMLElement = fixture.nativeElement; + const button = root.querySelector('.file-drop-zone')!; + const input = root.querySelector('input[type=file]')!; + const help = root.querySelector('.task-upload-requirements')!; + expect(help.textContent).toContain('Files required: 1'); + expect(button.getAttribute('aria-describedby')).toBe(help.id); + expect(input.getAttribute('aria-describedby')).toBe(help.id); + expect(help.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(input.accept).toContain('.vue'); + expect(fixture.componentInstance.isUploaderReady).toBe(false); + }); + + it('preserves invalid-file feedback and enables submission only after an accepted selection', () => { + const input: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]'); + Object.defineProperty(input, 'files', { + configurable: true, + value: [new File(['x'], 'bad.exe')], + }); + input.dispatchEvent(new Event('change')); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Invalid file provided'); + expect(fixture.componentInstance.isUploaderReady).toBe(false); + + Object.defineProperty(input, 'files', { + configurable: true, + value: [new File(['x'], 'source.vue')], + }); + input.dispatchEvent(new Event('change')); + fixture.detectChanges(); + expect(fixture.componentInstance.isUploaderReady).toBe(true); + expect(fixture.nativeElement.textContent).toContain('source.vue'); + }); + + it('associates the required comment instructions and exposes the same trimmed validation as Submit', async () => { + const component = fixture.componentInstance; + component.onSubmissionTypeChange('need_help'); + component.onReadyChange(true); + component.goToCommentsStage(); + fixture.detectChanges(); + await fixture.whenStable(); + const comment: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea'); + const hintIds = comment.getAttribute('aria-describedby')!.split(' '); + const help = hintIds.map((id) => document.getElementById(id)?.textContent).join(' '); + expect(comment.required).toBe(true); + expect(help).toContain('at least 25 characters'); + expect(help).toContain('excluding leading and trailing spaces'); + expect(component.shouldDisableSubmit()).toBe(true); + + comment.value = 'short'; + comment.dispatchEvent(new Event('input')); + comment.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(comment.getAttribute('aria-invalid')).toBe('true'); + expect(fixture.nativeElement.querySelector('mat-error').textContent).toContain( + 'at least 25 characters', + ); + + comment.value = ' Please help with this demonstration task. '; + comment.dispatchEvent(new Event('input')); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.shouldDisableSubmit()).toBe(false); + expect(comment.getAttribute('aria-invalid')).not.toBe('true'); + expect(fixture.nativeElement.textContent).toContain( + `Character count: ${comment.value.trim().length}`, + ); + }); + + it('moves focus to the new stage after Next and Back without exposing a hidden heading', async () => { + const component = fixture.componentInstance; + component.onSubmissionTypeChange('need_help'); + component.onReadyChange(true); + fixture.detectChanges(); + await fixture.whenStable(); + component.goToCommentsStage(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.activeElement?.textContent).toContain('What do you need help with?'); + expect(document.activeElement?.getAttribute('tabindex')).toBe('-1'); + + component.goToDetailsStage(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(document.activeElement?.textContent).toContain('Select files to upload'); + }); + + it('keeps optional comments optional and announces upload start in one stable status region', async () => { + const component = fixture.componentInstance; + component.onSubmissionTypeChange('ready_for_feedback'); + component.onReadyChange(true); + component.goToCommentsStage(); + fixture.detectChanges(); + await fixture.whenStable(); + const comment: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea'); + expect(comment.required).toBe(false); + expect(component.shouldDisableSubmit()).toBe(false); + const region = fixture.nativeElement.querySelector('mat-dialog-content > [role="status"]'); + expect(region.textContent.trim()).toBe(''); + component.onUploaderReady(component.onBeforeUpload); + component.uploadButtonClicked(); + fixture.detectChanges(); + expect(region.textContent).toContain('Uploading submission'); + expect(fixture.nativeElement.querySelector('mat-dialog-content > [role="status"]')).toBe( + region, + ); + component.onUploadFailure(); + fixture.detectChanges(); + expect(region.textContent).toContain('Submission upload failed'); + }); + + it('announces a retry and moves focus away from the removed Retry Upload button', async () => { + vi.spyOn(XMLHttpRequest.prototype, 'open').mockImplementation(() => {}); + vi.spyOn(XMLHttpRequest.prototype, 'setRequestHeader').mockImplementation(() => {}); + const send = vi.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(() => {}); + const input: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]'); + Object.defineProperty(input, 'files', { + configurable: true, + value: [new File(['demo'], 'source.vue')], + }); + input.dispatchEvent(new Event('change')); + const component = fixture.componentInstance; + const uploader = fixture.debugElement.query(By.directive(FileUploaderComponent)) + .componentInstance as FileUploaderComponent; + component.uploadStarted = true; + uploader.isUploading = true; + uploader.uploadingInfo = { + progress: 100, + complete: true, + success: false, + error: 'Demonstration error', + }; + uploader.onFailure?.({error: 'Demonstration error'}); + fixture.detectChanges(); + const region = fixture.nativeElement.querySelector('mat-dialog-content > [role="status"]'); + expect(region.textContent).toContain('Retry Upload or Cancel'); + const retry = [...fixture.nativeElement.querySelectorAll('button')].find( + (button: HTMLButtonElement) => button.textContent?.trim() === 'Retry Upload', + ) as HTMLButtonElement; + retry.focus(); + retry.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(send).toHaveBeenCalledOnce(); + expect(region.textContent).toContain('Uploading submission'); + expect(retry.isConnected).toBe(false); + expect(document.activeElement).toBe( + fixture.nativeElement.querySelector('h2[mat-dialog-title]'), + ); + }); +}); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 5107f3f584..fc66466402 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -1,4 +1,15 @@ -import {ChangeDetectionStrategy, Component, Inject, OnInit, ViewChild} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Inject, + Injector, + OnInit, + ViewChild, + afterNextRender, + inject, +} from '@angular/core'; +import {ErrorStateMatcher} from '@angular/material/core'; import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; import {MemberContribution} from 'src/app/api/models/groups/group'; import {Task} from 'src/app/api/models/task'; @@ -62,6 +73,11 @@ export type UploadSubmissionModalResult = }) export class UploadSubmissionModalComponent implements OnInit { @ViewChild(FileUploaderComponent) private fileUploader?: FileUploaderComponent; + @ViewChild('groupHeading') private groupHeading?: ElementRef; + @ViewChild('detailsHeading') private detailsHeading?: ElementRef; + @ViewChild('commentsHeading') private commentsHeading?: ElementRef; + @ViewChild('dialogTitle') private dialogTitle?: ElementRef; + private readonly injector = inject(Injector); public readonly minCommentLength = 25; public readonly task = this.data.task; @@ -88,6 +104,13 @@ export class UploadSubmissionModalComponent implements OnInit { public isUploaderReady = false; public uploadStarted = false; public uploadSubmitLocked = false; + public uploadAnnouncement = ''; + public readonly commentErrorStateMatcher: ErrorStateMatcher = { + isErrorState: (control) => + !!control?.touched && + this.requiresComment && + this.comment.trim().length < this.minCommentLength, + }; private uploadResponse: UploadSubmissionResponse | null = null; private startUpload?: () => void; @@ -204,17 +227,36 @@ export class UploadSubmissionModalComponent implements OnInit { public goToCommentsStage(): void { if (this.showCommentsSection && !this.shouldDisableNext()) { this.currentStage = 'comments'; + this.focusStageHeading(); } } public goToGroupStage(): void { if (this.showGroupSection) { this.currentStage = 'group'; + this.focusStageHeading(); } } public goToDetailsStage(): void { this.currentStage = 'details'; + this.focusStageHeading(); + } + + private focusStageHeading(): void { + afterNextRender( + () => { + const heading = this.uploadStarted + ? this.dialogTitle + : this.isGroupStage + ? this.groupHeading + : this.isCommentsStage + ? this.commentsHeading + : this.detailsHeading; + heading?.nativeElement.focus(); + }, + {injector: this.injector}, + ); } public cancel = (): void => { @@ -231,6 +273,8 @@ export class UploadSubmissionModalComponent implements OnInit { } public onBeforeUpload = (): void => { + this.uploadAnnouncement = 'Uploading submission. Please wait for the result.'; + this.focusStageHeading(); Object.keys(this.payload).forEach((key) => delete this.payload[key]); if (this.showGroupSection) { @@ -257,6 +301,7 @@ export class UploadSubmissionModalComponent implements OnInit { public onUploadSuccess = (response: unknown): void => { if (this.isValidUploadResponse(response)) { this.uploadResponse = response; + this.uploadAnnouncement = 'Submission uploaded successfully.'; if (this.data.isTestSubmission) { this.projectService.loadProject(response.project_id, this.task.unit).subscribe({ @@ -277,6 +322,11 @@ export class UploadSubmissionModalComponent implements OnInit { ); }; + public onUploadFailure = (): void => { + this.uploadAnnouncement = + 'Submission upload failed. Review the error below, then choose Retry Upload or Cancel.'; + }; + public onUploadComplete = (): void => { this.uploadSubmitLocked = false; @@ -298,7 +348,7 @@ export class UploadSubmissionModalComponent implements OnInit { : response.status; this.task.updateFromJson(response, this.taskService.mapping); - this.task.processTaskStatusChange(expectedStatus as TaskStatusEnum, this.alertService); + this.task.processTaskStatusChange(expectedStatus as TaskStatusEnum, this.alertService, true); }, 1500); }; @@ -334,6 +384,7 @@ export class UploadSubmissionModalComponent implements OnInit { private resetUploadState(): void { this.uploadStarted = false; + this.uploadAnnouncement = ''; this.uploadSubmitLocked = false; this.uploadResponse = null; this.currentStage = this.showGroupSection ? 'group' : 'details'; diff --git a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.accessibility.spec.ts b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.accessibility.spec.ts new file mode 100644 index 0000000000..bb788e33fd --- /dev/null +++ b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.accessibility.spec.ts @@ -0,0 +1,73 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; +import {AttachmentConfirmationDialogComponent} from './attachment-confirmation-dialog.component'; + +describe('attachment confirmation keyboard controls', () => { + let fixture: ComponentFixture; + let dialog: {close: ReturnType}; + + beforeEach(async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:attachment'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + dialog = {close: vi.fn()}; + await TestBed.configureTestingModule({ + declarations: [AttachmentConfirmationDialogComponent], + imports: [MatButtonModule, MatDialogModule, MatIconModule], + providers: [ + {provide: MatDialogRef, useValue: dialog}, + { + provide: MAT_DIALOG_DATA, + useValue: {file: new File(['pdf'], 'work.pdf', {type: 'application/pdf'})}, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(AttachmentConfirmationDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture?.destroy(); + vi.restoreAllMocks(); + }); + + it.each([ + ['Cancel', false], + ['Post Attachment', true], + ] as const)('allows Enter to activate %s', (label, confirmed) => { + const buttons: HTMLButtonElement[] = Array.from( + fixture.nativeElement.querySelectorAll('button'), + ); + const button = buttons.find((candidate) => candidate.textContent.trim() === label); + expect(button.type).toBe('button'); + button.focus(); + expect(document.activeElement).toBe(button); + + const reachedOutsideDialog = vi.fn(); + fixture.nativeElement.addEventListener('keydown', reachedOutsideDialog); + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + button.dispatchEvent(enter); + expect(enter.defaultPrevented).toBe(false); + // Keep native activation while containing the dialog's Enter from outer shortcuts. + expect(reachedOutsideDialog).not.toHaveBeenCalled(); + expect(dialog.close).not.toHaveBeenCalled(); + // jsdom does not synthesize the native button activation from Enter. + button.click(); + + expect(dialog.close).toHaveBeenCalledExactlyOnceWith(confirmed); + }); + + it('does not swallow Enter inside the audio preview controls', () => { + fixture.componentInstance.file = new File(['audio'], 'feedback.mp3', {type: 'audio/mpeg'}); + fixture.detectChanges(); + const audio: HTMLAudioElement = fixture.nativeElement.querySelector('audio'); + expect(audio.controls).toBe(true); + const enter = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}); + audio.dispatchEvent(enter); + + expect(enter.defaultPrevented).toBe(false); + expect(dialog.close).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html index d74eebfffc..f4d64834b2 100644 --- a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html +++ b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html @@ -1,5 +1,6 @@ -
+

Post Attachment?

+

This attachment is ready to post as a task comment.

@@ -29,6 +30,7 @@

Post Attachment?

{{ file.type || 'Audio file' }}
+
} @else { @@ -36,18 +38,19 @@

Post Attachment?

attach_file
{{ file.name }} - {{ file.type || 'Attachment' }} + {{ data.category || 'Attachment' }} — download only
}
-

- {{ file.name }} +

+ File: {{ file.name }} + Type: {{ data.category || file.type || 'Unknown' }} @if (file.size) { - ({{ formatFileSize(file.size) }}) + Size: {{ formatFileSize(file.size) }} } -

+
diff --git a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.spec.ts b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.spec.ts new file mode 100644 index 0000000000..12598e6e15 --- /dev/null +++ b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.spec.ts @@ -0,0 +1,46 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {AttachmentConfirmationDialogComponent} from './attachment-confirmation-dialog.component'; + +describe('AttachmentConfirmationDialogComponent keyboard actions', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AttachmentConfirmationDialogComponent], + providers: [ + {provide: MatDialogRef, useValue: {close: vi.fn()}}, + { + provide: MAT_DIALOG_DATA, + useValue: {file: new File(['value\n7\n'], 'scores.csv', {type: 'text/csv'})}, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + it('allows native Enter activation without forwarding Enter to the comment composer', () => { + const fixture = TestBed.createComponent(AttachmentConfirmationDialogComponent); + fixture.detectChanges(); + const host: HTMLElement = fixture.nativeElement; + const parentKeydown = vi.fn(); + host.addEventListener('keydown', parentKeydown); + + for (const button of host.querySelectorAll('button')) { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }); + button.dispatchEvent(event); + // Preventing this default action suppresses native button activation in browsers. + expect(event.defaultPrevented).toBe(false); + expect(parentKeydown).not.toHaveBeenCalled(); + } + + host.querySelectorAll('button')[0].click(); + expect(TestBed.inject(MatDialogRef).close).toHaveBeenLastCalledWith(false); + host.querySelectorAll('button')[1].click(); + expect(TestBed.inject(MatDialogRef).close).toHaveBeenLastCalledWith(true); + }); +}); diff --git a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.ts b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.ts index 644281e04e..207c39e5b1 100644 --- a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.ts +++ b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.ts @@ -3,6 +3,7 @@ import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; export interface AttachmentConfirmationDialogData { file: File; + category?: string; } @Component({ @@ -22,7 +23,9 @@ export class AttachmentConfirmationDialogComponent implements OnInit, OnDestroy ngOnInit() { this.file = this.data.file; - this.previewUrl = URL.createObjectURL(this.file); + if (this.isImage || this.isAudio) { + this.previewUrl = URL.createObjectURL(this.file); + } } ngOnDestroy() { diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.component.ts b/src/app/tasks/task-comment-composer/task-comment-composer.component.ts index 112e601253..1bf4e2d1fd 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.component.ts +++ b/src/app/tasks/task-comment-composer/task-comment-composer.component.ts @@ -13,21 +13,29 @@ import { KeyValueDiffer, KeyValueDiffers, OnChanges, + OnInit, QueryList, SimpleChanges, ViewChild, ViewChildren, } from '@angular/core'; import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from '@angular/material/dialog'; -import {BehaviorSubject, Subscription} from 'rxjs'; +import {BehaviorSubject, Subscription, finalize} from 'rxjs'; import { FeedbackTemplate, Task, TaskComment, TaskCommentService, } from 'src/app/api/models/doubtfire-model'; +import {UserService} from 'src/app/api/models/doubtfire-model'; +import { + AttachmentPolicy, + attachmentCategory, + attachmentError, +} from 'src/app/api/models/task-comment/attachment-policy'; import {AlertService} from 'src/app/common/services/alert.service'; import {EmojiService} from 'src/app/common/services/emoji.service'; +import {ThemeService} from 'src/app/common/theme/theme.service'; import {TaskCommentsViewerComponent} from '../task-comments-viewer/task-comments-viewer.component'; import {AttachmentConfirmationDialogComponent} from './attachment-confirmation-dialog/attachment-confirmation-dialog.component'; @@ -48,20 +56,12 @@ export interface TaskCommentComposerData { editingComment: TaskComment; } -const ACCEPTED_FILE_TYPES = [ - 'audio/mpeg', - 'audio/vorbis', - 'audio/mp4', - 'audio/ogg', - 'audio/wav', - 'audio/x-wav', - 'audio/webm', +const APPROVED_CLIPBOARD_IMAGE_TYPES = [ 'image/png', - 'image/pdf', - 'application/pdf', - 'image/gif', - 'image/jpg', + 'image/bmp', + 'image/tiff', 'image/jpeg', + 'image/gif', ]; /** @@ -80,13 +80,14 @@ const ACCEPTED_FILE_TYPES = [ changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnChanges { +export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnChanges, OnInit { @Input() task: Task; @Input() sharedData: TaskCommentComposerData; public $userIsTyping: BehaviorSubject = new BehaviorSubject(false); private draftSaveSubscription = new Subscription(); private readonly DRAFT_KEY_PREFIX = 'task_comment_draft_'; + private readonly SUBMITTED_KEY_PREFIX = 'task_comments_submitted_'; public isDraftLoaded = false; private submittedTaskIds: Set = new Set(); @@ -122,11 +123,14 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC private alerts: AlertService, @Inject(TaskCommentService) private taskCommentService: TaskCommentService, private cdRef: ChangeDetectorRef, + private userService: UserService, + public readonly theme: ThemeService, ) { this.differ = this.differs.find({}).create(); - // submitted tasks from sessionStorage + // submitted tasks from sessionStorage, for this user only try { - const saved = sessionStorage.getItem('task_comments_submitted'); + const key = this.submittedKey(); + const saved = key ? sessionStorage.getItem(key) : null; if (saved) { this.submittedTaskIds = new Set(JSON.parse(saved)); } @@ -135,6 +139,43 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } } + public attachmentPolicy: AttachmentPolicy | null = null; + public attachmentPolicyFailed = false; + public attachmentsUploading = 0; + + ngOnInit(): void { + this.taskCommentService.attachmentPolicy().subscribe({ + next: (policy) => { + this.attachmentPolicy = policy; + this.cdRef.markForCheck(); + }, + error: () => { + this.attachmentPolicyFailed = true; + this.cdRef.markForCheck(); + }, + }); + } + + public get attachmentAccept(): string { + return ( + this.attachmentPolicy?.categories + .flatMap((item) => item.extensions.map((ext) => `.${ext}`)) + .join(',') || '' + ); + } + + public get attachmentGuidance(): string { + if (!this.attachmentPolicy) { + return this.attachmentPolicyFailed + ? 'Attachment requirements are unavailable. Reload to try again. You can still send text.' + : 'Loading attachment requirements…'; + } + const formats = this.attachmentPolicy.categories + .map((item) => `${item.name}: ${item.extensions.join(', ').toUpperCase()}`) + .join('; '); + return `${formats}. Each file must be smaller than ${this.attachmentPolicy.max_bytes_exclusive / 1_000_000} MB. Select up to ${this.attachmentPolicy.max_selection_count} files; confirm each separately.`; + } + ngOnChanges(changes: SimpleChanges) { this.showFeedbackTemplatePicker = false; @@ -199,10 +240,10 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC // Update session storage try { - sessionStorage.setItem( - 'task_comments_submitted', - JSON.stringify([...this.submittedTaskIds]), - ); + const submittedKey = this.submittedKey(); + if (submittedKey) { + sessionStorage.setItem(submittedKey, JSON.stringify([...this.submittedTaskIds])); + } } catch (e) { console.error('Error saving submitted tasks:', e); } @@ -215,10 +256,39 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC this.saveCurrentDraft(); } - private getDraftKey(task: Task): string { + // The id of whoever is signed in, or null during sign out when currentUser has + // already been swapped for the anonymous user. A draft with nobody to own it is + // not worth keeping, so callers return early on null rather than inventing a + // key. The id and not the username or the email: ids are stable, and an email + // in a storage key is personal data sitting in plain sight in dev tools. + private currentUserId(): number | null { + const id = this.userService?.currentUser?.id; + return typeof id === 'number' && id > 0 ? id : null; + } + + private submittedKey(): string | null { + const userId = this.currentUserId(); + return userId === null ? null : `${this.SUBMITTED_KEY_PREFIX}${userId}`; + } + + // The key used to name a task identified the task and never the person, so on a + // shared machine the next person to open the same task was handed the previous + // person's unsent words. + // + // The user segment is written as uid rather than the bare number. A legacy + // key is task_comment_draft_ or task_comment_draft__, + // so a bare number would make task_comment_draft_5_7 mean both "user 5, task 7" + // and "project 5, definition 7". The marker makes the two shapes impossible to + // confuse, which is what lets sign out sweep the old ones safely. + private getDraftKey(task: Task): string | null { + const userId = this.currentUserId(); + if (userId === null) { + return null; + } + // If task has an ID, use it if (task.id) { - return `${this.DRAFT_KEY_PREFIX}${task.id}`; + return `${this.DRAFT_KEY_PREFIX}uid${userId}_${task.id}`; } // For "not started" tasks, create a composite key using only valid properties @@ -226,7 +296,7 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC // Fix: Use task.definition.id instead of task.definition_id const definitionId = task.definition?.id || 'unknown'; - return `${this.DRAFT_KEY_PREFIX}${projectId}_${definitionId}`; + return `${this.DRAFT_KEY_PREFIX}uid${userId}_${projectId}_${definitionId}`; } private hasContent(raw: string): boolean { @@ -240,6 +310,9 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } const draftKey = this.getDraftKey(task); + if (draftKey === null) { + return; + } try { let raw: string; @@ -277,6 +350,10 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } const draftKey = this.getDraftKey(task); + if (draftKey === null) { + return; + } + try { const draft = localStorage.getItem(draftKey); @@ -538,10 +615,10 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC this.submittedTaskIds.add(taskKey); try { - sessionStorage.setItem( - 'task_comments_submitted', - JSON.stringify([...this.submittedTaskIds]), - ); + const submittedKey = this.submittedKey(); + if (submittedKey) { + sessionStorage.setItem(submittedKey, JSON.stringify([...this.submittedTaskIds])); + } } catch (e) { console.error('Error saving submitted tasks:', e); } @@ -586,8 +663,6 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC next: (_success: TaskComment) => { this.comment.text = ''; this.commentsViewer.scrollDown(); - console.log('implement - check map comments'); - //this.task.comments = this.ts.mapComments(this.task.comments); }, error: (message: string) => this.alerts.error(message, 6000), }); @@ -599,15 +674,7 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC handlePaste(event: ClipboardEvent) { const files = this.getClipboardFiles(event); - - if (files.length === 0) { - return; - } - - const existingText = this.input?.first?.nativeElement?.innerText ?? ''; - event.preventDefault(); - this.clearPastedPlaceholderContent(existingText); - this.uploadFiles(files); + this.handleClipboardFiles(files, event); } handleBeforeInput(event: InputEvent) { @@ -616,36 +683,82 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } const files = Array.from(event.dataTransfer?.files ?? []); - - if (files.length === 0) { - return; - } - - const existingText = this.input?.first?.nativeElement?.innerText ?? ''; - event.preventDefault(); - this.clearPastedPlaceholderContent(existingText); - this.uploadFiles(files); + this.handleClipboardFiles(files, event); } uploadFiles(files: ArrayLike) { + if (!this.attachmentPolicy) { + this.alerts.error('Attachment requirements are unavailable. Reload and try again.', 6000); + return; + } + if (files.length > this.attachmentPolicy.max_selection_count) { + this.alerts.error( + `Choose at most ${this.attachmentPolicy.max_selection_count} attachments at a time.`, + 6000, + ); + this.resetUploader(); + return; + } const acceptedFiles: File[] = []; - Array.from(files).forEach((file) => { - if ( - ACCEPTED_FILE_TYPES.includes(file.type) || - file.type.startsWith('audio/') || - file.type.startsWith('image/') - ) { - acceptedFiles.push(file); + if (!attachmentCategory(this.attachmentPolicy, file)) { + this.alerts.error('Unsupported attachment format. Choose one of the listed formats.', 6000); + } else if (file.size === 0 || file.size >= this.attachmentPolicy.max_bytes_exclusive) { + this.alerts.error('Attachments must not be empty and must be smaller than 30 MB.', 6000); } else { - this.alerts.error('Cannot upload that file - only images, audio, and PDFs.', 4000); + acceptedFiles.push(file); } }); - this.confirmAttachmentsSequentially(acceptedFiles); this.resetUploader(); } + private lastClipboardPasteSignature = ''; + private lastClipboardPasteAt = 0; + private readonly CLIPBOARD_DUPLICATE_WINDOW_MS = 250; + + private handleClipboardFiles(files: File[], event: ClipboardEvent | InputEvent) { + if (files.length === 0) { + return; + } + + event.preventDefault(); + + const approvedImages = files.filter((file) => + APPROVED_CLIPBOARD_IMAGE_TYPES.includes(file.type.toLowerCase()), + ); + + if (approvedImages.length !== files.length) { + this.alerts.error('Clipboard paste supports approved image files only.', 4000); + } + + if (approvedImages.length === 0) { + return; + } + + const signature = approvedImages + .map((file) => `${file.name}:${file.type}:${file.size}:${file.lastModified}`) + .sort() + .join('|'); + + const now = Date.now(); + + if ( + signature === this.lastClipboardPasteSignature && + now - this.lastClipboardPasteAt < this.CLIPBOARD_DUPLICATE_WINDOW_MS + ) { + return; + } + + this.lastClipboardPasteSignature = signature; + this.lastClipboardPasteAt = now; + + const existingText = this.input?.first?.nativeElement?.innerText ?? ''; + + this.clearPastedPlaceholderContent(existingText); + this.uploadFiles(approvedImages); + } + private getClipboardFiles(event: ClipboardEvent): File[] { const clipboardData = event.clipboardData; @@ -678,16 +791,20 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC }); } - // # Upload image files as comments to a given task - postAttachmentComment(file) { - this.taskCommentService.addComment(this.task, file, 'file', null).subscribe( - (_tc: TaskComment) => { - this.commentsViewer.scrollDown(); - }, - (error: Error) => { - this.alerts.error(error.message, 2000); - }, - ); + postAttachmentComment(file: File) { + this.attachmentsUploading++; + this.taskCommentService + .addComment(this.task, file, 'file', null) + .pipe( + finalize(() => { + this.attachmentsUploading--; + this.cdRef.markForCheck(); + }), + ) + .subscribe({ + next: () => this.commentsViewer.scrollDown(), + error: (error) => this.alerts.error(attachmentError(error), 6000), + }); } private confirmAttachmentsSequentially(files: File[], index: number = 0) { @@ -698,6 +815,7 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC const dialogRef = this.dialog.open(AttachmentConfirmationDialogComponent, { data: { file: files[index], + category: attachmentCategory(this.attachmentPolicy, files[index])?.name, }, maxWidth: '720px', width: 'min(92vw, 720px)', @@ -790,7 +908,7 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } // The discussion prompt composer dialog Component -// eslint-disable-next-line max-classes-per-file + @Component({ selector: 'discussion-prompt-composer-dialog.html', templateUrl: 'discussion-prompt-composer-dialog.html', diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts b/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts new file mode 100644 index 0000000000..ccf3172095 --- /dev/null +++ b/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts @@ -0,0 +1,145 @@ +import {EmojiSearch} from '@ctrl/ngx-emoji-mart'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {CommonModule} from '@angular/common'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatDialog} from '@angular/material/dialog'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {of} from 'rxjs'; +import {TaskCommentService, UserService} from 'src/app/api/models/doubtfire-model'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {EmojiService} from 'src/app/common/services/emoji.service'; +import {ThemeService} from 'src/app/common/theme/theme.service'; +import {TaskCommentsViewerComponent} from '../task-comments-viewer/task-comments-viewer.component'; +import {TaskCommentComposerComponent} from './task-comment-composer.component'; + +function memoryStorage(): Storage { + const values: Map = new Map(); + + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => Array.from(values.keys())[index] ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, String(value)), + }; +} + +describe('TaskCommentComposerComponent phone actions', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + // Node exposes storage globals without values unless it is launched with storage files. + // Use isolated browser-compatible stores while rendering this storage-aware component. + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage(), + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: memoryStorage(), + }); + + await TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [TaskCommentComposerComponent], + providers: [ + provideNoopAnimations(), + {provide: MatDialog, useValue: {}}, + {provide: EmojiSearch, useValue: {}}, + {provide: EmojiService, useValue: {}}, + {provide: TaskCommentsViewerComponent, useValue: {}}, + {provide: AlertService, useValue: {}}, + { + provide: TaskCommentService, + useValue: { + attachmentPolicy: () => + of({ + version: 1, + categories: [], + max_bytes_exclusive: 30_000_000, + max_selection_count: 5, + }), + }, + }, + {provide: UserService, useValue: {currentUser: {id: 1}}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskCommentComposerComponent); + fixture.componentRef.setInput('task', { + id: 123, + unit: {currentUserIsStaff: false}, + }); + fixture.componentRef.setInput('sharedData', { + originalComment: null, + editingComment: null, + }); + fixture.detectChanges(); + }); + + const componentStyles = (): string => + ( + TaskCommentComposerComponent as unknown as { + ɵcmp: {styles: string[]}; + } + ).ɵcmp.styles.join('\n'); + + it('keeps clear accessible names on the attachment and microphone controls', () => { + const attach = fixture.nativeElement.querySelector( + 'button[aria-label="Attach a file"]', + ) as HTMLButtonElement; + const record = fixture.nativeElement.querySelector( + 'button[aria-label="Record audio feedback"]', + ) as HTMLButtonElement; + + expect(attach).toBeTruthy(); + expect(record).toBeTruthy(); + expect(attach.classList).toContain('composer-touch-action'); + expect(record.classList).toContain('composer-touch-action'); + }); + + it('updates the emoji picker when the resolved theme changes', () => { + const theme = TestBed.inject(ThemeService); + const picker = fixture.nativeElement.querySelector('emoji-mart') as HTMLElement & { + darkMode: boolean; + }; + + theme.setPreference('dark'); + fixture.detectChanges(); + expect(picker.darkMode).toBe(true); + + theme.setPreference('light'); + fixture.detectChanges(); + expect(picker.darkMode).toBe(false); + }); + + it('gives both phone controls 48px touch targets without changing their desktop rule', () => { + const styles = componentStyles(); + const phoneMediaStart = styles.indexOf('@media (max-width: 639.98px)'); + const desktopStyles = styles.slice(0, phoneMediaStart); + const phoneStyles = styles.slice(phoneMediaStart); + + expect(phoneMediaStart).toBeGreaterThan(-1); + expect(desktopStyles).not.toContain('composer-touch-action'); + expect(phoneStyles).toMatch(/min-width:\s*48px/); + expect(phoneStyles).toMatch(/min-height:\s*48px/); + expect(phoneStyles).toMatch(/width:\s*48px/); + expect(phoneStyles).toMatch(/height:\s*48px/); + expect(phoneStyles).toMatch(/transform:\s*none/); + expect(phoneStyles).toMatch(/border:\s*1px solid var\(--ot-color-border\)/); + expect(phoneStyles).toMatch(/background-color:\s*var\(--ot-color-page\)/); + }); + + it('keeps the phone composer away from both safe-area edges', () => { + const styles = componentStyles(); + const phoneStyles = styles.slice(styles.indexOf('@media (max-width: 639.98px)')); + + expect(phoneStyles).toMatch(/padding-left:\s*max\(8px,\s*env\(safe-area-inset-left\)\)/); + expect(phoneStyles).toMatch(/padding-right:\s*max\(8px,\s*env\(safe-area-inset-right\)\)/); + }); +}); diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html index 92a1002ce2..7e2b6b79c6 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html @@ -1,4 +1,17 @@
+
+ + Allow automatic post-feedback extensions + +

+ Apply the unit's resubmission extension setting when feedback requires more work near the due + date. Turning this off affects future feedback only; existing extensions are kept. The unit + must grant resubmission weeks and use fixed dates for this rule to apply. +

+
Restrict updates diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html index c0c8ff6d21..786fc89613 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html @@ -1,10 +1,21 @@ - +
- @@ -12,21 +23,34 @@ - - @@ -35,12 +59,25 @@ - @@ -48,10 +85,18 @@ - -
Filename + - + Type - + + Code - Document + PDF + Spreadsheet (CSV, XLS, XLSX) Image - ZIP + Archive (ZIP, TAR, TAR.GZ, TGZ) + @if (upreq.type === 'archive') { + Archive (legacy) + } Submission History + Check Similarity + @if (upreq.type === 'document' && tiiEnabled()) { - TurnItIn + TurnItIn } @if (upreq.type === 'code') { - Jplag + Jplag } Flag At + @if (upreq.type === 'document' && upreq.tiiCheck && tiiEnabled()) { - +  % } @@ -61,11 +106,11 @@ + diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss index 801341d8d1..c20dee570a 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss @@ -4,5 +4,6 @@ } .mat-toolbar { - background-color: white; + background-color: var(--ot-color-surface); + color: var(--ot-color-text); } diff --git a/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts index c965168641..b03e252a6f 100644 --- a/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts +++ b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts @@ -139,7 +139,7 @@ export class D2lTransferComponent implements OnInit { /** * The about doubtfire modal service - used to create and show the modal */ -// eslint-disable-next-line max-classes-per-file + @Injectable() export class D2lTransferModal { constructor(public dialog: MatDialog) {} diff --git a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html index f96d14c411..659b786b6d 100644 --- a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html +++ b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html @@ -108,197 +108,143 @@

Mark portfolios

@if (loading) {
-
- @for ( - column of [ - {columnWidth: '110px', rowWidth: '100%'}, - {columnWidth: '170px', rowWidth: '50%'}, - {columnWidth: '180px', rowWidth: '50%'}, - {columnWidth: '130px', rowWidth: '75%'}, - {columnWidth: '80px', rowWidth: '28px'}, - {columnWidth: '80px', rowWidth: '28px'}, - {columnWidth: '180px', rowWidth: '100%'}, - {columnWidth: '400px', rowWidth: '100%'}, - {columnWidth: '80px', rowWidth: '100%'}, - ]; - track $index - ) { - - } -
- - @for (row of [0, 1, 2, 3, 4, 5, 6, 7]; track row) { -
- @for ( - column of [ - {columnWidth: '110px', rowWidth: '100%'}, - {columnWidth: '170px', rowWidth: '100%'}, - {columnWidth: '180px', rowWidth: '75%'}, - {columnWidth: '130px', rowWidth: '70%'}, - {columnWidth: '80px', rowWidth: '28px'}, - {columnWidth: '80px', rowWidth: '28px'}, - {columnWidth: '180px', rowWidth: '100%'}, - {columnWidth: '400px', rowWidth: '100%'}, - {columnWidth: '80px', rowWidth: '100%'}, - ]; - track $index - ) { - @if ($index === 4 || $index === 5) { - - } @else { - - } - } -
- } + +
} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @if (!loading && dataSource.filteredData.length === 0) { + + } - - - -
Student - {{ project.student.studentId || project.student.username }} - Name - {{ project.student.name }} - Tutor - {{ project.tutorNames() }} - Tutorial - {{ project.shortTutorialDescription() }} - Target - - Submitted as - - Submission Date - {{ project.portfolioSubmissionDate | date: 'EEE d MMM y, h:mm a' }} - Has Portfolio - {{ project.hasPortfolio ? 'Yes' : 'No' }} - Stats -
- @for (bar of project.taskStats; track bar) { -
- @if (bar.key === 'not_started') { - {{ bar.value }}% - } - @if (bar.key === 'complete') { - {{ bar.value }}% - } -
- } -
-
Grade - {{ project.grade }} - View - -
No students found
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Student + {{ project.student.studentId || project.student.username }} + Name + {{ project.student.name }} + Tutor + {{ project.tutorNames() }} + Tutorial + {{ project.shortTutorialDescription() }} + Target + + Submitted as + + Submission Date + {{ project.portfolioSubmissionDate | date: 'EEE d MMM y, h:mm a' }} + Has Portfolio + {{ project.hasPortfolio ? 'Yes' : 'No' }} + Stats +
+ @for (bar of project.taskStats; track bar) { +
+ @if (bar.key === 'not_started') { + {{ bar.value }}% + } + @if (bar.key === 'complete') { + {{ bar.value }}% + } +
+ } +
+
Grade + {{ project.grade }} + View + +
+
true, + tutorNames: () => '', + shortTutorialDescription: () => '', + taskStats: [], + } as unknown as Project; +} + +describe('PortfoliosListComponent empty state', () => { + afterEach(() => vi.restoreAllMocks()); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PortfoliosListComponent], + imports: [MatTableModule, SkeletonLoaderComponent, EmptyStateComponent], + providers: [ + { + provide: TaskService, + useValue: {statusColors: new Map(), statusLabels: new Map()}, + }, + {provide: UserService, useValue: {}}, + {provide: GradeService, useValue: {gradeValuesFor: () => [], gradeLabel: () => ''}}, + {provide: FileDownloaderService, useValue: {}}, + {provide: UnitService, useValue: {}}, + {provide: AlertService, useValue: {}}, + {provide: SidekiqProgressModalService, useValue: {}}, + {provide: D2lTransferModal, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + it('renders the empty state only while the filtered list has no rows', () => { + const fixture = TestBed.createComponent(PortfoliosListComponent); + const root = fixture.nativeElement as HTMLElement; + fixture.componentRef.setInput('unit', { + students: [], + hasD2lMapping: () => false, + } as unknown as Unit); + + fixture.detectChanges(); + + expect(root.querySelector('f-empty-state')).toBeNull(); + expect(root.querySelectorAll('f-skeleton-loader')).toHaveLength(2); + + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + + const emptyState = root.querySelector('f-empty-state') as HTMLElement; + const table = root.querySelector('table') as HTMLTableElement; + const tableScrollContainer = table.parentElement as HTMLDivElement; + + expect(emptyState).toBeTruthy(); + expect(root.querySelector('f-skeleton-loader')).toBeNull(); + expect(emptyState.closest('table')).toBeNull(); + expect(tableScrollContainer.hidden).toBe(true); + + fixture.componentRef.setInput('unit', { + students: [projectStub('Cy Cole')], + hasD2lMapping: () => false, + } as unknown as Unit); + fixture.detectChanges(); + + expect(root.querySelector('f-empty-state')).toBeNull(); + expect(tableScrollContainer.hidden).toBe(false); + }); +}); diff --git a/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts index 0c90dcaf1e..02e826996e 100644 --- a/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts +++ b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts @@ -5,6 +5,7 @@ import { HostListener, Input, OnChanges, + SimpleChanges, } from '@angular/core'; import {BehaviorSubject} from 'rxjs'; import {Project} from 'src/app/api/models/project'; @@ -76,8 +77,8 @@ export class PortfoliosProjectProgressComponent implements OnChanges { scrollContainer.scrollBy({top: event.deltaY}); } - ngOnChanges(): void { - if (this.project) { + ngOnChanges(changes: SimpleChanges): void { + if (changes.project && this.project) { this.project$.next(this.project); } } diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html index d9eda60d1a..40d45df3cb 100644 --- a/src/app/units/states/portfolios/portfolios.component.html +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -1,4 +1,4 @@ -
+ @if (unit) { Student Notes for {{ selectedProject.student.name }} }
} - + diff --git a/src/app/units/states/portfolios/portfolios.component.ts b/src/app/units/states/portfolios/portfolios.component.ts index 88e0c665bb..62e93f6ef5 100644 --- a/src/app/units/states/portfolios/portfolios.component.ts +++ b/src/app/units/states/portfolios/portfolios.component.ts @@ -1,7 +1,7 @@ import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {MatTabChangeEvent} from '@angular/material/tabs'; import {ActivatedRoute, ParamMap, Router} from '@angular/router'; -import {BehaviorSubject, Observable, Subscription, first, of} from 'rxjs'; +import {BehaviorSubject, Observable, Subscription, distinctUntilChanged, of} from 'rxjs'; import {Project} from 'src/app/api/models/project'; import {Unit} from 'src/app/api/models/unit'; import {ProjectService} from 'src/app/api/services/project.service'; @@ -47,6 +47,9 @@ export class PortfoliosComponent implements OnInit, OnDestroy { private subscriptions: Subscription[] = []; private selectedProjectId: number | null = null; + private progressTaskSelectionUrlBaseCache: unknown[] | null = null; + private progressTaskSelectionUrlBaseKey: string | null = null; + private routeParamsSubscribed = false; constructor( private projectService: ProjectService, @@ -59,10 +62,15 @@ export class PortfoliosComponent implements OnInit, OnDestroy { public ngOnInit(): void { this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); this.subscriptions.push( - this.unit$.pipe(first()).subscribe({ + this.unit$.pipe(distinctUntilChanged((a, b) => a?.id === b?.id)).subscribe({ next: (unit) => { + const unitChanged = this.unit != null && this.unit.id !== unit.id; this.unit = unit; + if (unitChanged) { + this.resetStudentSelection(); + } + if ( this.userService.currentUser.systemRole === 'Admin' || this.userService.currentUser.systemRole === 'Convenor' @@ -70,15 +78,9 @@ export class PortfoliosComponent implements OnInit, OnDestroy { this.unit.loadD2lMapping().subscribe(); } + this.loadingStudents = true; this.loadStudents(); - this.subscriptions.push( - this.route.paramMap.subscribe((params) => { - this.updateCurrentTabFromState(params.get('tab'), params.get('projectId')); - }), - this.route.queryParamMap.subscribe((params) => { - this.updatePortfolioListFiltersFromQueryParams(params); - }), - ); + this.subscribeToRouteParams(); }, error: (error) => { this.alertService.error(`Failed to load unit: ${error}`, 6000); @@ -102,7 +104,23 @@ export class PortfoliosComponent implements OnInit, OnDestroy { return null; } - return ['/units', this.unit.id, 'students', 'portfolios', this.selectedProject.id, 'progress']; + // Cache the array so the binding keeps the same reference between change detection passes. + // A new literal each call makes the input dirty every check, which pushes the progress tab + // to re-emit its project over and over. + const key = `${this.unit.id}/${this.selectedProject.id}`; + if (key !== this.progressTaskSelectionUrlBaseKey) { + this.progressTaskSelectionUrlBaseKey = key; + this.progressTaskSelectionUrlBaseCache = [ + '/units', + this.unit.id, + 'students', + 'portfolios', + this.selectedProject.id, + 'progress', + ]; + } + + return this.progressTaskSelectionUrlBaseCache; } public studentSelected(project: Project): void { @@ -136,6 +154,42 @@ export class PortfoliosComponent implements OnInit, OnDestroy { this.navigateToProject(this.selectedProject.id, nextTab.routeSegment); } + // The selected student belongs to the unit we just left, so drop it and go back to + // the student list rather than showing someone who is not enrolled in the new unit. + private resetStudentSelection(): void { + const hadSelection = this.selectedProjectId != null; + + this.selectedProjectId = null; + this.selectedProject = null; + this.selectedProject$.next(null); + this.currentTab = this.tabs[0]; + + if (hadSelection) { + this.router.navigate(['/units', this.unit.id, 'students', 'portfolios'], { + queryParams: this.portfolioListFilterQueryParams(), + replaceUrl: true, + }); + } + } + + // The unit stream fires again on every unit change, so this has to be idempotent or + // a switch would stack a second pair of route subscriptions on top of the first. + private subscribeToRouteParams(): void { + if (this.routeParamsSubscribed) { + return; + } + + this.routeParamsSubscribed = true; + this.subscriptions.push( + this.route.paramMap.subscribe((params) => { + this.updateCurrentTabFromState(params.get('tab'), params.get('projectId')); + }), + this.route.queryParamMap.subscribe((params) => { + this.updatePortfolioListFiltersFromQueryParams(params); + }), + ); + } + private loadStudents(): void { this.projectService.loadStudents(this.unit, false).subscribe({ next: () => { From fc49abf8f8e4783a07928d43cdda11a6423d785b Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 22:58:48 +1000 Subject: [PATCH 2/3] feat(theme): page redesign for the submissions files Brings the dark mode page redesign (ontrack-features-t2-2026/doubtfire-web#280, which relands ontrack-features-t2-2026/doubtfire-web#192) to the files this PR already carries, so every file stays in exactly one PR. Co-authored-by: Maple Fox --- .../api/models/task-comment/task-comment.ts | 6 +- src/app/api/models/task-definition.ts | 13 +- .../services/task-definition.service.spec.ts | 38 + .../api/services/task-definition.service.ts | 8 +- .../edit-profile-form.component.html | 153 ++-- .../edit-profile-form.component.scss | 139 ++- .../edit-profile-form.component.spec.ts | 64 +- .../edit-profile-form.component.ts | 65 +- .../file-downloader.service.spec.ts | 184 +++- .../file-downloader.service.ts | 200 ++++- .../common/file-drop/file-drop.component.html | 12 +- .../file-uploader.component.html | 42 +- .../file-uploader.component.spec.ts | 130 ++- .../file-uploader/file-uploader.component.ts | 33 +- .../file-viewer/file-viewer.component.html | 10 +- .../file-viewer/file-viewer.component.spec.ts | 17 +- .../pdf-viewer-panel.component.scss | 2 +- .../pdf-viewer/pdf-viewer.component.html | 256 ++++-- .../pdf-viewer/pdf-viewer.component.spec.ts | 175 +++- ...te-portfolio-task-list-item.component.html | 14 +- .../previous-submissions.component.scss | 2 +- .../submission-files-modal.component.html | 31 +- .../task-submission-card.component.html | 152 ++-- ...portfolio-grade-select-step.component.html | 176 ++-- ...earning-summary-report-step.component.html | 145 ++-- .../portfolio-included-tasks.component.html | 73 +- .../task-upload-requirements.component.scss | 4 +- .../upload-submission-modal.component.html | 32 +- .../upload-submission-modal.component.spec.ts | 155 +++- .../upload-submission-modal.component.ts | 45 +- ...achment-confirmation-dialog.component.html | 2 +- .../task-comment-composer.component.ts | 800 ++++++++++++------ ...ask-comment-composer.touch-targets.spec.ts | 224 ++++- .../task-definition-options.component.html | 108 +-- .../task-definition-upload.component.html | 357 ++++---- .../task-definition-upload.component.scss | 9 - .../d2l-transfer.component.ts | 30 +- .../portfolios-list.component.html | 459 ++++++---- .../portfolios-list.component.spec.ts | 382 ++++++++- .../portfolios-project-progress.component.ts | 72 +- .../portfolios/portfolios.component.html | 271 ++++-- .../states/portfolios/portfolios.component.ts | 281 ++++-- 42 files changed, 3939 insertions(+), 1432 deletions(-) diff --git a/src/app/api/models/task-comment/task-comment.ts b/src/app/api/models/task-comment/task-comment.ts index 62f0d45943..648a7eb0c6 100644 --- a/src/app/api/models/task-comment/task-comment.ts +++ b/src/app/api/models/task-comment/task-comment.ts @@ -22,10 +22,10 @@ export class TaskComment extends Entity { recipientReadTime: string; commentType: string = 'text'; isNew: boolean; - attachmentFileName: string; - attachmentMimeType: string; - attachmentByteSize: number; replyToId: number; + attachmentFileName?: string; + attachmentMimeType?: string; + attachmentByteSize?: number; // Data calculated from the above initials: string; diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 07d2b01f93..53aa9d4fe6 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -52,6 +52,7 @@ export class TaskDefinition extends Entity { // groupSetId: number; groupSet: GroupSet = null; hasTaskSheet: boolean; + taskSheetFilename: string; hasTaskResources: boolean; scormEnabled: boolean; hasScormData: boolean; @@ -164,9 +165,6 @@ export class TaskDefinition extends Entity { id: this.id, }) .subscribe({ - next: (taskDefinition) => { - console.log(taskDefinition.name); - }, error: (message) => alerts.error(message, 6000), }); } @@ -259,6 +257,15 @@ export class TaskDefinition extends Entity { }`; } + public get effectiveTaskSheetFilename(): string { + const authoritativeName = this.taskSheetFilename?.trim(); + if (authoritativeName) { + return authoritativeName; + } + + return `${this.unit?.code ?? 'OnTrack'}-${this.abbreviation}-TaskSheet.pdf`; + } + public getTaskResourcesUrl(asAttachment: boolean = false) { const constants = AppInjector.get(DoubtfireConstants); return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/task_resources.json${ diff --git a/src/app/api/services/task-definition.service.spec.ts b/src/app/api/services/task-definition.service.spec.ts index 91e49120f0..169fbb5b70 100644 --- a/src/app/api/services/task-definition.service.spec.ts +++ b/src/app/api/services/task-definition.service.spec.ts @@ -47,6 +47,44 @@ describe('TaskDefinitionService grade due-date mapping', () => { gradeDate.startDate.getHours(), ]).toEqual([2026, 8, 8, 0]); }); + + it('does not clear an existing group set while its cache entry is still loading', () => { + const service = new TaskDefinitionService(null, null, null, null); + const unit = new Unit(); + const taskDefinition = service.buildInstance({group_set_id: 17}, {constructorParams: unit}); + + expect(taskDefinition.groupSet).toBeUndefined(); + const payload = taskDefinition.toJson(service.mapping) as {task_def: {group_set_id?: number}}; + expect(payload.task_def.group_set_id).toBeUndefined(); + }); + + it('clears a group set only when individual work was explicitly selected', () => { + const service = new TaskDefinitionService(null, null, null, null); + const taskDefinition = service.buildInstance({}, {constructorParams: new Unit()}); + taskDefinition.groupSet = null; + + const payload = taskDefinition.toJson(service.mapping) as {task_def: {group_set_id?: number}}; + expect(payload.task_def.group_set_id).toBe(-1); + }); + + it('maps the authoritative task-sheet filename and keeps a safe legacy fallback', () => { + const service = new TaskDefinitionService(null, null, null, null); + const unit = new Unit(); + unit.code = 'COS10001'; + const taskDefinition = service.buildInstance( + { + abbreviation: '1.1P', + task_sheet_filename: 'COS10001-1.1P-TaskSheet.pdf', + }, + {constructorParams: unit}, + ); + + expect(taskDefinition.taskSheetFilename).toBe('COS10001-1.1P-TaskSheet.pdf'); + expect(taskDefinition.effectiveTaskSheetFilename).toBe('COS10001-1.1P-TaskSheet.pdf'); + + taskDefinition.taskSheetFilename = undefined; + expect(taskDefinition.effectiveTaskSheetFilename).toBe('COS10001-1.1P-TaskSheet.pdf'); + }); }); describe('Task definition resubmission setting', () => { diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index 2e213eebc0..1fabb0a286 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -115,10 +115,16 @@ export class TaskDefinitionService extends CachedEntityService { } }, toJsonFn: (taskDef: TaskDefinition, _key: string) => { - return taskDef.groupSet?.id; + // The api only clears a task's group set when it is sent a negative id, and + // leaves it alone when the field is missing. Sending nothing for individual + // work meant a task could never be switched back from group work. + // An unresolved cache entry is undefined and must leave the server's + // group unchanged. The individual-work control explicitly sets null. + return taskDef.groupSet === null ? -1 : taskDef.groupSet?.id; }, }, 'hasTaskSheet', + 'taskSheetFilename', 'hasTaskResources', 'hasTaskAssessmentResources', 'hasTaskAssessmentScript', diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.html b/src/app/common/edit-profile-form/edit-profile-form.component.html index dd97afe9d7..ab741d46bb 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.html +++ b/src/app/common/edit-profile-form/edit-profile-form.component.html @@ -2,14 +2,19 @@ #form="ngForm" class="flex flex-col items-start justify-center" [ngClass]="{'p-10': modal}" - (ngSubmit)="submit()" + (ngSubmit)="submit(form)" > -
+
- + @@ -20,21 +25,51 @@

{{ user?.firstName }}

-
- - Username - - -
+ @if (newUser) { +
+ + Username + + +
+ } @else { + + } -
- +
+ First Name {{ user?.firstName }} } -
- - + Second Name {{ user?.firstName }} @if (user.systemRole === 'Student') { + @if (canEditStudentId) { + + Student ID + + @if (studentIdModel.hasError('required')) { + Student ID is required + } + + } + } + + @if (canEditEmail) { - Student ID + Email - @if (studentIdModel.hasError('required')) { - Student ID is required + @if (emailModel.hasError('required')) { + Email is required + } + @if (emailModel.hasError('email')) { + Enter a valid email address } } - - Email - - @if (emailModel.hasError('required')) { - Email is required - } - @if (emailModel.hasError('email')) { - Enter a valid email address - } - - @if (canSeeSystemRole) { System Role @@ -149,6 +187,10 @@

{{ user?.firstName }}

} + @if (mode === 'edit' && managingOwnProfile) { + + } + Peer progress class="push-opt-in m-0 flex size-full min-h-full min-w-full flex-col items-start justify-center" > } @if (mode === 'edit') { @@ -258,14 +299,20 @@

Peer progress

class="self-end" color="primary" mat-flat-button - type="button" - [disabled]="form.invalid || saving" - (click)="submit()" + type="submit" + [disabled]="form.invalid || form.pristine || saving" > - Save Profile + {{ saving ? 'Saving…' : form.dirty ? 'Save profile' : 'Profile saved' }} }
+
+ @if (form.invalid && form.dirty) { +

Fix the highlighted fields before saving.

+ } +

{{ saveMessage }}

+

{{ saveError }}

+
diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.scss b/src/app/common/edit-profile-form/edit-profile-form.component.scss index f9727b9d5b..ce16451809 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.scss +++ b/src/app/common/edit-profile-form/edit-profile-form.component.scss @@ -2,7 +2,17 @@ display: block; height: auto; min-width: 0; - overflow-y: auto; +} + +form, +.main-container, +.main { + max-width: 100%; +} + +.main { + gap: 4px; + padding: 16px clamp(16px, 4vw, 28px) calc(3rem + env(safe-area-inset-bottom)); } .main, @@ -32,7 +42,60 @@ p { } section { - padding: 8px; + padding: 12px; +} + +.account-information { + background: var(--ot-color-surface); + color: var(--ot-color-text); + border: 1px solid var(--ot-color-border); + border-radius: 12px; + display: grid; + gap: 12px; + margin-bottom: 16px; + padding: 16px; + width: 100%; + + h2, + p, + dl, + dd { + margin: 0; + } + + h2 { + font-size: 1.125rem; + } + + p { + color: var(--ot-color-text-muted); + margin-top: 4px; + } + + dl { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + dl > div { + min-width: 0; + } + + dt { + color: var(--ot-color-text-muted); + font-size: 0.8125rem; + font-weight: 600; + } + + dd { + overflow-wrap: anywhere; + } + + &__managed { + border-top: 1px solid var(--ot-color-divider); + padding-top: 10px; + } } /* checkbox */ @@ -72,7 +135,13 @@ section { } .peer-progress-preference { - border-block: 1px solid var(--ot-color-border); + // Divider token equals the raised surface in dark (#353c47), so this separator + // vanishes on the dialog. Use the border token in dark where it actually reads; + // keep the softer divider in light. + border-block: 1px solid var(--ot-color-divider); + :root[data-ot-theme='dark'] & { + border-block-color: var(--ot-color-border); + } gap: 6px; margin-block: 8px; padding-block: 16px; @@ -86,3 +155,67 @@ section { max-width: 42rem; } } + +.profile-actions { + background: var(--ot-color-surface-raised); + border-top: 1px solid var(--ot-color-divider); + bottom: 0; + padding: 12px clamp(16px, 4vw, 28px) max(12px, env(safe-area-inset-bottom)); + position: sticky; + width: 100%; + z-index: 2; + + button { + min-height: 44px; + } +} + +.profile-save-status { + min-height: 24px; + width: 100%; + + p { + margin: 4px 0 0; + } + + &__success { + color: var(--ot-color-success); + } + + &__validation, + &__error { + color: var(--ot-color-error); + font-weight: 600; + } +} + +.profile-name-fields { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + + mat-form-field { + min-width: 0; + width: 100%; + } +} + +@media (max-width: 600px) { + .profile-name-fields { + gap: 0; + grid-template-columns: 1fr; + } + + .main { + padding-inline: 16px; + } + + .account-information dl { + grid-template-columns: 1fr; + } + + .profile-actions { + padding-inline: 16px; + } +} diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts index 30c57e16a1..cfd2dce546 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, it} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {Directive, NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {MAT_DIALOG_DATA} from '@angular/material/dialog'; @@ -10,6 +10,7 @@ import {AuthenticationService} from 'src/app/api/services/authentication.service import {PushNotificationService} from 'src/app/api/services/push-notification.service'; import {UserService} from 'src/app/api/services/user.service'; import {NotificationSettingsComponent} from 'src/app/common/notification-settings/notification-settings.component'; +import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {EditProfileFormComponent} from './edit-profile-form.component'; @@ -41,7 +42,11 @@ const pushServiceStub = { describe('EditProfileFormComponent', () => { let component: EditProfileFormComponent; let fixture: ComponentFixture; - let userServiceStub: {currentUser: User}; + let userServiceStub: { + currentUser: User; + create: ReturnType; + update: ReturnType; + }; let dialogData: { user: User; mode: 'edit' | 'create' | 'new'; @@ -62,6 +67,8 @@ describe('EditProfileFormComponent', () => { userServiceStub = { currentUser, + create: vi.fn().mockReturnValue(of(currentUser)), + update: vi.fn().mockReturnValue(of(currentUser)), }; dialogData = { user: currentUser, @@ -72,12 +79,13 @@ describe('EditProfileFormComponent', () => { await TestBed.configureTestingModule({ declarations: [EditProfileFormComponent], providers: [ + {provide: AlertService, useValue: {error: vi.fn()}}, {provide: DoubtfireConstants, useValue: emptyProvider}, {provide: UserService, useValue: userServiceStub}, - {provide: Router, useValue: emptyProvider}, + {provide: Router, useValue: {navigateByUrl: vi.fn()}}, {provide: AuthenticationService, useValue: emptyProvider}, {provide: MAT_DIALOG_DATA, useValue: dialogData}, - {provide: MatSnackBar, useValue: emptyProvider}, + {provide: MatSnackBar, useValue: {open: vi.fn()}}, {provide: PushNotificationService, useValue: pushServiceStub}, ], schemas: [NO_ERRORS_SCHEMA], @@ -193,6 +201,50 @@ describe('EditProfileFormComponent', () => { expect(component.pushBlockerInstructions).toEqual(['step one', 'step two']); }); + + it('treats SSO identity and own student id as read-only account information', () => { + dialogData.user = makeUser({ + institutionalIdentityManaged: true, + emailEditable: false, + }); + + createComponent(); + + expect(component.canEditEmail).toBe(false); + expect(component.canEditStudentId).toBe(false); + }); + + it('preserves local email editing and admin maintenance of another local student id', () => { + userServiceStub.currentUser = makeUser({id: 1, systemRole: 'Admin'}); + dialogData.user = makeUser({ + id: 2, + institutionalIdentityManaged: false, + emailEditable: true, + }); + + createComponent(); + + expect(component.canEditEmail).toBe(true); + expect(component.canEditStudentId).toBe(true); + }); + + it('reports explicit saving and success state while preserving genuine settings', () => { + const updated = makeUser({ + nickname: 'Preferred', + receiveFeedbackNotifications: false, + }); + dialogData.user = updated; + userServiceStub.update.mockReturnValue(of(updated)); + + createComponent(); + component.submit(); + + expect(userServiceStub.update).toHaveBeenCalledWith(updated); + expect(component.saving).toBe(false); + expect(component.saveMessage).toBe('Profile saved.'); + expect(component.user.nickname).toBe('Preferred'); + expect(component.user.receiveFeedbackNotifications).toBe(false); + }); }); // A11Y-FORM06: WCAG 1.3.5 Identify Input Purpose (AA). @@ -225,6 +277,7 @@ describe('EditProfileFormComponent autocomplete purpose (A11Y-FORM06)', () => { await TestBed.configureTestingModule({ declarations: [EditProfileFormComponent, StubNgFormProfile, StubNgModelProfile], providers: [ + {provide: AlertService, useValue: {error: vi.fn()}}, { provide: DoubtfireConstants, useValue: {ExternalName: {value: 'OnTrack'}, IsTiiEnabled: {value: false}}, @@ -232,7 +285,7 @@ describe('EditProfileFormComponent autocomplete purpose (A11Y-FORM06)', () => { {provide: UserService, useValue: {currentUser: user}}, {provide: Router, useValue: {}}, {provide: AuthenticationService, useValue: {}}, - {provide: MAT_DIALOG_DATA, useValue: {user, mode: 'edit', modal: false}}, + {provide: MAT_DIALOG_DATA, useValue: {user, mode: 'new', modal: false}}, {provide: MatSnackBar, useValue: {}}, { provide: PushNotificationService, @@ -302,6 +355,7 @@ describe('EditProfileFormComponent notifications page link', () => { StubNotificationNgModel, ], providers: [ + {provide: AlertService, useValue: {error: vi.fn()}}, { provide: DoubtfireConstants, useValue: {ExternalName: {value: 'OnTrack'}, IsTiiEnabled: {value: false}}, diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.ts b/src/app/common/edit-profile-form/edit-profile-form.component.ts index 5ce9085c4c..7760497140 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.ts @@ -1,3 +1,4 @@ +import {HttpErrorResponse} from '@angular/common/http'; import { ChangeDetectionStrategy, Component, @@ -7,6 +8,7 @@ import { OnInit, Optional, } from '@angular/core'; +import {NgForm} from '@angular/forms'; import {MAT_DIALOG_DATA} from '@angular/material/dialog'; import {MatSnackBar} from '@angular/material/snack-bar'; import {Router} from '@angular/router'; @@ -51,10 +53,12 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { @Input() modal: boolean = false; public user: User; - public saving = false; public externalName = this.constants.ExternalName; public initialFirstName: string; public formPronouns = {pronouns: ''}; + public saving = false; + public saveMessage = ''; + public saveError = ''; public get customPronouns(): boolean { return this.formPronouns.pronouns === '__customPronouns'; } @@ -179,13 +183,21 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { return this.mode === 'new'; } - /** - * True only on the user's own profile page. The admin Users dialog opens this - * form in edit mode as a modal to change someone else's settings, and the - * welcome page uses create mode, so neither links to the notifications page. - */ + /** True only on the current user's own profile page. */ public get isOwnProfilePage(): boolean { - return this.mode === 'edit' && !this.modal; + return this.mode === 'edit' && !this.modal && this.managingOwnProfile; + } + + public get managingOwnProfile(): boolean { + return this.user?.id === this.userService.currentUser?.id; + } + + public get canEditEmail(): boolean { + return this.newUser || this.user.emailEditable === true; + } + + public get canEditStudentId(): boolean { + return this.newUser || (!this.user.institutionalIdentityManaged && !this.managingOwnProfile); } public get canEditSystemRole(): boolean { @@ -203,37 +215,45 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { return this.constants.IsTiiEnabled.value; } - public submit(): void { + public submit(form?: NgForm): void { + if (this.saving || form?.invalid) { + return; + } + + this.saving = true; + this.saveMessage = ''; + this.saveError = ''; this.user.pronouns = this.customPronouns ? this.user.pronouns : this.formPronouns.pronouns; this.user.hasRunFirstTimeSetup = true; - this.saving = true; if (this.newUser) { this.userService.create(this.user).subscribe({ next: (updatedUser) => { + this.saving = false; this.user = updatedUser; this.initialFirstName = this.user.firstName; + form?.form.markAsPristine(); + this.saveMessage = 'User created.'; this._snackBar.open('User created', 'dismiss', { duration: 1500, horizontalPosition: 'end', verticalPosition: 'top', }); - this.saving = false; - }, - error: (error) => { - this.alerts.error(error, 6000); - this.saving = false; }, + error: (error: unknown) => this.handleSaveError(error), }); } else { this.userService.update(this.user).subscribe({ next: (updatedUser) => { + this.saving = false; if (this.mode === 'create') { this.router.navigateByUrl('/home'); } else { this.user = updatedUser; this.initialFirstName = this.user.firstName; + form?.form.markAsPristine(); + this.saveMessage = 'Profile saved.'; // TODO: refactor into new alertService // this is a new snackbar alert test @@ -243,13 +263,20 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { verticalPosition: 'top', }); } - this.saving = false; - }, - error: (error) => { - this.alerts.error(error, 6000); - this.saving = false; }, + error: (error: unknown) => this.handleSaveError(error), }); } } + + private handleSaveError(error: unknown): void { + this.saving = false; + const serverMessage = error instanceof HttpErrorResponse ? error.error?.error : null; + const message = typeof error === 'string' ? error : serverMessage; + this.saveError = + typeof message === 'string' && message.trim() + ? message + : 'Profile could not be saved. Check your connection and try again.'; + this.alerts.error(this.saveError, 6000); + } } diff --git a/src/app/common/file-downloader/file-downloader.service.spec.ts b/src/app/common/file-downloader/file-downloader.service.spec.ts index fc21ebcb71..c8004ea416 100644 --- a/src/app/common/file-downloader/file-downloader.service.spec.ts +++ b/src/app/common/file-downloader/file-downloader.service.spec.ts @@ -1,10 +1,192 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {HttpErrorResponse, provideHttpClient} from '@angular/common/http'; +import { + HttpErrorResponse, + HttpHeaders, + provideHttpClient, + withInterceptorsFromDi, + withXhr, +} from '@angular/common/http'; import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; import {TestBed} from '@angular/core/testing'; import {AlertService} from '../services/alert.service'; import {FileDownloaderService} from './file-downloader.service'; +describe('FileDownloaderService download feedback', () => { + let service: FileDownloaderService; + let httpMock: HttpTestingController; + let alerts: { + message: ReturnType; + error: ReturnType; + }; + let clickedFilenames: string[]; + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + + beforeEach(() => { + vi.useFakeTimers(); + clickedFilenames = []; + alerts = {message: vi.fn(), error: vi.fn()}; + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + createObjectURL = vi.fn(() => 'blob:download'); + revokeObjectURL = vi.fn(); + Object.defineProperty(URL, 'createObjectURL', {configurable: true, value: createObjectURL}); + Object.defineProperty(URL, 'revokeObjectURL', {configurable: true, value: revokeObjectURL}); + + TestBed.configureTestingModule({ + providers: [ + FileDownloaderService, + provideHttpClient(withXhr(), withInterceptorsFromDi()), + provideHttpClientTesting(), + {provide: AlertService, useValue: alerts}, + ], + }); + + service = TestBed.inject(FileDownloaderService); + httpMock = TestBed.inject(HttpTestingController); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () { + clickedFilenames.push(this.download); + }); + }); + + afterEach(() => { + httpMock.verify(); + vi.useRealTimers(); + vi.restoreAllMocks(); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: originalCreateObjectURL, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: originalRevokeObjectURL, + }); + }); + + it('announces start only after dispatch, prefers filename*, and revokes its object URL', () => { + service.downloadFileWithFeedback('/feedback/42', 'fallback.pdf'); + + expect(alerts.message).not.toHaveBeenCalled(); + expect(clickedFilenames).toEqual([]); + + httpMock.expectOne('/feedback/42').flush(new Blob(['pdf'], {type: 'application/pdf'}), { + headers: new HttpHeaders({ + 'Content-Disposition': + "attachment; filename=legacy.pdf; filename*=UTF-8''..%2Fprivate%2F%E2%9C%93%20review.pdf", + }), + }); + + expect(clickedFilenames).toEqual(['✓ review.pdf']); + expect(alerts.message).toHaveBeenCalledOnce(); + expect(alerts.message).toHaveBeenCalledWith('Download started: ✓ review.pdf'); + expect(alerts.error).not.toHaveBeenCalled(); + expect(revokeObjectURL).not.toHaveBeenCalled(); + + vi.runOnlyPendingTimers(); + + expect(revokeObjectURL).toHaveBeenCalledOnce(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:download'); + }); + + it('falls back to a safely bounded plain filename when filename* is malformed', () => { + service.downloadFileWithFeedback('/feedback/43', 'fallback.pdf'); + + httpMock.expectOne('/feedback/43').flush(new Blob(['docx']), { + headers: new HttpHeaders({ + 'Content-Disposition': + "attachment; filename=..\\safe\\Feedback .docx; filename*=UTF-8''%E0%A4%A", + }), + }); + + expect(clickedFilenames).toEqual(['Feedback .docx']); + expect(alerts.message).toHaveBeenCalledWith('Download started: Feedback .docx'); + }); + + it('reports a retrieval failure without claiming that a download started or exposing the error', () => { + service.downloadFileWithFeedback('/feedback/44', '../Student feedback.docx'); + + httpMock.expectOne('/feedback/44').flush(new Blob(['private server detail']), { + status: 503, + statusText: 'Unavailable', + }); + + expect(clickedFilenames).toEqual([]); + expect(alerts.message).not.toHaveBeenCalled(); + expect(alerts.error).toHaveBeenCalledOnce(); + expect(alerts.error).toHaveBeenCalledWith( + 'Download failed: Student feedback.docx. Please try again.', + ); + }); + + it('ignores a stale response when a newer request uses the same control key', () => { + service.downloadFileWithFeedback('/feedback/old', 'feedback.pdf', {requestKey: 'comment-7'}); + service.downloadFileWithFeedback('/feedback/new', 'feedback.pdf', {requestKey: 'comment-7'}); + + httpMock.expectOne('/feedback/old').flush(new Blob(['old'])); + + expect(clickedFilenames).toEqual([]); + expect(alerts.message).not.toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:download'); + + httpMock.expectOne('/feedback/new').flush(new Blob(['new'])); + + expect(clickedFilenames).toEqual(['feedback.pdf']); + expect(alerts.message).toHaveBeenCalledOnce(); + expect(alerts.error).not.toHaveBeenCalled(); + }); + + it('reports dispatch failure and still revokes an internally-created object URL', () => { + vi.mocked(HTMLAnchorElement.prototype.click).mockImplementation(() => { + throw new Error('browser rejected click'); + }); + + service.downloadFileWithFeedback('/feedback/45', 'feedback.pdf'); + httpMock.expectOne('/feedback/45').flush(new Blob(['pdf'])); + + expect(alerts.message).not.toHaveBeenCalled(); + expect(alerts.error).toHaveBeenCalledWith('Download failed: feedback.pdf. Please try again.'); + + vi.runOnlyPendingTimers(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:download'); + }); + + it('reports failure when the browser cannot create a downloadable object URL', () => { + createObjectURL.mockImplementationOnce(() => { + throw new Error('object URLs unavailable'); + }); + + service.downloadFileWithFeedback('/feedback/46', 'feedback.pdf'); + httpMock.expectOne('/feedback/46').flush(new Blob(['pdf'])); + + expect(clickedFilenames).toEqual([]); + expect(alerts.message).not.toHaveBeenCalled(); + expect(alerts.error).toHaveBeenCalledWith('Download failed: feedback.pdf. Please try again.'); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + it('reports dispatch of a caller-owned blob without revoking the preview URL', () => { + service.downloadBlobToFileWithFeedback('blob:preview', '../displayed-pdf.pdf'); + + expect(clickedFilenames).toEqual(['displayed-pdf.pdf']); + expect(alerts.message).toHaveBeenCalledWith('Download started: displayed-pdf.pdf'); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + it('keeps the legacy URL-download API toast-free while releasing its temporary blob', () => { + service.downloadFile('/legacy/export', 'export.csv'); + httpMock.expectOne('/legacy/export').flush(new Blob(['row'])); + + expect(clickedFilenames).toEqual(['export.csv']); + expect(alerts.message).not.toHaveBeenCalled(); + expect(alerts.error).not.toHaveBeenCalled(); + + vi.runOnlyPendingTimers(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:download'); + }); +}); + describe('FileDownloaderService', () => { const endpoint = '/api/projects/12/tasks/34/submission'; let service: FileDownloaderService; diff --git a/src/app/common/file-downloader/file-downloader.service.ts b/src/app/common/file-downloader/file-downloader.service.ts index 90f4f1f5f5..116e6bb5d1 100644 --- a/src/app/common/file-downloader/file-downloader.service.ts +++ b/src/app/common/file-downloader/file-downloader.service.ts @@ -13,10 +13,21 @@ interface FileDownloaderData { totalSize?: number; } +export interface DownloadFeedbackOptions { + /** + * Identifies one logical download control. Starting another request with the + * same key makes callbacks from the older request stale. + */ + requestKey?: string; +} + @Injectable({ providedIn: 'root', }) export class FileDownloaderService { + private nextFeedbackRequestId = 0; + private readonly activeFeedbackRequests: Map = new Map(); + constructor( private httpClient: HttpClient, private alerts: AlertService, @@ -76,10 +87,16 @@ export class FileDownloaderService { } private reportSuccess(data: FileDownloaderData) { - const resourceUrl: string = window.URL.createObjectURL( - new Blob(data.binaryData, {type: data.response.body.type}), - ); - data.success(resourceUrl, data.response); + try { + const resourceUrl: string = window.URL.createObjectURL( + new Blob(data.binaryData, {type: data.response.body.type}), + ); + data.success(resourceUrl, data.response); + } catch (error: unknown) { + if (data.failure) { + data.failure(error); + } + } } public downloadBlob( @@ -130,26 +147,181 @@ export class FileDownloaderService { downloadLink.parentNode.removeChild(downloadLink); } - public downloadFile(url: string, defaultFilename: string) { + /** + * Dispatch an already-created blob URL to the browser and report only what + * browser code can prove: that the download was started. The caller retains + * ownership of blobUrl and remains responsible for revoking it. + */ + public downloadBlobToFileWithFeedback(blobUrl: string, filename: string): void { + const safeFilename = this.safeFilename(filename, 'download'); + this.dispatchDownloadWithFeedback(blobUrl, safeFilename, false); + } + + /** + * Retrieve a file, dispatch it to the browser, and provide accessible start + * or failure feedback. A newer request for the same logical control wins, so + * a delayed response cannot trigger a duplicate download or stale message. + */ + public downloadFileWithFeedback( + url: string, + defaultFilename: string, + options: DownloadFeedbackOptions = {}, + ): void { + const safeDefaultFilename = this.safeFilename(defaultFilename, 'download'); + const requestKey = options.requestKey ?? `${url}\u0000${safeDefaultFilename}`; + const requestId = ++this.nextFeedbackRequestId; + this.activeFeedbackRequests.set(requestKey, requestId); + this.downloadBlob( url, (resourceUrl: string, response: HttpResponse) => { - const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; - - const matches = filenameRegex.exec(response.headers.get('Content-Disposition')); - let filename: string; + if (!this.isCurrentFeedbackRequest(requestKey, requestId)) { + this.releaseBlob(resourceUrl); + return; + } - if (matches != null && matches[1]) { - filename = matches[1].replace(/['"]/g, ''); - } else { - filename = defaultFilename; + const filename = this.filenameFromResponse(response, safeDefaultFilename); + this.finishFeedbackRequest(requestKey, requestId); + this.dispatchDownloadWithFeedback(resourceUrl, filename, true); + }, + (_error: unknown) => { + if (!this.isCurrentFeedbackRequest(requestKey, requestId)) { + return; } - this.downloadBlobToFile(resourceUrl, filename); + this.finishFeedbackRequest(requestKey, requestId); + this.alerts.error(`Download failed: ${safeDefaultFilename}. Please try again.`); + }, + ); + } + + public downloadFile(url: string, defaultFilename: string) { + this.downloadBlob( + url, + (resourceUrl: string, response: HttpResponse) => { + const filename = this.filenameFromResponse(response, defaultFilename); + try { + this.downloadBlobToFile(resourceUrl, filename); + } finally { + this.releaseBlobAfterDispatch(resourceUrl); + } }, (error) => { this.alerts.error(`Error downloading file - ${error}`); }, ); } + + private filenameFromResponse(response: HttpResponse, defaultFilename: string): string { + const fallback = this.safeFilename(defaultFilename, 'download'); + const contentDisposition = response.headers.get('Content-Disposition'); + if (!contentDisposition) { + return fallback; + } + + const encodedFilename = this.contentDispositionParameter(contentDisposition, 'filename*'); + const decodedFilename = encodedFilename + ? this.decodeExtendedFilename(encodedFilename) + : undefined; + if (decodedFilename) { + return this.safeFilename(decodedFilename, fallback); + } + + const filename = this.contentDispositionParameter(contentDisposition, 'filename'); + return this.safeFilename(filename, fallback); + } + + private contentDispositionParameter(header: string, parameter: string): string | undefined { + const escapedParameter = parameter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const expression = new RegExp( + `(?:^|;)\\s*${escapedParameter}\\s*=\\s*(?:"((?:\\\\.|[^"])*)"|([^;]*))`, + 'i', + ); + const match = expression.exec(header); + const value = match?.[1] ?? match?.[2]; + if (!value) { + return undefined; + } + + return value.replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim(); + } + + private decodeExtendedFilename(value: string): string | undefined { + const encodedValueMatch = /^([^']*)'[^']*'(.*)$/.exec(value); + const charset = encodedValueMatch?.[1]?.toLowerCase(); + const encodedValue = encodedValueMatch?.[2] ?? value; + if (charset && charset !== 'utf-8' && charset !== 'utf8') { + return undefined; + } + + try { + return decodeURIComponent(encodedValue); + } catch (_error: unknown) { + return undefined; + } + } + + private safeFilename(filename: string | undefined, fallback: string): string { + const sanitize = (candidate: string | undefined): string | undefined => { + if (!candidate) { + return undefined; + } + + const withoutControls = Array.from(candidate) + .filter((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return !( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ); + }) + .join('') + .trim(); + const finalPathSegment = withoutControls.split(/[\\/]/).filter(Boolean).pop()?.trim(); + if (!finalPathSegment || finalPathSegment === '.' || finalPathSegment === '..') { + return undefined; + } + + return Array.from(finalPathSegment).slice(0, 255).join(''); + }; + + return sanitize(filename) ?? sanitize(fallback) ?? 'download'; + } + + private isCurrentFeedbackRequest(requestKey: string, requestId: number): boolean { + return this.activeFeedbackRequests.get(requestKey) === requestId; + } + + private finishFeedbackRequest(requestKey: string, requestId: number): void { + if (this.isCurrentFeedbackRequest(requestKey, requestId)) { + this.activeFeedbackRequests.delete(requestKey); + } + } + + private releaseBlobAfterDispatch(resourceUrl: string): void { + window.setTimeout(() => this.releaseBlob(resourceUrl), 0); + } + + /** + * The one semantic download-feedback dispatch path. Public convenience + * methods differ only in how they obtain and own the blob URL. + */ + private dispatchDownloadWithFeedback( + blobUrl: string, + filename: string, + releaseAfterDispatch: boolean, + ): void { + try { + this.downloadBlobToFile(blobUrl, filename); + this.alerts.message(`Download started: ${filename}`); + } catch (_error: unknown) { + this.alerts.error(`Download failed: ${filename}. Please try again.`); + } finally { + if (releaseAfterDispatch) { + this.releaseBlobAfterDispatch(blobUrl); + } + } + } } diff --git a/src/app/common/file-drop/file-drop.component.html b/src/app/common/file-drop/file-drop.component.html index db4a18a61e..3afb43862c 100644 --- a/src/app/common/file-drop/file-drop.component.html +++ b/src/app/common/file-drop/file-drop.component.html @@ -27,13 +27,17 @@ } Choose file -
-
-
+ +
+
+
@if (file?.name) { } - {{ file?.name || message }} + {{ file?.name || message }} {{ uploadProgress }}
@if (!singleDropZone && upload.model?.length > 0) { -
- {{ upload.display.icon }} - {{ upload.model[0].name }} +
+ + {{ upload.model[0].name }}
} @@ -89,24 +89,24 @@
Select {{ upload.display.name }}
Upload Summary
@for (upload of uploadZones; track upload) { -
-
- {{ upload.display.icon }} +
+
+ {{ upload.display.name }}
@if (upload.model?.length > 0) { - {{ upload.model[0].name }} + {{ upload.model[0].name }} } @else { - File Pending + File pending }
} @@ -117,14 +117,21 @@
Upload Summary
@if (showUploader && !isUploading) {
@if (showUploadButton && readyToUpload() && uploadingInfo === null) { - } @if (asButton) { - + }
} @@ -146,6 +153,9 @@
Upload Summary
[value]="$safeNavigationMigration(uploadingInfo?.progress)" > +
} @@ -163,7 +173,7 @@
Upload Summary
@if (!uploadingInfo.success) {
-
+

Error Message: {{ uploadingInfo.error }}

diff --git a/src/app/common/file-uploader/file-uploader.component.spec.ts b/src/app/common/file-uploader/file-uploader.component.spec.ts index aa3a1bb504..07855faa53 100644 --- a/src/app/common/file-uploader/file-uploader.component.spec.ts +++ b/src/app/common/file-uploader/file-uploader.component.spec.ts @@ -1,10 +1,130 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {CommonModule} from '@angular/common'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCardModule} from '@angular/material/card'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressBarModule} from '@angular/material/progress-bar'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {BehaviorSubject} from 'rxjs'; +import {UserService} from 'src/app/api/services/user.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {FileUploaderComponent} from './file-uploader.component'; -// SJ-11: the upload type check must match a real file extension, not any -// filename that merely ends with the accepted letters (e.g. figure.eps -// must not pass a `ps` zone). -describe('FileUploaderComponent validateFiles', () => { +describe('FileUploaderComponent responsive selected-file state', () => { + let fixture: ComponentFixture; + let component: FileUploaderComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NoopAnimationsModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatProgressBarModule, + ], + declarations: [FileUploaderComponent], + providers: [ + { + provide: UserService, + useValue: {currentUser: {authenticationToken: 'token', username: 'demo_student'}}, + }, + {provide: DoubtfireConstants, useValue: {ExternalName: new BehaviorSubject('OnTrack')}}, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FileUploaderComponent); + component = fixture.componentInstance; + component.files = {file0: {name: 'Learning Summary Report', type: 'document'}}; + component.url = '/projects/1/portfolio'; + fixture.detectChanges(); + }); + + it('wraps a long selected filename and exposes a named remove control', () => { + const file = new File( + ['portfolio'], + 'A very long learning summary report filename that must remain readable on a phone.pdf', + {type: 'application/pdf'}, + ); + const input = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, 'files', {configurable: true, value: [file]}); + + component.onFileSelected({target: input} as unknown as Event, component.shownUploadZones[0]); + fixture.detectChanges(); + + const name = fixture.nativeElement.querySelector('.selected-upload__name') as HTMLElement; + const remove = fixture.nativeElement.querySelector( + '.selected-upload button', + ) as HTMLButtonElement; + expect(name.textContent).toContain(file.name); + expect(remove.getAttribute('aria-label')).toBe(`Remove ${file.name}`); + expect(component.readyToUpload()).toBe(true); + }); + + it('aborts a slow upload and returns control to the owning flow', () => { + const abort = vi.fn(); + const onCancelUpload = vi.fn(); + component.onCancelUpload = onCancelUpload; + component.isUploading = true; + component.uploadingInfo = {progress: 20, success: false, error: '', complete: false}; + (component as unknown as {activeRequest: {abort: () => void}}).activeRequest = {abort}; + + component.cancelUpload(); + + expect(abort).toHaveBeenCalledOnce(); + expect(onCancelUpload).toHaveBeenCalledOnce(); + expect(component.isUploading).toBe(false); + expect(component.uploadingInfo).toBeNull(); + }); + + it('keeps selected files after a server failure and permits an explicit retry', async () => { + const requests: FakeRequest[] = []; + class FakeRequest { + upload: {onprogress?: (event: ProgressEvent) => void} = {}; + readyState = 0; + status = 503; + responseText = '{"error":"Conversion service unavailable"}'; + onreadystatechange?: () => void; + open = vi.fn(); + setRequestHeader = vi.fn(); + abort = vi.fn(); + + constructor() { + requests.push(this); + } + + send = vi.fn(() => { + this.readyState = 4; + this.onreadystatechange?.(); + }); + } + vi.stubGlobal('XMLHttpRequest', FakeRequest); + + const file = new File(['submission'], 'submission.pdf', {type: 'application/pdf'}); + const input = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, 'files', {configurable: true, value: [file]}); + component.onFileSelected({target: input} as unknown as Event, component.shownUploadZones[0]); + + component.initiateUploadInternal(); + await Promise.resolve(); + + expect(component.uploadingInfo).toMatchObject({ + complete: true, + success: false, + error: 'Conversion service unavailable', + }); + expect(component.hasSelectedFiles()).toBe(true); + + component.initiateUploadInternal(); + await Promise.resolve(); + expect(requests).toHaveLength(2); + vi.unstubAllGlobals(); + }); +}); + +describe('FileUploaderComponent extension validation', () => { let component: FileUploaderComponent; beforeEach(() => { @@ -20,7 +140,7 @@ describe('FileUploaderComponent validateFiles', () => { display: {error: null as unknown}, }); - it('rejects a file whose name only ends with the accepted letters', () => { + it('rejects a filename that only ends with the accepted letters', () => { const zone = zoneFor('figure.eps'); (component as never as {shownUploadZones: unknown}).shownUploadZones = [zone]; diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index 1780c9b9d3..c401561970 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -62,6 +62,7 @@ export class FileUploaderComponent implements OnInit, OnChanges { @Input() onFailure?: (response) => void; @Input() onComplete?: () => void; @Input() onClickFailureCancel?: () => void; + @Input() onCancelUpload?: () => void; @Input() isUploading: boolean; @Input() isReady: boolean; @@ -95,6 +96,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { ) {} private externalName: string = 'OnTrack'; + private activeRequest?: XMLHttpRequest; + private uploadWasCancelled = false; ngOnInit(): void { this.showUploader = !this.asButton; @@ -190,6 +193,10 @@ export class FileUploaderComponent implements OnInit, OnChanges { return this.uploadZones.every((zone) => zone.model?.length); } + hasSelectedFiles(): boolean { + return this.uploadZones.some((zone) => zone.model?.length); + } + updateReadyState(ready: boolean) { this.isReady = ready; this.isReadyChange.emit(ready); @@ -224,6 +231,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.isUploading = true; const xhr = new XMLHttpRequest(); + this.activeRequest = xhr; + this.uploadWasCancelled = false; const form = new FormData(); // Append files @@ -252,7 +261,12 @@ export class FileUploaderComponent implements OnInit, OnChanges { xhr.onreadystatechange = () => { if (xhr.readyState === 4) { - setTimeout(() => { + this.activeRequest = undefined; + if (this.uploadWasCancelled) { + return; + } + + queueMicrotask(() => { this.uploadingInfo.complete = true; let response; try { @@ -278,13 +292,13 @@ export class FileUploaderComponent implements OnInit, OnChanges { if (this.resetAfterUpload) { this.resetUploader(); } - }, 2500); + }, 800); } else { this.onFailure?.(response); this.uploadingInfo.success = false; this.uploadingInfo.error = (response?.error ?? 'Unknown error') as string; } - }, 2000); + }); } }; const method = this.method ?? 'POST'; @@ -296,6 +310,19 @@ export class FileUploaderComponent implements OnInit, OnChanges { xhr.send(form); } + cancelUpload(): void { + if (!this.activeRequest || !this.isUploading || this.uploadingInfo?.complete) { + return; + } + + this.uploadWasCancelled = true; + this.activeRequest.abort(); + this.activeRequest = undefined; + this.uploadingInfo = null; + this.isUploading = false; + this.onCancelUpload?.(); + } + // onClickFailureCancelInternal() { // console.log('onClickFailureCancelInternal'); // } diff --git a/src/app/common/file-viewer/file-viewer.component.html b/src/app/common/file-viewer/file-viewer.component.html index 15568e8515..621a7f4c28 100644 --- a/src/app/common/file-viewer/file-viewer.component.html +++ b/src/app/common/file-viewer/file-viewer.component.html @@ -20,8 +20,12 @@ > } @if (fileType === 'html') { -
- -
+ + }
diff --git a/src/app/common/file-viewer/file-viewer.component.spec.ts b/src/app/common/file-viewer/file-viewer.component.spec.ts index 82ae214573..faee5fc418 100644 --- a/src/app/common/file-viewer/file-viewer.component.spec.ts +++ b/src/app/common/file-viewer/file-viewer.component.spec.ts @@ -14,7 +14,8 @@ describe('FileViewerComponent', () => { await TestBed.configureTestingModule({ declarations: [FileViewerComponent, SafePipe], providers: [ - {provide: FileDownloaderService, useValue: {}}, + // The component releases its blob URL on destroy. + {provide: FileDownloaderService, useValue: {releaseBlob: () => undefined}}, {provide: AlertService, useValue: {}}, ], schemas: [NO_ERRORS_SCHEMA], @@ -67,4 +68,18 @@ describe('FileViewerComponent', () => { expect(c.pdfLoadingProgressPercentage).toBe(0); expect(c.pdfLoadingTotalKnown).toBe(false); }); + + // An iframe with no height of its own is 150px tall, which cut a similarity report + // off after its first lines, and an untitled iframe has no accessible name. + it('gives an HTML preview a title and a height of its own', () => { + const c = component as never as {fileType: string; blobUrl: string}; + c.fileType = 'html'; + c.blobUrl = 'blob:http://localhost/preview'; + fixture.detectChanges(); + + const frame: HTMLIFrameElement = fixture.nativeElement.querySelector('iframe'); + expect(frame.getAttribute('title')).toBe('Submission preview'); + expect(frame.hasAttribute('height')).toBe(false); + expect(frame.className).toContain('h-[32rem]'); + }); }); diff --git a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss index 58be23efbc..224309b3a9 100644 --- a/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss +++ b/src/app/common/pdf-viewer-panel/pdf-viewer-panel.component.scss @@ -5,7 +5,7 @@ padding: 0; position: relative; overflow: inherit !important; - background-color: var(--ot-color-page); + background-color: var(--ot-color-surface); min-height: 75vh; @include doubtfire-scroll-panel; } diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.html b/src/app/common/pdf-viewer/pdf-viewer.component.html index c279de74ed..2ed19060e9 100644 --- a/src/app/common/pdf-viewer/pdf-viewer.component.html +++ b/src/app/common/pdf-viewer/pdf-viewer.component.html @@ -1,67 +1,217 @@ -
-
-
- + + + + + + @if (canFullscreen) { + } - - +
-
-
- - Search PDF - search + + @if (previewEnabled && viewerMode === 'ontrack') { +
+ - - - - -
-
+
+
+ } + - @if (pdfBlobUrl) { - @if (useNativePdfViewer) { - @if (pdfBlobUrl) { - - PDF Preview +
+ @if (!previewEnabled) { +
+ +

Preview is off

+

{{ displayFilename }} is still available to download.

+
+ + +
+
+ } @else if (loadState === 'empty') { +
+ +

No PDF to preview

+

This attachment has no pages or no PDF source was supplied.

+
+ } @else { + @if (pdfBlobUrl && viewerMode === 'browser') { + +

+ Your browser cannot show this PDF preview. + +

+ } @else if (pdfBlobUrl) { + + } + + @if (loadState === 'loading') { +
+ +

Loading PDF preview

+

{{ displayFilename }}

+
+ } @else if (loadState === 'error') { + } - } @else { - } - } @else { - - } -
+ + diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.spec.ts b/src/app/common/pdf-viewer/pdf-viewer.component.spec.ts index e649491e2d..9525cbd69a 100644 --- a/src/app/common/pdf-viewer/pdf-viewer.component.spec.ts +++ b/src/app/common/pdf-viewer/pdf-viewer.component.spec.ts @@ -1,65 +1,178 @@ -import {beforeEach, describe, expect, it, vi} from 'vitest'; +import type {PDFDocumentProxy} from 'ng2-pdf-viewer'; +import {vi} from 'vitest'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {HttpHeaders, HttpResponse} from '@angular/common/http'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormsModule} from '@angular/forms'; -import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; -import {AlertService} from 'src/app/common/services/alert.service'; +import {FileDownloaderService} from '../file-downloader/file-downloader.service'; import {SafePipe} from '../pipes/safe.pipe'; +import {AlertService} from '../services/alert.service'; import {fPdfViewerComponent} from './pdf-viewer.component'; -vi.mock('ng2-pdf-viewer', () => ({ - PdfViewerComponent: class {}, - PDFDocumentProxy: class {}, -})); +vi.mock('ng2-pdf-viewer', () => ({PdfViewerComponent: class PdfViewerComponent {}})); describe('fPdfViewerComponent', () => { - let component: fPdfViewerComponent; let fixture: ComponentFixture; + let component: fPdfViewerComponent; + let downloader: { + downloadBlob: ReturnType; + releaseBlob: ReturnType; + downloadBlobToFileWithFeedback: ReturnType; + downloadFileWithFeedback: ReturnType; + }; beforeEach(async () => { + localStorage.clear(); + downloader = { + downloadBlob: vi.fn(), + releaseBlob: vi.fn(), + downloadBlobToFileWithFeedback: vi.fn(), + downloadFileWithFeedback: vi.fn(), + }; await TestBed.configureTestingModule({ - declarations: [fPdfViewerComponent, SafePipe], imports: [FormsModule], + declarations: [fPdfViewerComponent, SafePipe], providers: [ - {provide: FileDownloaderService, useValue: {releaseBlob: () => {}}}, - {provide: AlertService, useValue: {error: () => {}}}, + {provide: FileDownloaderService, useValue: downloader}, + {provide: AlertService, useValue: {error: vi.fn()}}, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(fPdfViewerComponent); component = fixture.componentInstance; + }); + + it('shows a finite loading state and labelled wrapping toolbar for the actual filename', () => { + fixture.componentRef.setInput('filename', 'A very long feedback document name.pdf'); + fixture.componentRef.setInput('pdfUrl', '/comments/3'); fixture.detectChanges(); + + expect(component.loadState).toBe('loading'); + expect(downloader.downloadBlob).toHaveBeenCalledOnce(); + expect(fixture.nativeElement.textContent).toContain('Loading PDF preview'); + expect(fixture.nativeElement.textContent).toContain('A very long feedback document name.pdf'); + expect(fixture.nativeElement.querySelector('[aria-label="Search this PDF"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[aria-label="Zoom out PDF"]')).toBeTruthy(); + expect( + fixture.nativeElement.querySelector( + '[aria-label="Viewer mode: OnTrack. Switch to browser viewer"]', + ), + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector( + '[aria-label="Download PDF: A very long feedback document name.pdf"]', + ), + ).toBeTruthy(); }); - it('labels the two zoom buttons with different accessible names instead of both announcing "Zoom in PDF button"', () => { - const buttons = fixture.nativeElement.querySelectorAll('#pdfActions button'); - expect(buttons.length).toBe(2); + it('moves from retrieval to ready and exposes search and bounded zoom controls', () => { + fixture.componentRef.setInput('pdfUrl', '/comments/4'); + fixture.detectChanges(); + const success = downloader.downloadBlob.mock.calls[0][1] as ( + url: string, + response: HttpResponse, + ) => void; + success('blob:ready', new HttpResponse({body: new Blob(['pdf']), headers: new HttpHeaders()})); + component.onLoaded({numPages: 2} as PDFDocumentProxy); + fixture.detectChanges(); - const zoomOutButton = buttons[0] as HTMLButtonElement; - const zoomInButton = buttons[1] as HTMLButtonElement; + expect(component.loadState).toBe('ready'); + expect(component.canZoomIn).toBe(true); + expect(component.canZoomOut).toBe(true); + const search = fixture.nativeElement.querySelector( + '[aria-label="Search this PDF"]', + ) as HTMLInputElement; + expect(search.disabled).toBe(false); - expect(zoomOutButton.getAttribute('aria-label')).toBe('Zoom out'); - expect(zoomInButton.getAttribute('aria-label')).toBe('Zoom in'); - expect(zoomOutButton.getAttribute('aria-label')).not.toBe( - zoomInButton.getAttribute('aria-label'), - ); + for (let index = 0; index < 30; index++) { + component.zoomIn(); + } + expect(component.zoomValue).toBe(2.5); + for (let index = 0; index < 30; index++) { + component.zoomOut(); + } + expect(component.zoomValue).toBe(0.5); + }); + + it('shows recoverable network and parser error states without a perpetual spinner', () => { + fixture.componentRef.setInput('filename', 'broken.pdf'); + fixture.componentRef.setInput('pdfUrl', '/comments/broken'); + fixture.detectChanges(); + const failure = downloader.downloadBlob.mock.calls[0][2] as (error: unknown) => void; + failure(new Error('offline')); + fixture.detectChanges(); + + let alert = fixture.nativeElement.querySelector('[role="alert"]') as HTMLElement; + expect(component.loadState).toBe('error'); + expect(alert.textContent).toContain('PDF preview unavailable'); + expect(alert.textContent).toContain('broken.pdf'); + expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull(); + + component.pdfBlobUrl = 'blob:broken'; + component.onPdfError(new Error('parse')); + fixture.detectChanges(); + alert = fixture.nativeElement.querySelector('[role="alert"]') as HTMLElement; + expect(alert.textContent).toContain('retry or download'); + }); + + it('renders useful empty and preview-off states with recovery actions', () => { + fixture.detectChanges(); + expect(component.loadState).toBe('empty'); + expect(fixture.nativeElement.textContent).toContain('No PDF to preview'); + + component.pdfBlobUrl = 'blob:preview'; + component.loadState = 'ready'; + component.togglePreview(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Preview is off'); + expect(fixture.nativeElement.textContent).toContain('Show preview'); + expect(fixture.nativeElement.textContent).toContain('Download PDF'); }); - it('gives the search field a mat-label and the loading spinner an accessible name, instead of leaving them unnamed', () => { - const matLabel = fixture.nativeElement.querySelector('mat-form-field mat-label'); - expect(matLabel?.textContent?.trim()).toBe('Search PDF'); + it('names the viewer mode and dispatches the real filename through the shared helper', () => { + fixture.componentRef.setInput('filename', 'Student evidence.pdf'); + fixture.componentRef.setInput('pdfUrl', 'blob:evidence'); + fixture.detectChanges(); + component.loadState = 'ready'; + + component.toggleViewerMode(); + expect(component.viewerMode).toBe('browser'); + expect(localStorage.getItem('pdfViewerMode')).toBe('browser'); - const spinner = fixture.nativeElement.querySelector('mat-spinner'); - expect(spinner.getAttribute('aria-label')).toBe('Loading PDF'); + component.downloadPdf(); + expect(downloader.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:evidence', + 'Student evidence.pdf', + ); }); - it('titles the embedded PDF object so the native viewer is not an untitled embed', () => { - component.pdfBlobUrl = 'blob:http://localhost/fake-pdf'; - component.useNativePdfViewer = true; + it('releases stale and owned network blobs but not caller-owned blob URLs', () => { + fixture.componentRef.setInput('pdfUrl', '/comments/old'); fixture.detectChanges(); + const oldSuccess = downloader.downloadBlob.mock.calls[0][1] as ( + url: string, + response: HttpResponse, + ) => void; + + fixture.componentRef.setInput('pdfUrl', '/comments/new'); + fixture.detectChanges(); + oldSuccess('blob:stale', new HttpResponse({body: new Blob(['old'])})); + expect(downloader.releaseBlob).toHaveBeenCalledWith('blob:stale'); + + const newSuccess = downloader.downloadBlob.mock.calls[1][1] as ( + url: string, + response: HttpResponse, + ) => void; + newSuccess('blob:owned', new HttpResponse({body: new Blob(['new'])})); + fixture.destroy(); + expect(downloader.releaseBlob).toHaveBeenCalledWith('blob:owned'); - const object = fixture.nativeElement.querySelector('object'); - expect(object.getAttribute('title')).toBe('Submission PDF'); + const callerFixture = TestBed.createComponent(fPdfViewerComponent); + callerFixture.componentRef.setInput('pdfUrl', 'blob:caller-owned'); + callerFixture.detectChanges(); + callerFixture.destroy(); + expect(downloader.releaseBlob).not.toHaveBeenCalledWith('blob:caller-owned'); }); }); diff --git a/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html b/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html index 5fa5e5595d..fcb5262b4a 100644 --- a/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html +++ b/src/app/projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component.html @@ -1,5 +1,5 @@
@@ -8,10 +8,8 @@
Create Portfolio
- person -
+ person +
Create and submit your portfolio
@@ -19,5 +17,9 @@
- + +
+ +
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss index de5c4c04aa..dae6234b7e 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component.scss @@ -7,7 +7,7 @@ } .history-list li { padding-block: 0.75rem; - border-bottom: 1px solid var(--mat-sys-outline-variant, currentColor); + border-bottom: 1px solid var(--ot-color-border); overflow-wrap: anywhere; } .history-list p { diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html index 597a3efaab..8f73ba8580 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html @@ -7,11 +7,12 @@

Submission Files }

-
+
{{ data.assessment.task.definition.abbreviation }} - {{ data.assessment.task.definition.name }} ({{ - data.assessment.task.project.student.displayName - }}) + {{ data.assessment.task.definition.name }} + @if (data.assessment.task.project?.student?.displayName; as studentName) { + ({{ studentName }}) + }
@@ -24,7 +25,7 @@

} @else if (errorMessage) { -
+
{{ errorMessage }}
} @else if (compareMode) { @@ -73,7 +74,7 @@

(selectedFileChanged)="onComparedSelectionChange($event)" > } @else { -
+
Loading archive...
} @@ -82,12 +83,12 @@

@if (!bothSelectionsReady) { -
+
Select a file in both submissions to compare.
} @else if (canShowDiffEditor) {
-
+
{{ primarySelectedFile?.path ?? primarySelectedFile?.name }}
@@ -123,7 +124,7 @@

} @else { -
+
Unable to load one or both submission archives.
} @@ -145,7 +146,7 @@

>

} @else { -
+
Unable to load submission files.
} @@ -157,7 +158,7 @@

let-number="number" let-timestamp="timestamp" > -
+
Submission{{ number !== undefined ? ' ' + number : '' }}{{ isMostRecent ? ' (Most recent)' : '' }}: {{ timestamp | date: 'dd/MM/yyyy HH:mm' }} @@ -167,7 +168,7 @@

@@ -176,8 +177,8 @@

@if (selectedFilesMatch !== null) { {{ selectedFilesMatch ? 'Identical' : 'Different' }} @@ -200,7 +201,7 @@

/>

} @else { -
+
Preview not available for this file type.
} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html index 9719fb9fa8..070410f46a 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html @@ -1,75 +1,97 @@ - - - Submission Processing - - -

- Your submission is being processed and will be avaliable to view soon. You will also be able - to download your most recently submitted files. -

-

- You can choose to download your previous - submission or the files you uploaded below. -

- @if (task?.hasPdf && task.submissionDate) { -

- You uploaded this submission {{ task.submissionDate | date: 'dd/MM/yyyy' }} + +

+
+

Your submission

+ + {{ stateLabel }} + +
+ +

+ {{ stateMessage }} + @if (task?.submissionDate) { + Uploaded + {{ + task.submissionDate | date: 'dd/MM/yyyy' + }}. + } +

+ + @if (task?.submissionProcessingActive) { + +

+ Status checks use bounded backoff and stop when you leave this task.

} -

- If you feel there has been an error in your submission, you can request to regenerate your - submission under the "Actions" dropdown menu. -

-

- If you would like to submit alternate evidence for use in your portfolio, you can upload - alternate files under the "Actions" dropdown menu. -

- - - -
- - - - +
+ } + + @if (refreshFailed) { + + } + + @if (!task?.submissionPdfReady) { +

+ Submission PDF: unavailable until conversion succeeds. +

+ } + @if (!task?.submissionFilesReady) { +

+ Submitted files: unavailable until OnTrack secures the upload archive. +

+ } +
+ +
+ @if (task?.submissionPdfReady) { + + } + @if (task?.submissionFilesReady) { + + } + @if (task?.submissionRetryable) { + - - + } + @if (canRegeneratePdf) { + - - - + } +
diff --git a/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html b/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html index d10135b3df..3533ed96c3 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-grade-select-step/portfolio-grade-select-step.component.html @@ -1,100 +1,88 @@ -
- - - - -

Select Grade

-
-
- - -

- In preparing your portfolio, you need to undertake a self-assessment. Use the unit's - assessment criteria to determine the grade your portfolio should be awarded. +

+
+
+

Choose your grade

+

+ Assess your own work against the unit's assessment criteria, then pick the grade your + portfolio should be awarded.

+
- - - - - warning - Read the assessment criteria - - - - -

- Make sure that you have reviewed the Assessment Criteria for the grade you are applying - for. Each grade will have a list of criteria that you can use to determine if you meet - the requirements to achieve that grade. -

-
- - - - I have read the Assessment Criteria for this unit - - -
- - +
+ +
+

Read the assessment criteria first

+

+ Each grade has its own list of criteria. Use it to check that your work meets the grade + you are applying for. +

+ + I have read the assessment criteria for this unit + +
+
- @if (agreedToAssessmentCriteria) { - - - - Grade Application - - - - -

- Select the grade you are applying for {{ unit.code }} - {{ unit.name }} below. -

-
- - - +

+ Which grade are you applying for in {{ unit.code }} {{ unit.name }}? +

+ + @for (grade of gradeValues; track grade) { + - @for (grade of gradeValues; track grade) { - - - - } -
- -

- Make sure your Learning Summary Report justifies how your portfolio - demonstrates you have - met all unit learning outcomes to a {{ targetGrade }} level -

-
-
- } - + + + {{ gradeLabel(grade) }} + + + } + + @if (hasSubmittedGrade) { +

+ Your learning summary report needs to show how your portfolio meets + all the unit learning outcomes at {{ targetGrade }} level. +

+ } +
+ } +
- - - - + + @if (!canContinue) { + Tick the box and choose a grade to continue. - Next - - - -
+ } + + + diff --git a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html index a5e9195be2..6fa8db5960 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html @@ -1,68 +1,75 @@ - - - Learning Summary Report - - -

- Upload the Learning Summary Report, the primary porfolio document which justifies your desired - grade. -

-

- Your Learning Summary Report is a - summary of what you have learnt in this unit. It consists of two sections: -

- -
    -
  1. a self-assessment, and
  2. -
  3. your reflections on the unit.
  4. -
- -

- The self-assessment indicates how your portfolio aligns with the assessment - criteria, and which grade you are applying for. -

+
+
+
+

Learning summary report

+

+ The main document in your portfolio. It sums up what you learnt in this unit and justifies + the grade you are applying for. +

+
-

- Your reflections are a personal comment on what you have learnt in the unit, - and how your knowledge and skills have developed. -

+
    +
  • + +
    +

    Self-assessment

    +

    + How your portfolio lines up with the assessment criteria, and which grade you are + applying for. +

    +
    +
  • +
  • + +
    +

    Reflections

    +

    + What you learnt in the unit, and how your knowledge and skills developed. +

    +
    +
  • +
@if ( projectHasDraftLearningSummaryReport && !forceLSRSubmit && !acceptUploadNewLearningSummary ) { -
- + +
}
-
- warning - - Remember to provide a justification for why you believe you have achieved a - {{ targetGradeLabel }} in {{ unit.code }} {{ unit.name }}. +
+ +

+ Remember to explain why you believe you have earned a + {{ targetGradeLabel }} in {{ unit.code }} {{ unit.name }}. +

- - - -
- @if (!projectHasDraftLearningSummaryReport) { -
- You're missing a Learning Summary Report. Upload one to continue. -
- } +
+ +
+ @if (!projectHasDraftLearningSummaryReport) { + + } +
+
- - +
+ diff --git a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html index 7ac829a53e..8cc41e72b9 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-review-step/portfolio-included-tasks/portfolio-included-tasks.component.html @@ -5,53 +5,50 @@
} @else { @if (tasksInPortfolio.length === 0) { -
- assignment_late -

No tasks found

+
+ +

No tasks found

} @else { -
    +
      @for (task of tasksInPortfolio; track task) { -
    1. -
      -
      -
      {{ task.definition.abbreviation }} — {{ task.definition.name }}
      -
      - -
      +
    2. + {{ task.definition.abbreviation }} + {{ + task.definition.name + }} +
    3. }
    } @if (tasksStillProcessing.length > 0) { -
    -
    - hourglass_top -
    -

    Tasks still processing

    -

    - Please wait until these tasks have finished processing before creating your portfolio to - ensure they are included. -

    -
    +
    + +
    +

    Tasks still processing

    +

    + Wait until these tasks finish processing before you create your portfolio, so they are + included. +

    +
      + @for (task of tasksStillProcessing; track task) { +
    1. + + {{ task.definition.abbreviation }} — {{ task.definition.name }} + + +
    2. + } +
    +

    + This list refreshes by itself as tasks finish processing. +

    -
      - @for (task of tasksStillProcessing; track task) { -
    1. -
      - {{ task.definition.abbreviation }} — {{ task.definition.name }} -
      -
      - -
      -
    2. - } -
    -

    - This list will automatically refresh as tasks finish processing. -

    } } diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss index 37a5354fe8..37467efbf5 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss @@ -13,7 +13,9 @@ .requirement { padding: 0.5em 0.75em; margin-bottom: 0.5em; - border: 1px solid #ddd; + // A text mix, not the divider: the dialog is the raised surface, which the dark + // divider matches exactly. In light this lands on the old #ddd. + border: 1px solid color-mix(in srgb, var(--ot-color-text) 15%, transparent); border-radius: 4px; word-break: break-word; } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index e5449332cb..4717eeebf0 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -1,11 +1,8 @@ -
    +

    Submit {{ task.definition.abbreviation }} {{ task.definition.name }}

    - + Submission type

    Team Contribution

    -

    +

    Please rate each team member's effort on {{ task.definition.name }}.

    -

    Click the same rating twice to record no contribution.

    +

    Click the same rating twice to record no contribution.

@if (submissionType === 'need_help') { -

+

Upload the required files so your tutor can help with this task, even if they are not finished yet.

} @else { -

+

Upload files that show your evidence of work until each required file has been selected.

@@ -79,6 +76,7 @@

[describedBy]="uploadStarted || isUploading ? null : uploadRequirements.elementId" [files]="fileRequirements" [onBeforeUpload]="onBeforeUpload" + [onCancelUpload]="onUploadCancelled" [onClickFailureCancel]="cancel" [onComplete]="onUploadComplete" [onFailure]="onUploadFailure" @@ -104,17 +102,17 @@

What do you need help with?

-

+

Add a short comment so your tutor knows what help you need on this task.

} @else {

Final comments

@if (task.definition.assessInPortfolioOnly) { -

+

Please specify which areas of your submission you would like feedback on.

} @else { -

+

Add an optional note for your tutor to read while assessing this submission.

} @@ -153,7 +151,7 @@

Final comments

Declaration

-

{{ privacyPolicy.privacy }}

+

{{ privacyPolicy.privacy }}

@@ -168,7 +166,7 @@

Declaration

@if (showPlagiarism) { -
+
{{ privacyPolicy.plagiarism }}
} @@ -182,9 +180,9 @@

Declaration

@if (!uploadStarted) { - - @if (isGroupStage) { diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 0de1180b80..9cc0dd670c 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -1,3 +1,4 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormsModule} from '@angular/forms'; @@ -8,6 +9,7 @@ import {MatSelectModule} from '@angular/material/select'; import {By} from '@angular/platform-browser'; import {BehaviorSubject} from 'rxjs'; import {ProjectService, TaskService} from 'src/app/api/models/doubtfire-model'; +import {Task} from 'src/app/api/models/task'; import {UserService} from 'src/app/api/services/user.service'; import {FileUploaderComponent} from 'src/app/common/file-uploader/file-uploader.component'; import {AlertService} from 'src/app/common/services/alert.service'; @@ -15,9 +17,124 @@ import {EmojiService} from 'src/app/common/services/emoji.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {PrivacyPolicy} from 'src/app/config/privacy-policy/privacy-policy'; import {TaskUploadRequirementsComponent} from './task-upload-requirements/task-upload-requirements.component'; -import {UploadSubmissionModalComponent} from './upload-submission-modal.component'; +import { + UploadSubmissionModalComponent, + UploadSubmissionModalData, +} from './upload-submission-modal.component'; -describe('UploadSubmissionModalComponent upload guidance', () => { +describe('UploadSubmissionModalComponent', () => { + let component: UploadSubmissionModalComponent; + let task: Task; + let dialogRef: {close: ReturnType}; + + beforeEach(() => { + task = { + status: 'not_started', + definition: { + abbreviation: '1.1P', + name: 'Hello World', + uploadRequirements: [{key: 'file0', name: 'Task evidence', type: 'document'}], + assessInPortfolioOnly: false, + }, + project: {id: 1}, + unit: {id: 2}, + isGroupTask: () => false, + inSubmittedState: () => false, + submissionUrl: () => '/submission', + testSubmissionUrl: () => '/test-submission', + updateFromJson: vi.fn(), + processTaskStatusChange: vi.fn(), + } as unknown as Task; + dialogRef = {close: vi.fn()}; + const data: UploadSubmissionModalData = { + task, + reuploadEvidence: false, + isTestSubmission: false, + }; + // The component injects its Injector for focus handling, so build it in an injection context. + component = TestBed.runInInjectionContext( + () => + new UploadSubmissionModalComponent( + data, + dialogRef as never, + { + submittableStatuses: ['ready_for_feedback'], + statusLabels: new Map([['ready_for_feedback', 'Ready for feedback']]), + mapping: {}, + } as never, + {} as never, + {privacy: '', plagiarism: ''} as never, + {error: vi.fn()} as never, + {nativeEmojiToColons: (value: string) => value} as never, + ), + ); + component.ngOnInit(); + }); + + it('allows untouched backdrop dismissal and confirms before discarding selected files', () => { + component.comment = 'A note that has not selected a local file'; + component.submissionType = 'need_help'; + expect(component.canClose()).toBe(true); + + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: false, + hasSelectedFiles: () => true, + }; + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + expect(component.canClose()).toBe(false); + expect(confirm).toHaveBeenCalledWith( + 'Discard the files and details selected for this submission?', + ); + + confirm.mockReturnValue(true); + expect(component.canClose()).toBe(true); + confirm.mockRestore(); + }); + + it('cannot dismiss while the upload request is active', () => { + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: true, + hasSelectedFiles: () => true, + }; + const confirm = vi.spyOn(window, 'confirm'); + + expect(component.canClose()).toBe(false); + expect(confirm).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); + + it('marks the task queued immediately and closes on the same task after upload', () => { + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + + expect(task.processingPdf).toBe(true); + expect(task.submissionProcessingState).toBe('queued'); + + component.onUploadComplete(); + + expect(task.updateFromJson).toHaveBeenCalled(); + expect(task.processTaskStatusChange).toHaveBeenCalledWith( + 'ready_for_feedback', + expect.anything(), + true, + ); + expect(dialogRef.close).toHaveBeenCalledWith({value: task}); + }); + + it('restores selection controls after a cancelled slow upload', () => { + component.uploadStarted = true; + component.uploadSubmitLocked = true; + + component.onUploadCancelled(); + + expect(component.uploadStarted).toBe(false); + expect(component.uploadSubmitLocked).toBe(false); + expect(component.currentStage).toBe('details'); + }); +}); + +// The guidance and file selection checks for this setup are in the reviewed spec. +describe('UploadSubmissionModalComponent rendered stages', () => { let fixture: ComponentFixture; beforeEach(async () => { @@ -65,40 +182,6 @@ describe('UploadSubmissionModalComponent upload guidance', () => { afterEach(() => vi.restoreAllMocks()); - it('shows guidance before selection and describes the real file picker controls', () => { - const root: HTMLElement = fixture.nativeElement; - const button = root.querySelector('.file-drop-zone')!; - const input = root.querySelector('input[type=file]')!; - const help = root.querySelector('.task-upload-requirements')!; - expect(help.textContent).toContain('Files required: 1'); - expect(button.getAttribute('aria-describedby')).toBe(help.id); - expect(input.getAttribute('aria-describedby')).toBe(help.id); - expect(help.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(input.accept).toContain('.vue'); - expect(fixture.componentInstance.isUploaderReady).toBe(false); - }); - - it('preserves invalid-file feedback and enables submission only after an accepted selection', () => { - const input: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]'); - Object.defineProperty(input, 'files', { - configurable: true, - value: [new File(['x'], 'bad.exe')], - }); - input.dispatchEvent(new Event('change')); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('Invalid file provided'); - expect(fixture.componentInstance.isUploaderReady).toBe(false); - - Object.defineProperty(input, 'files', { - configurable: true, - value: [new File(['x'], 'source.vue')], - }); - input.dispatchEvent(new Event('change')); - fixture.detectChanges(); - expect(fixture.componentInstance.isUploaderReady).toBe(true); - expect(fixture.nativeElement.textContent).toContain('source.vue'); - }); - it('associates the required comment instructions and exposes the same trimmed validation as Submit', async () => { const component = fixture.componentInstance; component.onSubmissionTypeChange('need_help'); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index fc66466402..8f0c22bbd2 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -68,6 +68,7 @@ export type UploadSubmissionModalResult = @Component({ selector: 'f-upload-submission-modal', templateUrl: './upload-submission-modal.component.html', + styleUrls: ['./upload-submission-modal.component.scss'], changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) @@ -132,7 +133,6 @@ export class UploadSubmissionModalComponent implements OnInit { : this.data.reuploadEvidence ? 'reupload_evidence' : this.task.status; - this.resetUploadState(); } @@ -260,10 +260,28 @@ export class UploadSubmissionModalComponent implements OnInit { } public cancel = (): void => { - this.uploadSubmitLocked = false; this.dialogRef.close({dismissed: true}); }; + public canClose(): boolean { + if (this.isUploading) { + return false; + } + if (!this.isDirty) { + return true; + } + + return window.confirm('Discard the files and details selected for this submission?'); + } + + public get isDirty(): boolean { + // The dialog may be dismissed freely until a local file has actually been + // selected. This avoids warning on an untouched dialog (or after merely + // inspecting submission options) while protecting the only state that the + // browser cannot reconstruct after close. + return this.fileUploader?.hasSelectedFiles() === true; + } + public onReadyChange(isReady: boolean): void { this.isUploaderReady = isReady; } @@ -272,6 +290,14 @@ export class UploadSubmissionModalComponent implements OnInit { this.startUpload = startUpload; } + public onUploadCancelled = (): void => { + this.uploadSubmitLocked = false; + this.uploadStarted = false; + this.uploadAnnouncement = ''; + this.uploadResponse = null; + this.currentStage = 'details'; + }; + public onBeforeUpload = (): void => { this.uploadAnnouncement = 'Uploading submission. Please wait for the result.'; this.focusStageHeading(); @@ -301,6 +327,10 @@ export class UploadSubmissionModalComponent implements OnInit { public onUploadSuccess = (response: unknown): void => { if (this.isValidUploadResponse(response)) { this.uploadResponse = response; + this.task.processingPdf = true; + this.task.hasPdf = false; + this.task.submissionProcessingState = 'queued'; + this.task.submissionRetryable = false; this.uploadAnnouncement = 'Submission uploaded successfully.'; if (this.data.isTestSubmission) { @@ -335,13 +365,8 @@ export class UploadSubmissionModalComponent implements OnInit { } const response = this.uploadResponse; - this.dialogRef.close({value: this.task}); - - window.setTimeout(() => { - if (this.data.isTestSubmission) { - return; - } + if (!this.data.isTestSubmission) { const expectedStatus = this.submissionType === 'need_help' || this.submissionType === 'ready_for_feedback' ? this.submissionType @@ -349,7 +374,9 @@ export class UploadSubmissionModalComponent implements OnInit { this.task.updateFromJson(response, this.taskService.mapping); this.task.processTaskStatusChange(expectedStatus as TaskStatusEnum, this.alertService, true); - }, 1500); + } + + this.dialogRef.close({value: this.task}); }; public uploadButtonClicked(): void { diff --git a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html index f4d64834b2..6455812d2e 100644 --- a/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html +++ b/src/app/tasks/task-comment-composer/attachment-confirmation-dialog/attachment-confirmation-dialog.component.html @@ -5,7 +5,7 @@

Post Attachment?

This attachment is ready to post as a task comment.

@if (isImage) { false', [style({width: 38.4}), animate('150ms 0ms ease-in-out')]), - transition('false => true', [style({width: 80}), animate('150ms 0ms ease-in-out')]), - ]), - ], changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnChanges, OnInit { +export class TaskCommentComposerComponent + implements AfterViewInit, DoCheck, OnChanges, OnDestroy, OnInit +{ @Input() task: Task; @Input() sharedData: TaskCommentComposerData; - public $userIsTyping: BehaviorSubject = new BehaviorSubject(false); - private draftSaveSubscription = new Subscription(); - private readonly DRAFT_KEY_PREFIX = 'task_comment_draft_'; private readonly SUBMITTED_KEY_PREFIX = 'task_comments_submitted_'; + private readonly TEXTAREA_MAX_HEIGHT_PX = 144; + private readonly routeSubscription: Subscription; + private draftLoadGeneration = 0; + private readonly draftLoadTimers: Set> = new Set(); public isDraftLoaded = false; private submittedTaskIds: Set = new Set(); public isSending: boolean = false; + public stagedAttachments: StagedFeedbackAttachment[] = []; + private draftClientRequestId: string | null = null; + private draftReplyToId: number | null = null; private draftBeforeEdit: string = ''; comment = { @@ -99,9 +107,12 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC type: 'text', }; - @ViewChildren('commentInput') input: QueryList; - @ViewChildren('cag') cag: QueryList; + @ViewChildren('commentInput') input: QueryList>; @ViewChild('uploader') uploader: ElementRef; + @ViewChild('emojiPickerHost') emojiPickerHost?: ElementRef; + // The trigger is a Material button, so read its element rather than the component. + @ViewChild('emojiPickerButton', {read: ElementRef}) + emojiPickerButton?: ElementRef; differ: KeyValueDiffer; showEmojiPicker = false; @@ -112,7 +123,6 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC emojiMatch: string; showFeedbackTemplatePicker: boolean = false; recording = false; - cagStartWidth: number; constructor( private differs: KeyValueDiffers, @@ -124,6 +134,8 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC @Inject(TaskCommentService) private taskCommentService: TaskCommentService, private cdRef: ChangeDetectorRef, private userService: UserService, + private router: Router, + private draftStore: FeedbackDraftStore, public readonly theme: ThemeService, ) { this.differ = this.differs.find({}).create(); @@ -137,11 +149,14 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } catch (e) { console.error('Error loading submitted tasks:', e); } + + this.routeSubscription = this.router.events + .pipe(filter((event) => event instanceof NavigationStart)) + .subscribe(() => this.dismissEmojiPicker()); } public attachmentPolicy: AttachmentPolicy | null = null; public attachmentPolicyFailed = false; - public attachmentsUploading = 0; ngOnInit(): void { this.taskCommentService.attachmentPolicy().subscribe({ @@ -156,6 +171,11 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC }); } + // Staged files that are on their way to the server, for the upload status message. + public get attachmentsUploading(): number { + return this.stagedAttachments.filter((item) => item.status === 'uploading').length; + } + public get attachmentAccept(): string { return ( this.attachmentPolicy?.categories @@ -173,19 +193,31 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC const formats = this.attachmentPolicy.categories .map((item) => `${item.name}: ${item.extensions.join(', ').toUpperCase()}`) .join('; '); - return `${formats}. Each file must be smaller than ${this.attachmentPolicy.max_bytes_exclusive / 1_000_000} MB. Select up to ${this.attachmentPolicy.max_selection_count} files; confirm each separately.`; + return `${formats}. Each file must be smaller than ${this.attachmentPolicy.max_bytes_exclusive / 1_000_000} MB. Select up to ${this.attachmentPolicy.max_selection_count} files. They post when you press Send.`; } ngOnChanges(changes: SimpleChanges) { this.showFeedbackTemplatePicker = false; + this.dismissEmojiPicker(); if (changes.task && changes.task.currentValue !== changes.task.previousValue) { const newTask = changes.task.currentValue as Task; - // Check if the task has changed + const previousTask = changes.task.previousValue as Task; + if (previousTask) { + this.saveDraftForTask( + previousTask, + this.currentInputText, + this.sharedData?.originalComment?.id ?? this.draftReplyToId, + ); + } + this.cancelDraftLoadTimers(); + this.draftLoadGeneration += 1; this.cancelEdit(); - this.cancelReply(); - + this.sharedData.originalComment = null; + this.stagedAttachments = []; + this.draftClientRequestId = null; + this.draftReplyToId = null; this.clearInput(); if (newTask) { @@ -199,33 +231,28 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC if (this.task?.id) { this.loadDraftForTask(this.task); } + this.resizeMessageField(); }, 100); } - // ngOnDestroy() { - // if (this.task?.id) { - // try { - // const inputElement = this.input?.first?.nativeElement; - // if (inputElement) { - // const text = inputElement.innerText.trim(); - // if (text && !this.hasSubmittedComment) { - // localStorage.setItem(this.getDraftKey(this.task), text); - // } else { - // } - // } - // } catch (error) {} - // } - // } + ngOnDestroy(): void { + this.saveCurrentDraft(); + this.cancelDraftLoadTimers(); + this.draftLoadGeneration += 1; + this.routeSubscription.unsubscribe(); + this.dismissEmojiPicker(); + } // Update onInputChange to reset submitted status onInputChange(event: Event) { if (this.isEditing) { + this.resizeMessageField(); + this.keyTyped(); return; } - const target = event.target as HTMLElement; - const text = target.innerText; - const _raw = target.innerText; + const target = event.target as HTMLTextAreaElement; + const text = target.value; // If user is typing something new after submission, reset the submitted status if (this.task) { @@ -248,12 +275,11 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC console.error('Error saving submitted tasks:', e); } } - - const _draftKey = this.getDraftKey(this.task); - // this.taskDraftContents.set(draftKey, raw); } this.saveCurrentDraft(); + this.resizeMessageField(); + this.keyTyped(); } // The id of whoever is signed in, or null during sign out when currentUser has @@ -271,32 +297,31 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC return userId === null ? null : `${this.SUBMITTED_KEY_PREFIX}${userId}`; } - // The key used to name a task identified the task and never the person, so on a - // shared machine the next person to open the same task was handed the previous - // person's unsent words. - // - // The user segment is written as uid rather than the bare number. A legacy - // key is task_comment_draft_ or task_comment_draft__, - // so a bare number would make task_comment_draft_5_7 mean both "user 5, task 7" - // and "project 5, definition 7". The marker makes the two shapes impossible to - // confuse, which is what lets sign out sweep the old ones safely. - private getDraftKey(task: Task): string | null { + private draftContext(task: Task): FeedbackDraftContext | null { const userId = this.currentUserId(); - if (userId === null) { + if (userId === null || !task) { return null; } - // If task has an ID, use it - if (task.id) { - return `${this.DRAFT_KEY_PREFIX}uid${userId}_${task.id}`; + const projectId = task.projectId || task.project?.id || 0; + const taskDefinitionId = task.definition?.id || 0; + if (!task.id && (!projectId || !taskDefinitionId)) { + return null; } - // For "not started" tasks, create a composite key using only valid properties - const projectId = task.projectId || task.project?.id || 'unknown'; - // Fix: Use task.definition.id instead of task.definition_id - const definitionId = task.definition?.id || 'unknown'; + return { + userId, + unitId: task.unit?.id ?? null, + projectId, + taskDefinitionId, + taskId: task.id ?? null, + conversation: 'task-feedback', + }; + } - return `${this.DRAFT_KEY_PREFIX}uid${userId}_${projectId}_${definitionId}`; + private getDraftKey(task: Task): string | null { + const context = this.draftContext(task); + return context ? this.draftStore.key(context) : null; } private hasContent(raw: string): boolean { @@ -304,35 +329,29 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } // Update saveDraftForTask to use the taskDraftContents map - private saveDraftForTask(task: Task, _rawFromDom?: string): void { + private saveDraftForTask( + task: Task, + _rawFromDom?: string, + replyToId: number | null = this.originalComment?.id ?? this.draftReplyToId, + ): void { if (!task) { return; } - const draftKey = this.getDraftKey(task); - if (draftKey === null) { + const context = this.draftContext(task); + if (context === null) { return; } try { let raw: string; - if (this.task?.id === task.id && this.input.first) { - raw = this.input.first.nativeElement.innerText; + if (this.task?.id === task.id && this.input?.first) { + raw = _rawFromDom ?? this.input.first.nativeElement.value; } else { - // raw = this.taskDraftContents.get(draftKey) ?? ''; + raw = _rawFromDom ?? ''; } - if (!this.hasContent(raw)) { - // No text to save, removing draft from localStorage - // this.taskDraftContents.delete(draftKey); - localStorage.removeItem(draftKey); - return; - } - - const text = raw.trim(); - // Save comment draf - // this.taskDraftContents.set(draftKey, text); - localStorage.setItem(draftKey, text); + this.draftStore.save(context, raw, replyToId, this.draftClientRequestId); } catch (error) { console.error('saveDraftForTask error:', error); } @@ -343,44 +362,61 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC return; } - const taskKey = task.id || `${task.projectId || task.project?.id}_${task.definition?.id}`; - - if (this.submittedTaskIds.has(taskKey)) { - return; - } - - const draftKey = this.getDraftKey(task); - if (draftKey === null) { + const context = this.draftContext(task); + if (context === null) { return; } try { - const draft = localStorage.getItem(draftKey); + const generation = ++this.draftLoadGeneration; + const draft = this.draftStore.load(context); + this.stagedAttachments = this.draftStore.attachments(context); + this.draftClientRequestId = draft.clientRequestId; + this.draftReplyToId = draft.replyToId; - if (!draft) { + if (!draft.text && draft.replyToId === null && this.stagedAttachments.length === 0) { return; } const maxRetries = 5; const retryWithTimeout = (attempt = 0) => { + if ( + generation !== this.draftLoadGeneration || + this.getDraftKey(this.task) !== this.draftStore.key(context) + ) { + return; + } if (!this.input || !this.input.first || !this.input.first.nativeElement) { if (attempt < maxRetries) { - setTimeout(() => retryWithTimeout(attempt + 1), 200); + const timer = setTimeout(() => { + this.draftLoadTimers.delete(timer); + retryWithTimeout(attempt + 1); + }, 200); + this.draftLoadTimers.add(timer); return; } else { return; } } - this.input.first.nativeElement.innerText = draft; - // this.taskDraftContents.set(draftKey, draft); + this.input.first.nativeElement.value = draft.text; + if (draft.replyToId !== null) { + this.sharedData.originalComment = + task.comments?.find((comment) => comment.id === draft.replyToId) ?? null; + } + this.resizeMessageField(); this.isDraftLoaded = true; this.cdRef.detectChanges(); - setTimeout(() => { + const timer = setTimeout(() => { + this.draftLoadTimers.delete(timer); + if (generation !== this.draftLoadGeneration) { + return; + } this.isDraftLoaded = false; this.cdRef.detectChanges(); }, 1500); + this.draftLoadTimers.add(timer); }; retryWithTimeout(); @@ -389,9 +425,15 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } } + private cancelDraftLoadTimers(): void { + this.draftLoadTimers.forEach((timer) => clearTimeout(timer)); + this.draftLoadTimers.clear(); + } + private clearInput() { if (this.input?.first?.nativeElement) { - this.input.first.nativeElement.innerText = ''; + this.input.first.nativeElement.value = ''; + this.resizeMessageField(); this.cdRef.detectChanges(); } } @@ -404,6 +446,10 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } ngDoCheck() { + if (this.draftReplyToId !== null && this.originalComment == null) { + this.sharedData.originalComment = + this.task?.comments?.find((comment) => comment.id === this.draftReplyToId) ?? null; + } // Check to see if the sharedData has changed const change = this.differ.diff(this.sharedData); if (change) { @@ -432,8 +478,14 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC return this.task?.unit?.currentUserIsStaff; } + private get currentInputText(): string { + return this.input?.first?.nativeElement?.value ?? ''; + } + cancelReply() { this.sharedData.originalComment = null; + this.draftReplyToId = null; + this.saveCurrentDraft(); } cancelEdit() { @@ -441,12 +493,6 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC this.restoreDraftAfterEdit(); } - contentEditableValue() { - const UA = navigator.userAgent; - const isWebkit = /WebKit/.test(UA) && !/Edge/.test(UA); - return isWebkit ? 'plaintext-only' : 'true'; - } - formatImageName(imageName) { const index = imageName.indexOf('.'); let nameString = imageName.substring(0, index); @@ -462,29 +508,110 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC recordingMode(): void { this.recording = !this.recording; - this.$userIsTyping.next(true); + this.dismissEmojiPicker(); } - send(e: Event) { - e.preventDefault(); - this.emojiSearchMode = false; + get canSendMessage(): boolean { + return ( + !this.isSending && + (this.currentInputText.trim().length > 0 || this.stagedAttachments.length > 0) + ); + } + + get hasDiscardableDraft(): boolean { + return ( + !this.isEditing && + (this.hasContent(this.currentInputText) || + this.originalComment != null || + this.stagedAttachments.length > 0) + ); + } + + send(e?: Event) { + e?.preventDefault(); + if (!this.canSendMessage) { + return; + } + + if (this.isEditing) { + this.saveEditedComment(); + } else { + this.addComment(); + } + } + + toggleEmojiPicker(): void { + this.showEmojiPicker = !this.showEmojiPicker; + } + + dismissEmojiPicker(restoreTriggerFocus: boolean = false): void { + if (!this.showEmojiPicker) { + return; + } + this.showEmojiPicker = false; - if (this.input.first.nativeElement.innerText.trim() !== '') { - if (this.isEditing) { - this.saveEditedComment(); - } else { - this.addComment(); - } + if (restoreTriggerFocus) { + setTimeout(() => this.emojiPickerButton?.nativeElement.focus()); + } + } + + @HostListener('document:pointerdown', ['$event']) + onDocumentPointerDown(event: Event): void { + this.dismissEmojiPickerFromOutside(event); + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event): void { + this.dismissEmojiPickerFromOutside(event); + } + + private dismissEmojiPickerFromOutside(event: Event): void { + if (!this.showEmojiPicker) { + return; + } + + const target = event.target as Node | null; + if ( + target && + (this.emojiPickerHost?.nativeElement?.contains(target) || + this.emojiPickerButton?.nativeElement?.contains(target)) + ) { + return; + } + + this.dismissEmojiPicker(); + } + + @HostListener('document:keydown.escape', ['$event']) + onEscapePressed(event: KeyboardEvent): void { + if (!this.showEmojiPicker) { + return; } + + event.preventDefault(); + this.dismissEmojiPicker(true); + } + + resizeMessageField(): void { + const element = this.input?.first?.nativeElement; + if (!element) { + return; + } + + element.style.height = 'auto'; + const contentHeight = Math.max(element.scrollHeight, 24); + const nextHeight = Math.min(contentHeight, this.TEXTAREA_MAX_HEIGHT_PX); + element.style.height = `${nextHeight}px`; + element.style.overflowY = contentHeight > this.TEXTAREA_MAX_HEIGHT_PX ? 'auto' : 'hidden'; } keyTyped() { setTimeout(() => { - const commentText: string = this.input.first.nativeElement.innerText; + const commentText: string = this.currentInputText; this.emojiSearchMode = !commentText.includes('`') && this.emojiRegex.test(commentText); if (this.emojiSearchMode) { - // get the cursor position in the content-editable + // Get the cursor position in the textarea. const cursorPosition = this.caretOffset(); // get the text from the start of the string up to the cursor. @@ -512,36 +639,17 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } emojiSelected(emoji: string) { - this.input.first.nativeElement.innerText = this.input.first.nativeElement.innerText.replace( - `:${this.emojiMatch}`, - emoji, - ); + const element = this.input.first.nativeElement; + element.value = element.value.replace(`:${this.emojiMatch}`, emoji); this.emojiSearchMode = false; + this.resizeMessageField(); + this.saveCurrentDraft(); + element.focus(); } private caretOffset() { const element = this.input.first.nativeElement; - let caretOffset: number = 0; - const doc = element.ownerDocument || element.document; - const win = doc.defaultView || doc.parentWindow; - let sel; - if (typeof win.getSelection !== 'undefined') { - sel = win.getSelection(); - if (sel.rangeCount > 0) { - const range = win.getSelection().getRangeAt(0); - const preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretOffset = preCaretRange.toString().length; - } - } else if (sel === doc.selection && sel.type !== 'Control') { - const textRange = sel.createRange(); - const preCaretTextRange = doc.body.createTextRange(); - preCaretTextRange.moveToElementText(element); - preCaretTextRange.setEndPoint('EndToEnd', textRange); - caretOffset = preCaretTextRange.text.length; - } - return caretOffset; + return element.selectionStart ?? element.value.length; } addEmoji(e): void { @@ -551,25 +659,21 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } else { char = e.emoji.native; } - const text = this.input.first.nativeElement.innerText; + const element = this.input.first.nativeElement; const position = this.caretOffset(); - this.input.first.nativeElement.innerText = [ - text.slice(0, position), - char, - text.slice(position), - ].join(''); + element.setRangeText(char, position, element.selectionEnd ?? position, 'end'); + element.focus(); + this.resizeMessageField(); + this.saveCurrentDraft(); } addFeedback(template: FeedbackTemplate): void { const char = template.commentText; - const text = this.input.first.nativeElement.innerText; + const element = this.input.first.nativeElement; const position = this.caretOffset(); - this.input.first.nativeElement.innerText = [ - text.slice(0, position), - char, - text.slice(position), - ].join(''); - this.input.first.nativeElement.focus(); + element.setRangeText(char, position, element.selectionEnd ?? position, 'end'); + element.focus(); + this.resizeMessageField(); setTimeout(() => { this.saveDraftForTask(this.task); }); @@ -595,45 +699,114 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC if (this.isSending) { return; } - this.isSending = true; - const originalComment = this.sharedData.originalComment; - if (originalComment != null) { - this.cancelReply(); + if (this.stagedAttachments.length > 0) { + this.uploadAttachmentQueue([...this.stagedAttachments], 0, originalComment); + return; } - const text = this.emojiService.nativeEmojiToColons(this.input.first.nativeElement.innerText); + this.postDraftText(originalComment); + } - const taskKey = - this.task.id || `${this.task.projectId || this.task.project?.id}_${this.task.definition?.id}`; + private postDraftText(originalComment: TaskComment | null): void { + if (!this.hasContent(this.currentInputText)) { + this.finishSuccessfulDraft(); + return; + } - const draftKey = this.getDraftKey(this.task); - this.taskCommentService.addComment(this.task, text, 'text', originalComment).subscribe({ - next: (_tc: TaskComment) => { - this.isSending = false; + this.isSending = true; + this.draftClientRequestId ??= this.newClientRequestId(); + this.saveCurrentDraft(); - this.submittedTaskIds.add(taskKey); + const text = this.emojiService.nativeEmojiToColons(this.currentInputText); + this.taskCommentService + .addComment(this.task, text, 'text', originalComment, undefined, this.draftClientRequestId) + .subscribe({ + next: (_tc: TaskComment) => { + this.finishSuccessfulDraft(); + }, + error: (error: ApiError) => { + this.isSending = false; + this.alerts.error(this.uploadErrorMessage(error, 'Failed to send this message.'), 6000); + }, + }); + } - try { - const submittedKey = this.submittedKey(); - if (submittedKey) { - sessionStorage.setItem(submittedKey, JSON.stringify([...this.submittedTaskIds])); + private uploadAttachmentQueue( + queue: StagedFeedbackAttachment[], + index: number, + originalComment: TaskComment | null, + ): void { + if (index >= queue.length) { + this.isSending = false; + if (this.stagedAttachments.length === 0) { + this.postDraftText(originalComment); + } else { + this.alerts.error('Some attachments could not be sent. Remove them or retry.', 6000); + } + return; + } + + this.isSending = true; + const attachment = queue[index]; + this.updateStagedAttachment(attachment.clientRequestId, { + status: 'uploading', + progress: 0, + error: undefined, + }); + + this.taskCommentService + .uploadStagedAttachment( + this.task, + attachment.data, + attachment.fileName, + '', + originalComment, + attachment.clientRequestId, + ) + .subscribe({ + next: (state) => { + this.updateStagedAttachment(attachment.clientRequestId, { + status: 'uploading', + progress: state.progress, + }); + if (state.state === 'complete') { + this.removeStagedAttachment(attachment.clientRequestId, false, true); } - } catch (e) { - console.error('Error saving submitted tasks:', e); - } + }, + error: (error: ApiError) => { + this.updateStagedAttachment(attachment.clientRequestId, { + status: 'failed', + error: this.uploadErrorMessage(error, 'Upload failed. Retry or remove this file.'), + }); + this.uploadAttachmentQueue(queue, index + 1, originalComment); + }, + complete: () => this.uploadAttachmentQueue(queue, index + 1, originalComment), + }); + } - if (this.task) { - localStorage.removeItem(draftKey); - } + private finishSuccessfulDraft(): void { + this.isSending = false; + const taskKey = + this.task.id || `${this.task.projectId || this.task.project?.id}_${this.task.definition?.id}`; + this.submittedTaskIds.add(taskKey); + const submittedKey = this.submittedKey(); + if (submittedKey) { + sessionStorage.setItem(submittedKey, JSON.stringify([...this.submittedTaskIds])); + } - this.input.first.nativeElement.innerText = ''; - }, - error: (error: ApiError) => { - this.isSending = false; - this.alerts.error(error.error || error.message || `Failed to add comment: ${error}`, 6000); - }, - }); + const context = this.draftContext(this.task); + if (context) { + this.draftStore.clear(context); + } + this.stagedAttachments = []; + this.draftClientRequestId = null; + this.draftReplyToId = null; + this.sharedData.originalComment = null; + this.clearInput(); + this.emojiSearchMode = false; + this.dismissEmojiPicker(); + this.commentsViewer.scrollDown(); } saveEditedComment() { @@ -642,18 +815,20 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } this.isSending = true; - const text = this.emojiService.nativeEmojiToColons(this.input.first.nativeElement.innerText); + const text = this.emojiService.nativeEmojiToColons(this.currentInputText); this.taskCommentService.editComment(this.editingComment, text).subscribe({ next: (_tc: TaskComment) => { this.isSending = false; this.sharedData.editingComment = null; this.draftBeforeEdit = ''; + this.emojiSearchMode = false; + this.dismissEmojiPicker(); this.clearInput(); }, error: (error: ApiError) => { this.isSending = false; - this.alerts.error(error.error || error.message || `Failed to edit comment: ${error}`, 6000); + this.alerts.error(this.uploadErrorMessage(error, 'Failed to edit this comment.'), 6000); }, }); } @@ -686,7 +861,12 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC this.handleClipboardFiles(files, event); } + // Files are checked against the server-owned attachment policy, then staged on the + // draft. Nothing is posted until Send, so a staged file can still be removed. uploadFiles(files: ArrayLike) { + if (!files || files.length === 0) { + return; + } if (!this.attachmentPolicy) { this.alerts.error('Attachment requirements are unavailable. Reload and try again.', 6000); return; @@ -699,18 +879,19 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC this.resetUploader(); return; } - const acceptedFiles: File[] = []; + Array.from(files).forEach((file) => { - if (!attachmentCategory(this.attachmentPolicy, file)) { - this.alerts.error('Unsupported attachment format. Choose one of the listed formats.', 6000); - } else if (file.size === 0 || file.size >= this.attachmentPolicy.max_bytes_exclusive) { - this.alerts.error('Attachments must not be empty and must be smaller than 30 MB.', 6000); - } else { - acceptedFiles.push(file); + const validationError = this.attachmentValidationError(file); + if (validationError) { + this.alerts.error(validationError, 6000); + return; } + + this.stageAttachment(file, file.name, 'file'); }); - this.confirmAttachmentsSequentially(acceptedFiles); + this.resetUploader(); + this.saveCurrentDraft(); } private lastClipboardPasteSignature = ''; @@ -753,12 +934,182 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC this.lastClipboardPasteSignature = signature; this.lastClipboardPasteAt = now; - const existingText = this.input?.first?.nativeElement?.innerText ?? ''; + const existingText = this.currentInputText; this.clearPastedPlaceholderContent(existingText); this.uploadFiles(approvedImages); } + stageAudioRecording(recording: Blob): void { + if (!recording || recording.size === 0) { + return; + } + for (const existing of this.stagedAttachments.filter((item) => item.kind === 'audio')) { + this.removeStagedAttachment(existing.clientRequestId, false); + } + const extension = recording.type.includes('ogg') ? 'ogg' : 'webm'; + this.stageAttachment(recording, `feedback-recording.${extension}`, 'audio'); + this.saveCurrentDraft(); + } + + removeStagedAttachment( + clientRequestId: string, + save: boolean = true, + allowUploading: boolean = false, + ): void { + const context = this.draftContext(this.task); + if (!context) { + return; + } + const attachment = this.stagedAttachments.find( + (item) => item.clientRequestId === clientRequestId, + ); + if (attachment?.status === 'uploading' && !allowUploading) { + return; + } + this.draftStore.removeAttachment(context, clientRequestId); + this.stagedAttachments = this.draftStore.attachments(context); + if (save) { + this.saveCurrentDraft(); + } + } + + retryStagedAttachment(clientRequestId: string): void { + if (this.isSending) { + return; + } + const attachment = this.stagedAttachments.find( + (item) => item.clientRequestId === clientRequestId, + ); + if (attachment) { + this.uploadAttachmentQueue([attachment], 0, this.originalComment); + } + } + + discardDraft(): void { + const context = this.draftContext(this.task); + if (context) { + this.draftStore.clear(context); + } + this.stagedAttachments = []; + this.draftClientRequestId = null; + this.draftReplyToId = null; + this.sharedData.originalComment = null; + this.clearInput(); + this.recording = false; + } + + // The policy category a staged file falls under, such as PDF or Spreadsheet. + attachmentCategoryName(attachment: StagedFeedbackAttachment): string | null { + const named = {name: attachment.fileName} as File; + return attachmentCategory(this.attachmentPolicy, named)?.name ?? null; + } + + private stageAttachment(data: File | Blob, fileName: string, kind: 'file' | 'audio'): void { + const context = this.draftContext(this.task); + if (!context) { + this.alerts.error('Sign in before adding an attachment.', 4000); + return; + } + const attachment: StagedFeedbackAttachment = { + data, + fileName, + mimeType: data.type || 'application/octet-stream', + byteSize: data.size, + kind, + clientRequestId: this.newClientRequestId(), + status: 'staged', + progress: 0, + }; + this.draftStore.stageAttachment(context, attachment); + this.stagedAttachments = this.draftStore.attachments(context); + } + + private updateStagedAttachment( + clientRequestId: string, + update: Partial, + ): void { + const context = this.draftContext(this.task); + if (!context) { + return; + } + this.draftStore.updateAttachment(context, clientRequestId, update); + this.stagedAttachments = this.draftStore.attachments(context); + this.cdRef.detectChanges(); + } + + // The policy decides which formats and sizes are allowed. The extra DOCX and PDF + // checks catch a renamed file before it is uploaded. + private attachmentValidationError(file: File): string | null { + const policy = this.attachmentPolicy; + if (!attachmentCategory(policy, file)) { + return 'Unsupported attachment format. Choose one of the listed formats.'; + } + const limitMb = policy.max_bytes_exclusive / 1_000_000; + if (file.size === 0) { + return `${file.name} is empty. Attachments must not be empty.`; + } + if (file.size >= policy.max_bytes_exclusive) { + return `${file.name} is too large. Attachments must be smaller than ${limitMb} MB.`; + } + + const mimeType = (file.type ?? '').toLowerCase(); + const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; + const unknownMime = mimeType === '' || mimeType === 'application/octet-stream'; + if (extension === 'docx' || mimeType === DOCX_MIME_TYPE) { + return extension === 'docx' && (mimeType === DOCX_MIME_TYPE || unknownMime) + ? null + : `${file.name} does not match the DOCX file type.`; + } + if (extension === 'pdf' || mimeType === 'application/pdf' || mimeType === 'image/pdf') { + return extension === 'pdf' && + (mimeType === 'application/pdf' || mimeType === 'image/pdf' || unknownMime) + ? null + : `${file.name} does not match the PDF file type.`; + } + return null; + } + + private newClientRequestId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`; + } + + private uploadErrorMessage(error: unknown, fallback: string): string { + const failure = error as { + error?: string | {error?: string; message?: string}; + message?: string; + status?: number; + name?: string; + }; + if (failure?.status === 413) { + return attachmentError(failure); + } + // A proxy can answer with an HTML page, which is no use as a message. + if ( + typeof failure?.error === 'string' && + failure.error.trim() && + !failure.error.trim().startsWith('<') + ) { + return failure.error; + } + if (failure?.error && typeof failure.error === 'object') { + const nested = failure.error.error || failure.error.message; + if (nested) { + return nested; + } + } + if (failure?.status === 0) { + return 'You appear to be offline. Your draft is still here; reconnect and retry.'; + } + if (failure?.name === 'TimeoutError' || failure?.status === 408 || failure?.status === 504) { + return 'The upload timed out. Your draft is still here; retry when the connection is stable.'; + } + return failure?.message || fallback; + } + private getClipboardFiles(event: ClipboardEvent): File[] { const clipboardData = event.clipboardData; @@ -784,50 +1135,19 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC // Let the browser finish the paste event lifecycle, then restore the pre-paste text // so clipboard attachment placeholders do not replace an in-progress draft. - setTimeout(() => { - this.input.first.nativeElement.innerText = existingText; + const generation = this.draftLoadGeneration; + const draftKey = this.getDraftKey(this.task); + const timer = setTimeout(() => { + this.draftLoadTimers.delete(timer); + if (generation !== this.draftLoadGeneration || draftKey !== this.getDraftKey(this.task)) { + return; + } + this.input.first.nativeElement.value = existingText; + this.resizeMessageField(); this.saveCurrentDraft(); this.cdRef.detectChanges(); }); - } - - postAttachmentComment(file: File) { - this.attachmentsUploading++; - this.taskCommentService - .addComment(this.task, file, 'file', null) - .pipe( - finalize(() => { - this.attachmentsUploading--; - this.cdRef.markForCheck(); - }), - ) - .subscribe({ - next: () => this.commentsViewer.scrollDown(), - error: (error) => this.alerts.error(attachmentError(error), 6000), - }); - } - - private confirmAttachmentsSequentially(files: File[], index: number = 0) { - if (index >= files.length) { - return; - } - - const dialogRef = this.dialog.open(AttachmentConfirmationDialogComponent, { - data: { - file: files[index], - category: attachmentCategory(this.attachmentPolicy, files[index])?.name, - }, - maxWidth: '720px', - width: 'min(92vw, 720px)', - }); - - dialogRef.afterClosed().subscribe((confirmed: boolean) => { - if (confirmed) { - this.postAttachmentComment(files[index]); - } - - this.confirmAttachmentsSequentially(files, index + 1); - }); + this.draftLoadTimers.add(timer); } private resetUploader() { @@ -847,13 +1167,15 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC return; } + this.draftReplyToId = this.originalComment?.id ?? null; + this.saveCurrentDraft(); setTimeout(() => { this.input.first.nativeElement.focus(); }); } private beginEditingComment() { - const currentText = this.input?.first?.nativeElement?.innerText ?? ''; + const currentText = this.currentInputText; const nextText = this.editingComment?.text ?? ''; if (this.sharedData.originalComment != null) { @@ -881,7 +1203,8 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC return; } - this.input.first.nativeElement.innerText = text; + this.input.first.nativeElement.value = text; + this.resizeMessageField(); this.cdRef.detectChanges(); } @@ -892,18 +1215,7 @@ export class TaskCommentComposerComponent implements AfterViewInit, DoCheck, OnC } element.focus(); - - const selection = window.getSelection(); - if (!selection) { - return; - } - - const range = document.createRange(); - range.selectNodeContents(element); - range.collapse(false); - - selection.removeAllRanges(); - selection.addRange(range); + element.setSelectionRange(element.value.length, element.value.length); } } diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts b/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts index ccf3172095..6cd9419c55 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts +++ b/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts @@ -1,14 +1,17 @@ import {EmojiSearch} from '@ctrl/ngx-emoji-mart'; -import {beforeEach, describe, expect, it} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {CommonModule} from '@angular/common'; -import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ElementRef, NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; import {MatDialog} from '@angular/material/dialog'; import {provideNoopAnimations} from '@angular/platform-browser/animations'; -import {of} from 'rxjs'; +import {NavigationStart, Router} from '@angular/router'; +import {Subject, of} from 'rxjs'; import {TaskCommentService, UserService} from 'src/app/api/models/doubtfire-model'; import {AlertService} from 'src/app/common/services/alert.service'; import {EmojiService} from 'src/app/common/services/emoji.service'; +import {FeedbackDraftStore} from 'src/app/common/services/feedback-draft-store.service'; import {ThemeService} from 'src/app/common/theme/theme.service'; import {TaskCommentsViewerComponent} from '../task-comments-viewer/task-comments-viewer.component'; import {TaskCommentComposerComponent} from './task-comment-composer.component'; @@ -30,8 +33,29 @@ function memoryStorage(): Storage { describe('TaskCommentComposerComponent phone actions', () => { let fixture: ComponentFixture; + let routerEvents: Subject; + let taskCommentService: { + addComment: ReturnType; + editComment: ReturnType; + uploadStagedAttachment: ReturnType; + attachmentPolicy: ReturnType; + }; beforeEach(async () => { + routerEvents = new Subject(); + taskCommentService = { + addComment: vi.fn(() => of({id: 1})), + editComment: vi.fn(() => of({id: 1})), + uploadStagedAttachment: vi.fn(() => of({state: 'complete', progress: 100})), + attachmentPolicy: vi.fn(() => + of({ + version: 1, + categories: [], + max_bytes_exclusive: 30_000_000, + max_selection_count: 5, + }), + ), + }; // Node exposes storage globals without values unless it is launched with storage files. // Use isolated browser-compatible stores while rendering this storage-aware component. Object.defineProperty(globalThis, 'localStorage', { @@ -44,28 +68,22 @@ describe('TaskCommentComposerComponent phone actions', () => { }); await TestBed.configureTestingModule({ - imports: [CommonModule], + imports: [CommonModule, FormsModule], declarations: [TaskCommentComposerComponent], providers: [ provideNoopAnimations(), {provide: MatDialog, useValue: {}}, - {provide: EmojiSearch, useValue: {}}, - {provide: EmojiService, useValue: {}}, - {provide: TaskCommentsViewerComponent, useValue: {}}, - {provide: AlertService, useValue: {}}, + {provide: EmojiSearch, useValue: {search: vi.fn(() => [])}}, { - provide: TaskCommentService, - useValue: { - attachmentPolicy: () => - of({ - version: 1, - categories: [], - max_bytes_exclusive: 30_000_000, - max_selection_count: 5, - }), - }, + provide: EmojiService, + useValue: {nativeEmojiToColons: vi.fn((text: string) => text)}, }, + {provide: TaskCommentsViewerComponent, useValue: {scrollDown: vi.fn()}}, + {provide: AlertService, useValue: {}}, + {provide: TaskCommentService, useValue: taskCommentService}, {provide: UserService, useValue: {currentUser: {id: 1}}}, + {provide: Router, useValue: {events: routerEvents.asObservable()}}, + {provide: FeedbackDraftStore, useFactory: () => new FeedbackDraftStore()}, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -89,6 +107,25 @@ describe('TaskCommentComposerComponent phone actions', () => { } ).ɵcmp.styles.join('\n'); + const textarea = (): HTMLTextAreaElement => + fixture.nativeElement.querySelector( + 'textarea[aria-label="Task comment"]', + ) as HTMLTextAreaElement; + + const sendButton = (): HTMLButtonElement => + fixture.nativeElement.querySelector('button[aria-label="Send message"]') as HTMLButtonElement; + + const emojiButton = (): HTMLButtonElement => + fixture.nativeElement.querySelector( + 'button[aria-label="Choose an emoji"]', + ) as HTMLButtonElement; + + const enterText = (text: string): void => { + textarea().value = text; + textarea().dispatchEvent(new Event('input', {bubbles: true})); + fixture.detectChanges(); + }; + it('keeps clear accessible names on the attachment and microphone controls', () => { const attach = fixture.nativeElement.querySelector( 'button[aria-label="Attach a file"]', @@ -103,6 +140,149 @@ describe('TaskCommentComposerComponent phone actions', () => { expect(record.classList).toContain('composer-touch-action'); }); + it('uses a labelled multiline textarea and an explicit visible Send button', () => { + expect(textarea()).toBeTruthy(); + expect(textarea().placeholder).toBe('Write a message...'); + expect(textarea().getAttribute('enterkeyhint')).toBe('enter'); + expect(textarea().rows).toBe(1); + expect(sendButton()).toBeTruthy(); + expect(sendButton().textContent?.trim()).toBe('Send'); + expect(sendButton().disabled).toBe(true); + }); + + it('keeps attachment left of the field and microphone, emoji, and Send to its right', () => { + const orderedControls = Array.from( + fixture.nativeElement.querySelectorAll( + '.composer-container button, .composer-container textarea', + ), + ) as HTMLElement[]; + // Staff tools stay in the page for students but hidden, so only count what is shown. + const labels = orderedControls + .filter((element) => element.closest('[hidden]') === null) + .map((element) => element.getAttribute('aria-label')); + + expect(labels).toEqual([ + 'Attach a file', + 'Task comment', + 'Record audio feedback', + 'Choose an emoji', + 'Send message', + ]); + }); + + it('leaves Enter available for a new line instead of sending', () => { + enterText('First paragraph'); + const enter = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter', + }); + + textarea().dispatchEvent(enter); + + expect(enter.defaultPrevented).toBe(false); + expect(taskCommentService.addComment).not.toHaveBeenCalled(); + }); + + it('prevents whitespace sends and sends non-empty text only from the explicit action', () => { + enterText(' \n '); + expect(sendButton().disabled).toBe(true); + sendButton().click(); + expect(taskCommentService.addComment).not.toHaveBeenCalled(); + + enterText('First line\nSecond line'); + expect(sendButton().disabled).toBe(false); + sendButton().click(); + + expect(taskCommentService.addComment).toHaveBeenCalledOnce(); + expect(taskCommentService.addComment).toHaveBeenCalledWith( + fixture.componentInstance.task, + 'First line\nSecond line', + 'text', + null, + undefined, + expect.any(String), + ); + }); + + it('disables Send while the request is in flight', () => { + const response: Subject<{id: number}> = new Subject(); + taskCommentService.addComment.mockReturnValue(response); + enterText('Please send this once'); + + sendButton().click(); + fixture.detectChanges(); + + expect(taskCommentService.addComment).toHaveBeenCalledOnce(); + expect(sendButton().disabled).toBe(true); + expect(sendButton().textContent?.trim()).toBe('Sending…'); + }); + + it('auto-grows to its cap and then scrolls inside the textarea', () => { + Object.defineProperty(textarea(), 'scrollHeight', {configurable: true, value: 260}); + + enterText('A long multiline message'); + + expect(textarea().style.height).toBe('144px'); + expect(textarea().style.overflowY).toBe('auto'); + }); + + it('keeps taps inside the emoji picker open and dismisses on an outside tap', () => { + emojiButton().click(); + fixture.detectChanges(); + const picker = fixture.nativeElement.querySelector('.emoji-picker-host') as HTMLElement; + + picker.dispatchEvent(new Event('pointerdown', {bubbles: true})); + expect(fixture.componentInstance.showEmojiPicker).toBe(true); + + document.body.dispatchEvent(new Event('pointerdown', {bubbles: true})); + expect(fixture.componentInstance.showEmojiPicker).toBe(false); + + emojiButton().click(); + picker.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(fixture.componentInstance.showEmojiPicker).toBe(true); + + const backdrop = fixture.nativeElement.querySelector( + '.emoji-picker-backdrop', + ) as HTMLButtonElement; + backdrop.click(); + expect(fixture.componentInstance.showEmojiPicker).toBe(false); + }); + + it('dismisses safely while an emoji ViewChild is between render states', () => { + emojiButton().click(); + fixture.componentInstance.emojiPickerHost = new ElementRef( + undefined as unknown as HTMLElement, + ); + + expect(() => + fixture.componentInstance.onDocumentPointerDown(new Event('pointerdown', {bubbles: true})), + ).not.toThrow(); + expect(fixture.componentInstance.showEmojiPicker).toBe(false); + }); + + it('dismisses the emoji picker on Escape, route change, task change, and successful send', () => { + emojiButton().click(); + document.dispatchEvent( + new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: 'Escape'}), + ); + expect(fixture.componentInstance.showEmojiPicker).toBe(false); + + emojiButton().click(); + routerEvents.next(new NavigationStart(1, '/projects/1/dashboard/1.2P')); + expect(fixture.componentInstance.showEmojiPicker).toBe(false); + + emojiButton().click(); + fixture.componentRef.setInput('task', {id: 124, unit: {currentUserIsStaff: false}}); + fixture.detectChanges(); + expect(fixture.componentInstance.showEmojiPicker).toBe(false); + + emojiButton().click(); + enterText('Send and close'); + sendButton().click(); + expect(fixture.componentInstance.showEmojiPicker).toBe(false); + }); + it('updates the emoji picker when the resolved theme changes', () => { const theme = TestBed.inject(ThemeService); const picker = fixture.nativeElement.querySelector('emoji-mart') as HTMLElement & { @@ -132,14 +312,14 @@ describe('TaskCommentComposerComponent phone actions', () => { expect(phoneStyles).toMatch(/height:\s*48px/); expect(phoneStyles).toMatch(/transform:\s*none/); expect(phoneStyles).toMatch(/border:\s*1px solid var\(--ot-color-border\)/); - expect(phoneStyles).toMatch(/background-color:\s*var\(--ot-color-page\)/); + expect(phoneStyles).toMatch(/background-color:\s*var\(--ot-color-surface-raised\)/); }); it('keeps the phone composer away from both safe-area edges', () => { const styles = componentStyles(); - const phoneStyles = styles.slice(styles.indexOf('@media (max-width: 639.98px)')); - expect(phoneStyles).toMatch(/padding-left:\s*max\(8px,\s*env\(safe-area-inset-left\)\)/); - expect(phoneStyles).toMatch(/padding-right:\s*max\(8px,\s*env\(safe-area-inset-right\)\)/); + expect(styles).toContain('env(safe-area-inset-left)'); + expect(styles).toContain('env(safe-area-inset-right)'); + expect(styles).toContain('env(safe-area-inset-bottom)'); }); }); diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html index 7e2b6b79c6..b299b82d13 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component.html @@ -1,70 +1,82 @@ -
-
+
    +
  • Allow automatic post-feedback extensions -

    +

    Apply the unit's resubmission extension setting when feedback requires more work near the due date. Turning this off affects future feedback only; existing extensions are kept. The unit must grant resubmission weeks and use fixed dates for this rule to apply.

    -
-
- + + +
  • + Restrict updates - - This task will be locked once it goes into a staff-allocated status. Students will not be able - to resubmit, even if placed on the fix status. - -
  • +

    + Once staff give the task a status, students cannot resubmit it, even when it is set to Fix and + resubmit. +

    + -
    +
  • Assess in Portfolio Only - - This task cannot be signed off as complete and will only be assessed in the final portfolio. - Tutors can leave feedback and return it to the "Working on it" state. Students must then - update the status to "Assess in Portfolio" to ensure it's included in their portfolio. - -
  • - -
    - - Requires Discussion + Assess in portfolio only - - Tutors cannot mark this task as complete unless it has first been marked as discussed in - class. - -
    +

    + The task cannot be signed off as complete. It is marked in the final portfolio. Tutors can + give feedback and send it back to Working on it, and students then set it to Assess in + portfolio so it is included. +

    + -
    - Graded + - - Provide a grade along with the complete or discuss status. We recommend avoiding this - practice. - -
    + Requires discussion + +

    + Tutors cannot mark the task complete until it has been discussed in class. +

    + -
    - - Quality Stars - +
  • + + Graded + +

    + Tutors give a grade along with the Complete or Discuss status. We recommend you avoid this. +

    +
  • + +
  • + + Quality stars + - Provide a number of stars alongside the task status. Make sure you have a clear reason for - each star within your task description. -
  • -
    +

    + Tutors give up to this many stars along with the status. Explain what earns each star in the + task description. +

    + + diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html index 786fc89613..1a0ad03cba 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.html @@ -1,196 +1,195 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + +
    Filename - - - - Type - - Code - PDF - Spreadsheet (CSV, XLS, XLSX) - Image - Archive (ZIP, TAR, TAR.GZ, TGZ) - @if (upreq.type === 'archive') { - Archive (legacy) - } - - Submission History - - Check Similarity - @if (upreq.type === 'document' && tiiEnabled()) { - TurnItIn - } - @if (upreq.type === 'code') { - Jplag - } - Flag At - @if (upreq.type === 'document' && upreq.tiiCheck && tiiEnabled()) { - + +
    + + + + - + + - - - - - + + + + - - - + + + + + + - + } @else { + Not for this type + } + + + + + + + + + + + + - - - -
    File name + -  % - } - - - Type + + + Code + PDF + Spreadsheet (CSV, XLS, XLSX) + Image + Archive (ZIP, TAR, TAR.GZ, TGZ) + @if (upreq.type === 'archive') { + Archive (legacy) + } + + + - - - - Keep in history + + Similarity check + @if (upreq.type === 'document' && tiiEnabled()) { + Turnitin + } @else if (upreq.type === 'code') { + JPlag - add Add - - - - Flag at + @if (upreq.type === 'document' && upreq.tiiCheck && tiiEnabled()) { + + + % + + } @else { + + } + Remove + +
    +
    + +
    +
    + +
    + +
    @if (missingOverseerSubmissionHistory) { -
    - Overseer requires at least one upload requirement to be retained in submission history. + } @if (taskDefinition.needsJplag) { -
    - - Language used for JPLAG checks - - Java - C - C++ - C# - Python - JavaScript - TypeScript - Go - Kotlin - R - Rust - Swift - Scala - LLVM IR - Scheme - EMF Metamodel - EMF Model - SCXML - Text - Multi-language - - - - Similarity percent to flag for JPLAG checks - - -
    -
    -
    - - Use Task Resources for JPlag Base Code -

    - Base code is a common framework included in all submissions. When enabled, student code - matching this base code will be ignored during JPlag similarity checks. The base code will - be automatically extracted from the task resources. -

    +
    +

    Code similarity (JPlag)

    +
    + + Language + + Java + C + C++ + C# + Python + JavaScript + TypeScript + Go + Kotlin + R + Rust + Swift + Scala + LLVM IR + Scheme + EMF Metamodel + EMF Model + SCXML + Text + Multi-language + + + + Flag similarity at + + % +
    -
    + + Use the task resources as base code + +

    + Base code is the starter code every student gets. JPlag ignores code that matches it. It is + taken from the task resources ZIP. +

    } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss index c20dee570a..e69de29bb2 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-upload/task-definition-upload.component.scss @@ -1,9 +0,0 @@ -.upload-card { - max-width: 400px; - margin-bottom: 8px; -} - -.mat-toolbar { - background-color: var(--ot-color-surface); - color: var(--ot-color-text); -} diff --git a/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts index b03e252a6f..8f77da2446 100644 --- a/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts +++ b/src/app/units/states/portfolios/d2l-transfer-modal/d2l-transfer.component.ts @@ -1,5 +1,5 @@ // -// Modal to show Doubtfire version info +// Dialog that walks a convenor through sending the unit's portfolio grades to D2L // import {HttpClient} from '@angular/common/http'; import {ChangeDetectionStrategy, Component, Inject, Injectable, OnInit} from '@angular/core'; @@ -20,6 +20,8 @@ import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; }) export class D2lTransferComponent implements OnInit { public d2lDataMapping: D2lAssessmentMapping = new D2lAssessmentMapping(this.data); + public loadingMapping = true; + public startingTransfer = false; private apiEndpoint: string; private weightedUnit: boolean = false; @@ -38,6 +40,7 @@ export class D2lTransferComponent implements OnInit { this.data.loadD2lMapping().subscribe({ next: (d2lDataMapping) => { this.d2lDataMapping = d2lDataMapping; + this.loadingMapping = false; // If we have the org unit it, then we can check the grading standard if (this.d2lDataMapping.orgUnitId) { @@ -47,6 +50,7 @@ export class D2lTransferComponent implements OnInit { error: (_err) => { // No mapping found, create a new one this.d2lDataMapping = new D2lAssessmentMapping(this.data); + this.loadingMapping = false; }, }); @@ -55,7 +59,7 @@ export class D2lTransferComponent implements OnInit { this.apiEndpoint = response; }, error: (err) => { - this.alertService.error(`Failed to get locaiton of D2L instance: ${err}`); + this.alertService.error(`Failed to get location of D2L instance: ${err}`); }, }); } @@ -85,9 +89,18 @@ export class D2lTransferComponent implements OnInit { }); } + // The grade item lives on the D2L site, whose address arrives from the api. Until it + // does, the link would start with "undefined", so the button waits for it. + public get canOpenWeightPage(): boolean { + return !!this.apiEndpoint; + } + public openWeightPage(): void { + if (!this.canOpenWeightPage) { + return; + } + const url = `${this.apiEndpoint}/d2l/lms/grades/admin/manage/item_props_newedit.d2l?objectId=${this.d2lDataMapping.gradeObjectId}&ou=${this.d2lDataMapping.orgUnitId}&scroll=weight`; - console.log(url); window.open(url, '_blank'); } @@ -99,13 +112,21 @@ export class D2lTransferComponent implements OnInit { return this.weightedUnit; } + // A second click while the first request is out would queue a second transfer. public startTransfer(): void { + if (this.startingTransfer) { + return; + } + + this.startingTransfer = true; const url = `${this.doubtfireConstants.API_URL}/units/${this.data.id}/d2l/grades`; this.httpClient.post(url, {}).subscribe({ next: () => { + this.startingTransfer = false; this.alertService.success('Transfer started'); }, error: (err) => { + this.startingTransfer = false; this.alertService.error(`Failed to start transfer: ${err}`); }, }); @@ -137,7 +158,7 @@ export class D2lTransferComponent implements OnInit { } /** - * The about doubtfire modal service - used to create and show the modal + * Opens the D2L grade transfer dialog for a unit */ @Injectable() @@ -145,7 +166,6 @@ export class D2lTransferModal { constructor(public dialog: MatDialog) {} public open(unit: Unit): void { - // Show dialog while the data above is being fetched this.dialog.open(D2lTransferComponent, { width: '600px', data: unit, diff --git a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html index 659b786b6d..0b63c5e46b 100644 --- a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html +++ b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.html @@ -1,238 +1,350 @@ -
    -

    Mark portfolios

    -

    Assess student portfolios

    -
    -
    - - Filter - - - - public - menu_book - - - - account_balance - edit - - - - - All - - @for (grade of gradeValues; track grade) { - - - +
    + +
    +

    Portfolios

    +

    + @if (loading) { + Loading the students in this unit. + } @else if (loadError) { + The students in this unit could not be loaded. + } @else { + {{ summary }} + @if (studentCount > 0) { + Pick a student to see their progress, read their portfolio and record a grade. } - -

    + } +

    -
    - +
    + + + + + - - + @if (hasD2lMapping()) { - }
    + + + + +
    @if (loading) { -
    -
    - - +
    + Loading students +
    + } @else if (loadError) { +
    + + +
    + } @else if (!hasRows) { +
    + @switch (emptyReason) { + @case ('no-students') { + + } + @case ('no-portfolios') { + + + } + @default { + + + } + } +
    } - @if (!loading && dataSource.filteredData.length === 0) { - - } - -
    + +
    - - - + - - - + - + - - + - - + - - + + - - - + - - + + - - + + - + - - - + + @@ -240,6 +352,7 @@

    Mark portfolios

    @@ -247,9 +360,11 @@

    Mark portfolios

    - + diff --git a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts index fe2f033bd6..08fa11a236 100644 --- a/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts +++ b/src/app/units/states/portfolios/directives/portfolios-list/portfolios-list.component.spec.ts @@ -1,7 +1,19 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {CommonModule} from '@angular/common'; import {NO_ERRORS_SCHEMA} from '@angular/core'; -import {TestBed} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatIconModule} from '@angular/material/icon'; +import {MatInputModule} from '@angular/material/input'; +import {MatMenuModule} from '@angular/material/menu'; +import {MatPaginator, MatPaginatorModule} from '@angular/material/paginator'; +import {MatSelectModule} from '@angular/material/select'; +import {MatSortModule} from '@angular/material/sort'; import {MatTableModule} from '@angular/material/table'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Project} from 'src/app/api/models/project'; import {Unit} from 'src/app/api/models/unit'; import {TaskService} from 'src/app/api/services/task.service'; @@ -12,9 +24,361 @@ import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloa import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {GradeService} from 'src/app/common/services/grade.service'; -import {SkeletonLoaderComponent} from 'src/app/common/skeleton-loader/skeleton-loader.component'; import {D2lTransferModal} from '../../d2l-transfer-modal/d2l-transfer.component'; -import {PortfoliosListComponent} from './portfolios-list.component'; +import {PortfolioMarkingStateService} from '../../portfolio-marking-state.service'; +import { + DEFAULT_PORTFOLIO_LIST_FILTERS, + PortfolioListFilters, + PortfoliosListComponent, +} from './portfolios-list.component'; + +interface StudentOptions { + id: number; + name: string; + hasPortfolio?: boolean; + submittedGrade?: number | null; + grade?: number | null; + gradeRationale?: string | null; + taskStats?: {key: string; value: number}[]; +} + +function studentStub(options: StudentOptions): Project { + return { + id: options.id, + student: {name: options.name, studentId: String(1000 + options.id), username: 'u'}, + hasPortfolio: options.hasPortfolio ?? false, + portfolioAvailable: false, + submittedGrade: options.submittedGrade ?? null, + targetGrade: 0, + grade: options.grade ?? 0, + gradeRationale: options.gradeRationale ?? null, + taskStats: options.taskStats ?? [], + portfolioSubmissionDate: null, + tutorNames: () => 'Tia Tutor', + shortTutorialDescription: () => 'LA1-01', + hasTutor: () => true, + } as unknown as Project; +} + +function unitStub(students: Project[], id = 1): Unit { + return {id, code: 'SIT101', students, hasD2lMapping: () => false} as unknown as Unit; +} + +describe('PortfoliosListComponent', () => { + let fixture: ComponentFixture; + let component: PortfoliosListComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PortfoliosListComponent], + imports: [ + CommonModule, + EmptyStateComponent, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatMenuModule, + MatPaginatorModule, + MatSelectModule, + MatSortModule, + MatTableModule, + MatTooltipModule, + NoopAnimationsModule, + ], + providers: [ + {provide: UserService, useValue: {currentUser: {id: 7}}}, + { + provide: GradeService, + useValue: { + gradeValuesFor: () => [0, 1, 2, 3], + gradeLabel: (grade: number) => ['Pass', 'Credit', 'Distinction', 'HD'][grade], + }, + }, + {provide: FileDownloaderService, useValue: {downloadFile: vi.fn()}}, + {provide: UnitService, useValue: {}}, + {provide: AlertService, useValue: {error: vi.fn()}}, + {provide: SidekiqProgressModalService, useValue: {}}, + {provide: D2lTransferModal, useValue: {open: vi.fn()}}, + ], + // f-grade-icon and f-upload-grades are not under test here. + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + async function render( + students: Project[], + filters: PortfolioListFilters = DEFAULT_PORTFOLIO_LIST_FILTERS, + ): Promise { + fixture = TestBed.createComponent(PortfoliosListComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('unit', unitStub(students)); + fixture.componentRef.setInput('filters', filters); + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + function rowNames(): string[] { + return Array.from( + fixture.nativeElement.querySelectorAll('tr.mat-mdc-row td:first-child'), + ).map((cell) => cell.textContent.trim()); + } + + function text(): string { + return fixture.nativeElement.textContent.replace(/\s+/g, ' '); + } + + function buttonNamed(label: string): HTMLButtonElement { + return Array.from(fixture.nativeElement.querySelectorAll('button')).find( + (button) => button.textContent.trim() === label, + ); + } + + const withPortfolios = () => [ + studentStub({id: 1, name: 'Zed Zulu', hasPortfolio: true, submittedGrade: 1}), + studentStub({id: 2, name: 'Amy Adams', hasPortfolio: true, submittedGrade: 2}), + studentStub({id: 3, name: 'Mia Moss', hasPortfolio: true, submittedGrade: 1}), + ]; + + // The header said the list was sorted, but the rows came in the order the api sent. + it('sorts the rows the way the header says on first load', async () => { + await render(withPortfolios()); + + expect(rowNames()).toEqual(['Amy Adams', 'Mia Moss', 'Zed Zulu']); + }); + + // A filter change used to put back the unsorted list under a sorted header. + it('keeps the chosen sort when a filter changes', async () => { + await render(withPortfolios()); + + const nameHeader: HTMLElement = fixture.nativeElement.querySelector('th.mat-column-name'); + nameHeader.click(); + fixture.detectChanges(); + expect(rowNames()).toEqual(['Zed Zulu', 'Mia Moss', 'Amy Adams']); + + component.onGradeFilterChange({value: 1}); + fixture.detectChanges(); + + expect(rowNames()).toEqual(['Zed Zulu', 'Mia Moss']); + }); + + it('labels every filter and names the student on the open button', async () => { + await render(withPortfolios()); + + const labels = Array.from(fixture.nativeElement.querySelectorAll('mat-label')).map( + (label) => label.textContent.trim(), + ); + expect(labels).toEqual(['Search students', 'Show', 'Tutorials', 'Grade applied for']); + + const openButton: HTMLButtonElement = fixture.nativeElement.querySelector( + 'tr.mat-mdc-row button[aria-label]', + ); + expect(openButton.getAttribute('aria-label')).toBe( + 'Open the dashboard for Amy Adams in a new tab', + ); + }); + + it('offers to show everyone when nobody has submitted a portfolio yet', async () => { + await render([studentStub({id: 1, name: 'Amy Adams'})]); + const emitted: PortfolioListFilters[] = []; + component.filtersChange.subscribe((filters) => emitted.push(filters)); + + expect(text()).toContain('No portfolios submitted yet'); + expect(fixture.nativeElement.querySelector('table').closest('.hidden')).not.toBeNull(); + + buttonNamed('Show all students').click(); + fixture.detectChanges(); + + expect(emitted.at(-1).portfolioFilter).toBe('all'); + expect(rowNames()).toEqual(['Amy Adams']); + expect(fixture.nativeElement.querySelector('table').closest('.hidden')).toBeNull(); + }); + + it('offers to clear the filters when they hide everyone', async () => { + await render(withPortfolios(), {...DEFAULT_PORTFOLIO_LIST_FILTERS, filterText: 'nobody'}); + + expect(text()).toContain('No students match these filters'); + + const clear = Array.from( + fixture.nativeElement.querySelectorAll('section button'), + ).find((button) => button.textContent.trim() === 'Clear filters'); + clear.click(); + fixture.detectChanges(); + + expect(rowNames()).toHaveLength(3); + }); + + it('says a student with the starting 0 has not been graded', async () => { + await render([ + studentStub({id: 1, name: 'Amy Adams', hasPortfolio: true}), + studentStub({ + id: 2, + name: 'Bo Brown', + hasPortfolio: true, + grade: 0, + gradeRationale: 'Nothing was submitted', + }), + ]); + + const gradeCells = Array.from( + fixture.nativeElement.querySelectorAll('td.mat-column-grade'), + ).map((cell) => cell.textContent.trim()); + expect(gradeCells).toEqual(['Not graded', '0']); + }); + + const thirtyStudents = () => + Array.from({length: 30}, (_, index) => + studentStub({ + id: index + 1, + name: `Student ${String(index + 1).padStart(2, '0')}`, + hasPortfolio: true, + }), + ); + + function paginator(): MatPaginator { + return fixture.debugElement.query(By.directive(MatPaginator)).componentInstance; + } + + // Opening a student is a new route, so the list is built again when the tutor comes + // back. It used to start again on the first page, sorted by name. + it('comes back to the sort and page the tutor left', async () => { + await render(thirtyStudents()); + fixture.nativeElement.querySelector('th.mat-column-name').click(); + fixture.detectChanges(); + paginator().nextPage(); + fixture.detectChanges(); + fixture.destroy(); + + await render(thirtyStudents()); + + expect(paginator().pageIndex).toBe(1); + expect(rowNames()).toEqual([ + 'Student 05', + 'Student 04', + 'Student 03', + 'Student 02', + 'Student 01', + ]); + }); + + // With no rows yet the table drops a restored page to the first on its own. + it('keeps a restored page when the students arrive after the list is built', async () => { + TestBed.inject(PortfolioMarkingStateService).rememberListView(1, {pageIndex: 1}); + fixture = TestBed.createComponent(PortfoliosListComponent); + fixture.componentRef.setInput('unit', unitStub(thirtyStudents())); + fixture.componentRef.setInput('filters', DEFAULT_PORTFOLIO_LIST_FILTERS); + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(paginator().pageIndex).toBe(1); + expect(rowNames()[0]).toBe('Student 26'); + }); + + it('keeps the page when the students load again after a grades upload', async () => { + await render(thirtyStudents()); + paginator().nextPage(); + fixture.detectChanges(); + + fixture.componentRef.setInput('loading', true); + fixture.detectChanges(); + fixture.componentRef.setInput('loading', false); + fixture.detectChanges(); + + expect(paginator().pageIndex).toBe(1); + }); + + // Switching unit hands the list that unit's own filters at the same time, which + // used to count as a filter edit and wipe the page remembered for it. + it('opens another unit on the page remembered for it', async () => { + TestBed.inject(PortfolioMarkingStateService).rememberListView(2, {pageIndex: 1}); + await render(thirtyStudents()); + + fixture.componentRef.setInput('unit', unitStub(thirtyStudents(), 2)); + fixture.componentRef.setInput('filters', {...DEFAULT_PORTFOLIO_LIST_FILTERS, filterText: '1'}); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(paginator().pageIndex).toBe(1); + expect(TestBed.inject(PortfolioMarkingStateService).listViewFor(2).pageIndex).toBe(1); + }); + + // Setting the search and the rows one after the other made the table work out the + // page twice, and the answer from the old rows could land last. + it('keeps the remembered page when a unit and its filters arrive together', async () => { + const hundred = Array.from({length: 100}, (_, index) => + studentStub({id: index + 1, name: `Student ${index + 1}`, hasPortfolio: true}), + ); + TestBed.inject(PortfolioMarkingStateService).rememberListView(2, {pageIndex: 2}); + await render(thirtyStudents().slice(0, 10)); + + fixture.componentRef.setInput('unit', unitStub(hundred, 2)); + fixture.componentRef.setInput('filters', {...DEFAULT_PORTFOLIO_LIST_FILTERS, filterText: 's'}); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.pageIndex).toBe(2); + expect(paginator().pageIndex).toBe(2); + }); + + it('pulls the page back inside the rows there are now', async () => { + await render(thirtyStudents()); + paginator().nextPage(); + fixture.detectChanges(); + + fixture.componentRef.setInput('unit', unitStub(thirtyStudents().slice(0, 10))); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.pageIndex).toBe(0); + expect(paginator().pageIndex).toBe(0); + expect(rowNames()).toHaveLength(10); + }); + + it('goes back to the first page when the filters change', async () => { + await render(thirtyStudents()); + paginator().nextPage(); + fixture.detectChanges(); + + fixture.componentRef.setInput('filters', {...DEFAULT_PORTFOLIO_LIST_FILTERS, filterText: '1'}); + fixture.detectChanges(); + + expect(paginator().pageIndex).toBe(0); + }); + + // The stats turn a zero share of tasks not started into the whole bar. + it('works out the tasks not started from the rest of the bar', async () => { + await render(withPortfolios()); + + const progress = component.progressFor( + studentStub({ + id: 9, + name: 'Al Done', + taskStats: [ + {key: 'fail', value: 0}, + {key: 'not_started', value: 100}, + {key: 'working_on_it', value: 0}, + {key: 'ready_for_feedback', value: 0}, + {key: 'complete', value: 100}, + ], + }), + ); + + expect(progress.segments.map((segment) => [segment.key, segment.value])).toEqual([ + ['complete', 100], + ]); + expect(progress.segments[0].color).toBe('var(--ot-status-complete-graphic)'); + expect(progress.label).toBe('Task progress: Complete 100%'); + }); +}); function projectStub(name: string): Project { return { @@ -33,7 +397,7 @@ describe('PortfoliosListComponent empty state', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [PortfoliosListComponent], - imports: [MatTableModule, SkeletonLoaderComponent, EmptyStateComponent], + imports: [MatMenuModule, MatTableModule, EmptyStateComponent], providers: [ { provide: TaskService, @@ -51,6 +415,8 @@ describe('PortfoliosListComponent empty state', () => { }).compileComponents(); }); + // The loading rows are drawn in the template, and the table is hidden with a class so + // the sort survives, so this checks the placeholder text and the class. it('renders the empty state only while the filtered list has no rows', () => { const fixture = TestBed.createComponent(PortfoliosListComponent); const root = fixture.nativeElement as HTMLElement; @@ -62,7 +428,7 @@ describe('PortfoliosListComponent empty state', () => { fixture.detectChanges(); expect(root.querySelector('f-empty-state')).toBeNull(); - expect(root.querySelectorAll('f-skeleton-loader')).toHaveLength(2); + expect(root.textContent).toContain('Loading students'); fixture.componentRef.setInput('loading', false); fixture.detectChanges(); @@ -72,9 +438,9 @@ describe('PortfoliosListComponent empty state', () => { const tableScrollContainer = table.parentElement as HTMLDivElement; expect(emptyState).toBeTruthy(); - expect(root.querySelector('f-skeleton-loader')).toBeNull(); + expect(root.textContent).not.toContain('Loading students'); expect(emptyState.closest('table')).toBeNull(); - expect(tableScrollContainer.hidden).toBe(true); + expect(tableScrollContainer.classList.contains('hidden')).toBe(true); fixture.componentRef.setInput('unit', { students: [projectStub('Cy Cole')], @@ -83,6 +449,6 @@ describe('PortfoliosListComponent empty state', () => { fixture.detectChanges(); expect(root.querySelector('f-empty-state')).toBeNull(); - expect(tableScrollContainer.hidden).toBe(false); + expect(tableScrollContainer.classList.contains('hidden')).toBe(false); }); }); diff --git a/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts index 02e826996e..5c215cb792 100644 --- a/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts +++ b/src/app/units/states/portfolios/directives/portfolios-project-progress/portfolios-project-progress.component.ts @@ -10,10 +10,6 @@ import { import {BehaviorSubject} from 'rxjs'; import {Project} from 'src/app/api/models/project'; import {Unit} from 'src/app/api/models/unit'; -import {ProjectService} from 'src/app/api/services/project.service'; -import {TaskService} from 'src/app/api/services/task.service'; -import {AlertService} from 'src/app/common/services/alert.service'; -import {GradeService} from 'src/app/common/services/grade.service'; @Component({ selector: 'f-portfolios-project-progress', @@ -31,18 +27,7 @@ export class PortfoliosProjectProgressComponent implements OnChanges { @Input() public project$: BehaviorSubject = new BehaviorSubject(null); - public taskStats: {numberOfTasksCompleted: number; numberOfTasksRemaining: number} = { - numberOfTasksCompleted: 0, - numberOfTasksRemaining: 0, - }; - - constructor( - private elementRef: ElementRef, - private gradeService: GradeService, - private projectService: ProjectService, - private alertService: AlertService, - private taskService: TaskService, - ) {} + constructor(private elementRef: ElementRef) {} @HostListener('wheel', ['$event']) public prioritisePageScroll(event: WheelEvent): void { @@ -83,61 +68,6 @@ export class PortfoliosProjectProgressComponent implements OnChanges { } } - public get gradeValues() { - return this.gradeService.gradeValuesFor(this.unit); - } - public get grades() { - return Object.fromEntries( - this.unit.gradeDefinitions.map((definition) => [definition.value, definition.label]), - ); - } - - public gradeWord(grade) { - return this.gradeService.gradeLabel(grade, this.unit); - } - - updateTaskCompletionStats() { - this.taskStats.numberOfTasksCompleted = this.project.tasksByStatus( - this.taskService.completeStatus, - ).length; - this.taskStats.numberOfTasksRemaining = - this.project.activeTasks().length - this.taskStats.numberOfTasksCompleted; - } - - updateSubmittedGrade(newGrade: number): void { - const previousSubmittedGrade = this.project.submittedGrade; - this.project.submittedGrade = newGrade; - - this.projectService.update(this.project).subscribe({ - next: (project) => { - project.refreshBurndownChartData?.(); - this.alertService.success(`Updated project's submitted grade`); - this.updateTaskCompletionStats(); - }, - error: (error) => { - this.project.submittedGrade = previousSubmittedGrade; - this.alertService.error(`Failed to update submitted grade: ${error}`); - }, - }); - } - - updatedTargetGrade(newGrade: number): void { - const previousTargetGrade = this.project.targetGrade; - this.project.targetGrade = newGrade; - - this.projectService.update(this.project).subscribe({ - next: (project) => { - project.refreshBurndownChartData?.(); - this.alertService.success(`Updated project's target grade`); - this.updateTaskCompletionStats(); - }, - error: (error) => { - this.project.targetGrade = previousTargetGrade; - this.alertService.error(`Failed to update target grade: ${error}`); - }, - }); - } - private findInnerScrollContainer(target: HTMLElement, root: Element): HTMLElement | null { let element: HTMLElement | null = target; diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html index 40d45df3cb..2d1c09ad50 100644 --- a/src/app/units/states/portfolios/portfolios.component.html +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -1,65 +1,232 @@ @if (unit) { - - @for (tab of tabs; track tab.routeSegment) { - - } - + @if (currentTab.routeSegment === 'select') { + + } @else { + -
    - @switch (currentTab.routeSegment) { - @case ('select') { - - } - @case ('progress') { +
    + +
    @if (selectedProject) { - +

    + {{ selectedProject.student?.name }} +

    +

    + + {{ selectedProject.student?.studentId || selectedProject.student?.username }} + + + Tutorial {{ selectedProject.shortTutorialDescription() }} + @if (selectedProject.tutorNames()) { + + Tutor {{ selectedProject.tutorNames() }} + } +

    + } @else if (projectLoadFailed) { +

    + Portfolios +

    + } @else { +

    Loading student

    + } - } - @case ('student-notes') { - @if (selectedProject) { -
    -

    Student Notes for {{ selectedProject.student.name }}

    -

    - Use these notes for private staff discussions about the student. Students cannot - view them. -

    +
    - + @if (selectedProject) { +
    +
    +
    Target grade
    +
    + {{ gradeLabel(selectedProject.targetGrade) ?? 'Not set' }} +
    - } +
    +
    Applied for
    +
    + @if (hasSubmittedGrade) { + {{ gradeLabel(selectedProject.submittedGrade) }} + } @else { + Not chosen + } +
    +
    +
    +
    Portfolio
    +
    + @switch (portfolioStatus) { + @case ('ready') { + @if (selectedProject.portfolioSubmissionDate) { + Submitted {{ selectedProject.portfolioSubmissionDate | date: 'd MMM y' }} + } @else { + Submitted + } + } + @case ('building') { + Being created + } + @default { + Not submitted + } + } +
    +
    +
    +
    Grade
    +
    + {{ studentGrade ?? 'Not graded' }} +
    +
    +
    } - @case ('portfolio') { - @if (selectedProject) { - - } +
    + + + + + @if (selectedProject) { + @switch (currentTab.routeSegment) { + @case ('progress') { + + } + @case ('student-notes') { + +
    +
    +

    + Staff notes +

    +

    + Private notes about {{ selectedProject.student?.name }} for the teaching team. + Students cannot see them. +

    +
    + +
    + } + @case ('portfolio') { + + } + @case ('assessment') { + + } } + } @else if (projectLoadFailed) { +
    + +
    + + + Back to all students + +
    +
    + } @else { +
    + Loading student + +
    } - } -
    + + } }
    diff --git a/src/app/units/states/portfolios/portfolios.component.ts b/src/app/units/states/portfolios/portfolios.component.ts index 62e93f6ef5..fb9900d4c7 100644 --- a/src/app/units/states/portfolios/portfolios.component.ts +++ b/src/app/units/states/portfolios/portfolios.component.ts @@ -1,5 +1,13 @@ -import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; -import {MatTabChangeEvent} from '@angular/material/tabs'; +import { + AfterViewChecked, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; import {ActivatedRoute, ParamMap, Router} from '@angular/router'; import {BehaviorSubject, Observable, Subscription, distinctUntilChanged, of} from 'rxjs'; import {Project} from 'src/app/api/models/project'; @@ -7,10 +15,15 @@ import {Unit} from 'src/app/api/models/unit'; import {ProjectService} from 'src/app/api/services/project.service'; import {UserService} from 'src/app/api/services/user.service'; import {AlertService} from 'src/app/common/services/alert.service'; +import {GradeService} from 'src/app/common/services/grade.service'; import { DEFAULT_PORTFOLIO_LIST_FILTERS, PortfolioListFilters, + PortfoliosListComponent, + sameFilters, } from './directives/portfolios-list/portfolios-list.component'; +import {gradeBandFor, isProjectGraded} from './portfolio-grades'; +import {PortfolioMarkingStateService} from './portfolio-marking-state.service'; type PortfolioTabKey = 'select' | 'progress' | 'student-notes' | 'portfolio' | 'assessment'; @@ -20,6 +33,10 @@ interface PortfolioTab { requiresProject: boolean; } +// The query parameters that carry the list filters, so a student route can tell a url +// that sets filters apart from one that just dropped them. +const FILTER_QUERY_PARAMS = ['portfolio', 'tutorial', 'grade', 'search']; + @Component({ selector: 'f-portfolios', templateUrl: './portfolios.component.html', @@ -27,29 +44,46 @@ interface PortfolioTab { changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class PortfoliosComponent implements OnInit, OnDestroy { +export class PortfoliosComponent implements OnInit, AfterViewChecked, OnDestroy { @Input() unit$: Observable; + @ViewChild(PortfoliosListComponent) private portfoliosList?: PortfoliosListComponent; + @ViewChild('studentHeading') private studentHeading?: ElementRef; + public readonly tabs: PortfolioTab[] = [ - {label: 'Select Student', routeSegment: 'select', requiresProject: false}, - {label: 'View Progress', routeSegment: 'progress', requiresProject: true}, - {label: 'View Student Notes', routeSegment: 'student-notes', requiresProject: true}, - {label: 'View Portfolio', routeSegment: 'portfolio', requiresProject: true}, - {label: 'Assess Portfolio', routeSegment: 'assessment', requiresProject: true}, + {label: 'All students', routeSegment: 'select', requiresProject: false}, + {label: 'Progress', routeSegment: 'progress', requiresProject: true}, + {label: 'Staff notes', routeSegment: 'student-notes', requiresProject: true}, + {label: 'Portfolio', routeSegment: 'portfolio', requiresProject: true}, + {label: 'Grade', routeSegment: 'assessment', requiresProject: true}, ]; + public readonly studentTabs: PortfolioTab[] = this.tabs.filter((tab) => tab.requiresProject); public unit: Unit = null; public selectedProject: Project | null = null; public selectedProject$: BehaviorSubject = new BehaviorSubject(null); public loadingStudents = true; + public studentsLoadFailed = false; + public projectLoadFailed = false; public currentTab: PortfolioTab = this.tabs[0]; public portfolioListFilters: PortfolioListFilters = {...DEFAULT_PORTFOLIO_LIST_FILTERS}; + // The student view's links, rebuilt only when what they point at changes, so the + // router links are not handed a new array on every check. + public listLink: unknown[] = []; + public listQueryParams: Record = {}; + public tabLinks: Partial> = {}; + private subscriptions: Subscription[] = []; + private studentsLoad: Subscription | null = null; + private projectLoad: Subscription | null = null; private selectedProjectId: number | null = null; private progressTaskSelectionUrlBaseCache: unknown[] | null = null; private progressTaskSelectionUrlBaseKey: string | null = null; + private tabLinksKey: string | null = null; private routeParamsSubscribed = false; + private routeSeen = false; + private focusTarget: 'list' | 'student' | null = null; constructor( private projectService: ProjectService, @@ -57,15 +91,19 @@ export class PortfoliosComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private alertService: AlertService, private userService: UserService, + private gradeService: GradeService, + private markingState: PortfolioMarkingStateService, ) {} public ngOnInit(): void { + this.listQueryParams = this.portfolioListFilterQueryParams(); this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); this.subscriptions.push( this.unit$.pipe(distinctUntilChanged((a, b) => a?.id === b?.id)).subscribe({ next: (unit) => { const unitChanged = this.unit != null && this.unit.id !== unit.id; this.unit = unit; + this.listLink = ['/units', unit.id, 'students', 'portfolios']; if (unitChanged) { this.resetStudentSelection(); @@ -75,10 +113,12 @@ export class PortfoliosComponent implements OnInit, OnDestroy { this.userService.currentUser.systemRole === 'Admin' || this.userService.currentUser.systemRole === 'Convenor' ) { - this.unit.loadD2lMapping().subscribe(); + // A unit with no D2L link, or one this convenor cannot edit, answers with an + // error. That only means there is nothing to transfer to, so the transfer + // button stays hidden instead of the error landing in the console. + this.subscriptions.push(this.unit.loadD2lMapping().subscribe({error: () => {}})); } - this.loadingStudents = true; this.loadStudents(); this.subscribeToRouteParams(); }, @@ -90,13 +130,34 @@ export class PortfoliosComponent implements OnInit, OnDestroy { ); } - public ngOnDestroy(): void { - this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + // The list, a student and a task are separate routes, so moving between them builds + // this page again and the control the tutor used goes with the old one. Focus would be + // left on the page itself, so it goes to the heading of the view that appeared. Focus + // that is still on something is left alone, and the page is not scrolled to it. + public ngAfterViewChecked(): void { + if (!this.focusTarget) { + return; + } + + const active = document.activeElement; + if (active && active !== document.body) { + this.focusTarget = null; + return; + } + + if (this.focusTarget === 'list' && this.portfoliosList) { + this.focusTarget = null; + this.portfoliosList.focusHeading(); + } else if (this.focusTarget === 'student' && this.studentHeading) { + this.focusTarget = null; + this.studentHeading.nativeElement.focus({preventScroll: true}); + } } - public get currentIndex(): number { - const index = this.tabs.findIndex((tab) => tab.routeSegment === this.currentTab.routeSegment); - return index >= 0 ? index : 0; + public ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + this.studentsLoad?.unsubscribe(); + this.projectLoad?.unsubscribe(); } public get progressTaskSelectionUrlBase(): unknown[] | null { @@ -123,49 +184,91 @@ export class PortfoliosComponent implements OnInit, OnDestroy { return this.progressTaskSelectionUrlBaseCache; } + // Read the same way as the portfolio tab, so the header never says a portfolio is + // there when the tab says it is not. + public get portfolioStatus(): 'ready' | 'building' | 'none' { + const project = this.selectedProject; + if (project?.portfolioAvailable) { + return 'ready'; + } + + return project?.compilePortfolio ? 'building' : 'none'; + } + + public get hasSubmittedGrade(): boolean { + const grade = this.selectedProject?.submittedGrade; + return grade !== null && grade !== undefined; + } + + public get studentGrade(): string | null { + if (!isProjectGraded(this.selectedProject)) { + return null; + } + + const grade = this.selectedProject.grade; + const band = gradeBandFor(grade); + const bandLabel = band ? (this.gradeLabel(band.gradeValue) ?? band.name) : null; + return bandLabel ? `${grade} · ${bandLabel}` : `${grade}`; + } + + public gradeLabel(grade: number | null | undefined): string | undefined { + if (grade === null || grade === undefined) { + return undefined; + } + + return this.gradeService.gradeLabel(grade, this.unit); + } + public studentSelected(project: Project): void { this.navigateToProject(project.id, 'progress'); } + public showProgress(): void { + if (this.selectedProject) { + this.navigateToProject(this.selectedProject.id, 'progress'); + } + } + + // Asked for after a failed load or a grades upload, so it goes to the server: the + // cached answer is the one the tutor already has. + public reloadStudents(): void { + this.loadStudents(true); + } + + public retryProject(): void { + if (this.selectedProjectId) { + this.loadProject(this.selectedProjectId); + } + } + public portfolioListFiltersChange(filters: PortfolioListFilters): void { this.portfolioListFilters = {...filters}; + this.listQueryParams = this.portfolioListFilterQueryParams(); + this.markingState.rememberFilters(this.unit.id, this.portfolioListFilters); if (this.currentTab.routeSegment === 'select') { this.router.navigate([], { relativeTo: this.route, - queryParams: this.portfolioListFilterQueryParams(), + queryParams: this.listQueryParams, replaceUrl: true, }); } } - public onTabChange(event: MatTabChangeEvent): void { - const nextTab = this.tabs[event.index] ?? this.tabs[0]; - this.currentTab = nextTab; - - if (nextTab.routeSegment === 'select' || !this.selectedProject) { - this.router.navigate(['/units', this.unit.id, 'students', 'portfolios'], { - queryParams: this.portfolioListFilterQueryParams(), - replaceUrl: true, - }); - return; - } - - this.navigateToProject(this.selectedProject.id, nextTab.routeSegment); - } - // The selected student belongs to the unit we just left, so drop it and go back to // the student list rather than showing someone who is not enrolled in the new unit. private resetStudentSelection(): void { const hadSelection = this.selectedProjectId != null; + this.projectLoad?.unsubscribe(); this.selectedProjectId = null; this.selectedProject = null; this.selectedProject$.next(null); + this.projectLoadFailed = false; this.currentTab = this.tabs[0]; if (hadSelection) { - this.router.navigate(['/units', this.unit.id, 'students', 'portfolios'], { + this.router.navigate(this.listLink, { queryParams: this.portfolioListFilterQueryParams(), replaceUrl: true, }); @@ -190,29 +293,54 @@ export class PortfoliosComponent implements OnInit, OnDestroy { ); } - private loadStudents(): void { - this.projectService.loadStudents(this.unit, false).subscribe({ + // A unit change starts a new load, so the one for the unit we left is dropped + // instead of finishing late and marking the new list as loaded. + private loadStudents(refetch = false): void { + this.studentsLoad?.unsubscribe(); + this.loadingStudents = true; + this.studentsLoadFailed = false; + + this.studentsLoad = this.projectService.loadStudents(this.unit, false, refetch).subscribe({ next: () => { this.loadingStudents = false; }, error: (error) => { - this.alertService.error(`Failed to load unit: ${error}`, 6000); - this.router.navigateByUrl('/home'); + this.loadingStudents = false; + this.studentsLoadFailed = true; + this.alertService.error(`Could not load the students: ${error}`, 6000); }, }); } private updatePortfolioListFiltersFromQueryParams(params: ParamMap): void { - const filters: PortfolioListFilters = { + // A task opened in the progress tab is its own route, and its url carries none of + // the list's filters, so the page is built again without them. That is not the + // tutor clearing the filters, so a student route with none in its url takes the + // ones they last set. The list's own url always says what the filters are. + const onStudentRoute = !!this.route.snapshot?.paramMap?.get('projectId'); + const filters = + onStudentRoute && !FILTER_QUERY_PARAMS.some((key) => params.has(key)) + ? (this.markingState.filtersFor(this.unit.id) ?? this.portfolioListFilters) + : this.filtersFromQueryParams(params); + + // Compared without the spaces at either end, which the url drops. Otherwise the url + // coming back would replace what the tutor is typing, and eat the space between a + // first and last name as soon as it was typed. + if (!sameFilters(filters, this.portfolioListFilters)) { + this.portfolioListFilters = filters; + this.listQueryParams = this.portfolioListFilterQueryParams(); + } + + this.markingState.rememberFilters(this.unit.id, this.portfolioListFilters); + } + + private filtersFromQueryParams(params: ParamMap): PortfolioListFilters { + return { portfolioFilter: params.get('portfolio') === 'all' ? 'all' : 'submitted_only', tutorialFilter: params.get('tutorial') === 'mine' ? 'mine' : 'all', gradeFilter: this.gradeFilterFromQueryParam(params.get('grade')), filterText: params.get('search') ?? '', }; - - if (!this.portfolioListFiltersEqual(filters, this.portfolioListFilters)) { - this.portfolioListFilters = filters; - } } private gradeFilterFromQueryParam(grade: string | null): number | null { @@ -239,15 +367,6 @@ export class PortfoliosComponent implements OnInit, OnDestroy { }; } - private portfolioListFiltersEqual(a: PortfolioListFilters, b: PortfolioListFilters): boolean { - return ( - a.portfolioFilter === b.portfolioFilter && - a.tutorialFilter === b.tutorialFilter && - a.gradeFilter === b.gradeFilter && - a.filterText === b.filterText - ); - } - private updateCurrentTabFromState( tabParam?: string | null, projectIdParam?: string | null, @@ -255,22 +374,54 @@ export class PortfoliosComponent implements OnInit, OnDestroy { const projectId = projectIdParam ? Number(projectIdParam) : null; const requestedTab = this.tabFromRoute(tabParam, !!projectId); + // Only an arrival from somewhere else in the app moves focus, never the first load. + if (!this.routeSeen && this.arrivedWithinApp()) { + this.focusTarget = requestedTab.routeSegment === 'select' ? 'list' : 'student'; + } + this.routeSeen = true; + this.currentTab = requestedTab; if (!projectId) { + this.projectLoad?.unsubscribe(); this.selectedProjectId = null; this.selectedProject = null; this.selectedProject$.next(null); + this.projectLoadFailed = false; return; } + this.updateTabLinks(projectId); + if (this.selectedProject?.id === projectId) { return; } + // Already on its way, so a tab change while it loads does not ask for it again. + if (this.selectedProjectId === projectId && !this.projectLoadFailed) { + return; + } + + // A different student: drop the one on screen now, so their name and grade are + // never shown under the next student's url while it loads. + if (this.selectedProject) { + this.selectedProject = null; + this.selectedProject$.next(null); + } + this.loadProject(projectId); } + // The navigation that shows this page is still running when the router builds the page + // itself, and has already finished when the page waits for its unit to load first, so + // either one is read. It follows an earlier navigation only when the tutor came from + // somewhere else in the app; the first load of the app has none before it. + private arrivedWithinApp(): boolean { + const navigation = + this.router.currentNavigation?.() ?? this.router.lastSuccessfulNavigation?.() ?? null; + return !!navigation?.previousNavigation; + } + private tabFromRoute(tabParam: string | null, hasProject: boolean): PortfolioTab { if (!hasProject) { return this.tabs[0]; @@ -283,10 +434,27 @@ export class PortfoliosComponent implements OnInit, OnDestroy { return routeTab ?? this.tabs.find((tab) => tab.routeSegment === 'progress') ?? this.tabs[0]; } + private updateTabLinks(projectId: number): void { + const key = `${this.unit?.id}/${projectId}`; + if (key === this.tabLinksKey) { + return; + } + + this.tabLinksKey = key; + this.tabLinks = Object.fromEntries( + this.studentTabs.map((tab) => [ + tab.routeSegment, + [...this.listLink, projectId, tab.routeSegment], + ]), + ); + } + private loadProject(projectId: number): void { + this.projectLoad?.unsubscribe(); this.selectedProjectId = projectId; + this.projectLoadFailed = false; - this.projectService.loadProject(projectId, this.unit).subscribe({ + this.projectLoad = this.projectService.loadProject(projectId, this.unit).subscribe({ next: (project) => { if (this.selectedProjectId !== project.id) { return; @@ -296,17 +464,20 @@ export class PortfoliosComponent implements OnInit, OnDestroy { this.selectedProject$.next(project); }, error: (error) => { - this.selectedProjectId = null; + if (this.selectedProjectId !== projectId) { + return; + } + this.selectedProject = null; this.selectedProject$.next(null); - this.alertService.error(`Failed to load project: ${error}`, 6000); - console.error(error); + this.projectLoadFailed = true; + this.alertService.error(`Could not load this student: ${error}`, 6000); }, }); } private navigateToProject(projectId: number, tab: PortfolioTabKey): void { - this.router.navigate(['/units', this.unit.id, 'students', 'portfolios', projectId, tab], { + this.router.navigate([...this.listLink, projectId, tab], { queryParams: this.portfolioListFilterQueryParams(), replaceUrl: true, }); From 4bb35c39cdf7a5307cd83a1086e80bda7659bef9 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Mon, 28 Sep 2026 00:28:58 +1000 Subject: [PATCH 3/3] feat(submissions): bring in org web PR 281 for the submissions files Brings ontrack-features-t2-2026/doubtfire-web#281 to the files this PR already carries, so every file stays in exactly one PR. It carries the upload dialog that confirms the submission in place, and the profile page cards and save bar. --- .../api/models/task-comment/task-comment.ts | 6 + .../edit-profile-form/_profile-form.scss | 194 +++++++ .../edit-profile-form.component.html | 538 +++++++++-------- .../edit-profile-form.component.scss | 309 ++++++---- .../edit-profile-form.component.spec.ts | 389 ++++++++++++- .../edit-profile-form.component.ts | 197 ++++++- .../file-uploader.accessibility.spec.ts | 2 +- .../file-uploader.component.html | 250 +++++--- .../file-uploader.component.spec.ts | 3 +- .../file-uploader/file-uploader.component.ts | 174 +++++- .../submission-files-modal.component.html | 2 +- .../task-submission-card.component.html | 4 +- ...earning-summary-report-step.component.html | 1 + .../task-upload-requirements.component.html | 119 ++-- .../task-upload-requirements.component.scss | 129 +++-- ...task-upload-requirements.component.spec.ts | 39 +- .../task-upload-requirements.component.ts | 19 + .../upload-category.ts | 17 +- .../upload-submission-modal.component.html | 547 +++++++++++------- .../upload-submission-modal.component.spec.ts | 275 ++++++++- .../upload-submission-modal.component.ts | 320 +++++++++- .../task-comment-composer.component.ts | 294 ++++++++-- ...ask-comment-composer.touch-targets.spec.ts | 67 +++ 23 files changed, 3049 insertions(+), 846 deletions(-) create mode 100644 src/app/common/edit-profile-form/_profile-form.scss diff --git a/src/app/api/models/task-comment/task-comment.ts b/src/app/api/models/task-comment/task-comment.ts index 648a7eb0c6..635f92d36f 100644 --- a/src/app/api/models/task-comment/task-comment.ts +++ b/src/app/api/models/task-comment/task-comment.ts @@ -22,6 +22,12 @@ export class TaskComment extends Entity { recipientReadTime: string; commentType: string = 'text'; isNew: boolean; + /** + * True when OnTrack wrote this comment rather than a person. It is stored + * against the tutor for the task, because a comment needs an author, so + * without this it reads as something that tutor said. + */ + automated = false; replyToId: number; attachmentFileName?: string; attachmentMimeType?: string; diff --git a/src/app/common/edit-profile-form/_profile-form.scss b/src/app/common/edit-profile-form/_profile-form.scss new file mode 100644 index 0000000000..b7ba3d3450 --- /dev/null +++ b/src/app/common/edit-profile-form/_profile-form.scss @@ -0,0 +1,194 @@ +// Shared look for the profile page and the settings cards it hosts (additional +// notification email, notification settings). Cards follow the Unit Hub card, fields +// run at 48px like the other redesigned forms, and every colour is an --ot-* token. +@use '../../unit-hub/hub-card' as hub; + +// Include once at the top level of a component stylesheet. +@mixin theme { + @include hub.card-theme; + + :host { + // fields: 48px (Material density -2), as in the groups and units toolbars + --mat-form-field-container-height: 48px; + --mat-form-field-container-vertical-padding: 12px; + --mat-form-field-subscript-text-size: 0.78rem; + --mat-form-field-subscript-text-line-height: 1.4; + --mat-form-field-error-text-color: var(--ot-color-error); + --mat-form-field-outlined-input-text-color: var(--ot-color-text); + --mat-form-field-outlined-label-text-color: var(--ot-color-text-muted); + --mat-form-field-outlined-hover-label-text-color: var(--ot-color-text-muted); + // the focused outline is owned by the global rule in styles.scss; this restated it as + // primary, which is 2.40:1 on a dark card where the focus token the global rule uses is 3.32:1 + --mat-form-field-outlined-focus-label-text-color: var(--ot-color-link); + --mat-form-field-outlined-hover-outline-color: var(--ot-color-text-muted); + --mat-form-field-outlined-container-shape: var(--ot-radius-sm); + + // buttons: the app's filled or outlined style, at a 44px touch height + --mat-button-filled-container-height: 44px; + --mat-button-outlined-container-height: 44px; + } + + // Selection controls take the primary token. Set on the controls themselves so a + // colour class from the theme cannot re-point them. + :host ::ng-deep .mat-mdc-checkbox.mat-mdc-checkbox { + --mat-checkbox-selected-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-hover-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-focus-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-pressed-icon-color: var(--ot-color-primary); + --mat-checkbox-selected-checkmark-color: var(--ot-color-on-primary); + --mat-checkbox-selected-hover-state-layer-color: var(--ot-color-primary); + --mat-checkbox-selected-focus-state-layer-color: var(--ot-color-primary); + --mat-checkbox-selected-pressed-state-layer-color: var(--ot-color-primary); + --mat-checkbox-label-text-color: var(--ot-color-text); + } + + :host ::ng-deep .mat-mdc-slide-toggle.mat-mdc-slide-toggle { + --mat-slide-toggle-selected-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-hover-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-focus-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-pressed-handle-color: var(--ot-color-primary); + --mat-slide-toggle-selected-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-hover-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-focus-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-pressed-track-color: color-mix( + in srgb, + var(--ot-color-primary) 45%, + var(--ot-color-surface) + ); + --mat-slide-toggle-selected-icon-color: var(--ot-color-on-primary); + --mat-slide-toggle-label-text-color: var(--ot-color-text); + } + + :host ::ng-deep .mat-mdc-form-field-hint { + color: var(--ot-color-text-muted); + } + + :host ::ng-deep .mat-mdc-form-field-error { + color: var(--ot-color-error); + } +} + +// One card: surface token, soft token edge, 20px padding (16px on phones). +@mixin card { + @include hub.card(page); + display: flex; + flex-direction: column; + gap: 16px; + box-sizing: border-box; + width: 100%; + min-width: 0; + margin: 0; + padding: 20px; + color: var(--ot-color-text); + overflow-wrap: anywhere; + + @media (max-width: 599px) { + padding: 16px; + } +} + +// Card heading: a small link-coloured icon, the title, and a muted description. +@mixin card-heading { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + + h2 { + @include hub.icon-heading; + color: var(--ot-color-text); + } + + p { + margin: 0; + max-width: 42rem; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + line-height: 1.5; + } +} + +// Two field columns on wide screens, one on phones. +@mixin field-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + min-width: 0; + + &:empty { + display: none; + } + + mat-form-field { + width: 100%; + min-width: 0; + } + + @media (max-width: 599px) { + grid-template-columns: minmax(0, 1fr); + } +} + +// A setting row: label and description on the left, the control on the right. The +// control is a checkbox or toggle with labelPosition="before", stretched to the row. +@mixin setting-row { + display: grid; + gap: 2px; + min-width: 0; + + > :is(mat-checkbox, mat-slide-toggle) { + display: block; + width: 100%; + } + + // the checkbox box carries 11px of touch padding; let it overhang so its edge lines + // up with a toggle's + > mat-checkbox { + width: calc(100% + 11px); + margin-inline-end: -11px; + } + + > :is(mat-checkbox, mat-slide-toggle) ::ng-deep .mdc-form-field { + display: flex; + justify-content: space-between; + gap: 12px; + width: 100%; + } + + > :is(mat-checkbox, mat-slide-toggle) ::ng-deep label { + // Material pushes a "before" label against the control with an auto margin; + // let it fill the row instead so the control sits at the far edge + flex: 1 1 auto; + margin: 0; + padding-inline: 0; + text-align: start; + color: var(--ot-color-text); + font-size: 0.95rem; + font-weight: 600; + line-height: 1.4; + } + + > small { + display: block; + // the control's 40px touch target sets the row height; pull the text up to the label + margin-top: -6px; + // clear the control on the right + padding-inline-end: 56px; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.45; + } +} diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.html b/src/app/common/edit-profile-form/edit-profile-form.component.html index ab741d46bb..0b0914c1df 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.html +++ b/src/app/common/edit-profile-form/edit-profile-form.component.html @@ -1,230 +1,256 @@
    -
    -
    - -
    -
    - - +
    +
    + + + -
    -

    {{ user?.firstName }}

    - Set up your {{ externalName.value }} profile -
    -
    +
    +

    {{ displayName }}

    + @if (mode === 'create') { +

    Set up your {{ externalName.value }} profile

    + } + @if (user?.username || user?.email) { +

    + @if (user?.username) { + {{ user.username }} + } + @if (user?.email) { + {{ user.email }} + } +

    + }
    + - @if (newUser) { -
    - - Username - - -
    - } @else { - + +
    +
    +

    Your name

    - - Custom Pronouns - - +
    + @if (canEditName) { + + First name + + @if (firstModel.hasError('required')) { + First name is required + } + - @if (user.systemRole === 'Student') { - @if (canEditStudentId) { - - Student ID + + Last name - @if (studentIdModel.hasError('required')) { - Student ID is required + @if (lastModel.hasError('required')) { + Last name is required } } - } - @if (canEditEmail) { - - Email + + Preferred name - @if (emailModel.hasError('required')) { - Email is required - } - @if (emailModel.hasError('email')) { - Enter a valid email address - } + Shown to your tutors instead of your first name. - } - @if (canSeeSystemRole) { - - System Role - - Administrator - Convenor - Tutor - Student - Auditor - + + Custom pronouns + - } - - @if (mode === 'edit' && managingOwnProfile) { - - } +
    +
    - + @if (canEditEmail) { +
    +
    +

    Contact

    +
    -
    -

    Peer progress

    - - Display anonymous peer progress statistics - - - This controls whether you see the privacy-protected widget below your submission details. - It does not opt your anonymous task status out of authorised cohort aggregation. - +
    + + Email + + @if (emailModel.hasError('required')) { + Email is required + } + @if (emailModel.hasError('email')) { + Enter a valid email address + } + +
    + } - + @if (mode === 'edit' && managingOwnProfile) { + + } - -
    + + + + + +
    +
    +

    + Push notifications +

    +
    +
    -
    - +
    + +
    +
    +

    + Peer progress +

    +
    +
    + + Display anonymous peer progress statistics + + + This controls whether you see the privacy-protected widget below your submission details. + It does not opt your anonymous task status out of authorised cohort aggregation. + +
    +
    + +
    +
    + +
    +
    + Send anonymous research statistics -
    +
    @if (tiiEnabled) { -
    +
    Accepted TurnItIn EULA -
    +
    } -
    - -
    - @if (mode === 'create') { - - } - @if (mode === 'edit') { - - } -
    + +
    + + + @if (showActions(form.dirty)) { +
    @if (form.invalid && form.dirty) {

    Fix the highlighted fields before saving.

    + } @else if (mode === 'edit' && form.dirty) { +

    You have unsaved changes.

    } -

    {{ saveMessage }}

    +

    + {{ saveMessage }} +

    {{ saveError }}

    + + + @if (mode === 'create') { + + } + @if (mode === 'edit' && (form.dirty || saving)) { + + + }
    -
    + } diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.scss b/src/app/common/edit-profile-form/edit-profile-form.component.scss index ce16451809..5f8570f36e 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.scss +++ b/src/app/common/edit-profile-form/edit-profile-form.component.scss @@ -1,81 +1,120 @@ +// Profile page, first-login form and the admin Users dialog. Cards stack with one +// 16px rhythm and no rules between sections. Colours come only from --ot-* tokens. +@use './profile-form' as profile; + +@include profile.theme; + :host { display: block; height: auto; min-width: 0; + // the save bar's height, so a control focused from the keyboard scrolls clear of it + --profile-actions-height: 68px; } -form, -.main-container, -.main { +.profile-form { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; max-width: 100%; + min-width: 0; + padding-inline: clamp(16px, 4vw, 28px); + color: var(--ot-color-text); } -.main { - gap: 4px; - padding: 16px clamp(16px, 4vw, 28px) calc(3rem + env(safe-area-inset-bottom)); +.profile-form--modal { + padding: 24px 24px 0; + + @media (max-width: 599px) { + padding: 16px 16px 0; + } } -.main, -mat-form-field { +.profile-stack { + display: flex; + flex-direction: column; + gap: 16px; min-width: 0; -} + padding-block: 16px; -h1 { - // THM-M01: was hardcoded black. - color: var(--ot-color-text, #000); - margin-top: 0; + :is(input, textarea, button, a, mat-select, mat-checkbox, mat-slide-toggle) { + scroll-margin-block: 16px calc(var(--profile-actions-height) + 16px); + } } -a { - :hover { - text-decoration: none; - opacity: 0.6; - } +.profile-card { + @include profile.card; } -p { - color: var(--ot-color-text, #000); +.profile-card__heading { + @include profile.card-heading; } -.form-field { - margin: 5px; +.profile-fields { + @include profile.field-grid; } -section { - padding: 12px; +.profile-fields__wide { + grid-column: 1 / -1; } -.account-information { - background: var(--ot-color-surface); - color: var(--ot-color-text); - border: 1px solid var(--ot-color-border); - border-radius: 12px; - display: grid; - gap: 12px; - margin-bottom: 16px; - padding: 16px; - width: 100%; +// ---- Header ---- +.profile-header { + flex-direction: row; + align-items: center; + gap: 16px; +} - h2, - p, - dl, - dd { - margin: 0; +.profile-header__avatar { + display: block; + flex-shrink: 0; + width: 64px; + height: 64px; + border-radius: var(--ot-radius-circle); + transition: opacity 160ms ease; + + &:hover { + opacity: 0.8; } +} + +.profile-header__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; - h2 { - font-size: 1.125rem; + h1 { + margin: 0; + color: var(--ot-color-text); + font-size: 1.5rem; + font-weight: 650; + line-height: 1.3; + letter-spacing: -0.01em; } p { + margin: 0; color: var(--ot-color-text-muted); - margin-top: 4px; + font-size: 0.9rem; + line-height: 1.45; } +} + +.profile-header__meta { + display: flex; + flex-wrap: wrap; + gap: 0 12px; +} +// ---- Account facts ---- +.account-information { dl { display: grid; - gap: 10px; - grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px 24px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + margin: 0; } dl > div { @@ -83,105 +122,134 @@ section { } dt { + margin: 0 0 4px; color: var(--ot-color-text-muted); - font-size: 0.8125rem; + font-size: 0.72rem; font-weight: 600; + letter-spacing: 0.04em; + line-height: 1.4; + text-transform: uppercase; } dd { + margin: 0; + color: var(--ot-color-text); + font-size: 0.95rem; overflow-wrap: anywhere; } - - &__managed { - border-top: 1px solid var(--ot-color-divider); - padding-top: 10px; - } } -/* checkbox */ -::ng-deep .mdc-form-field label { - font-weight: normal; +.account-information__managed { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.875rem; } -.mat-icon { - width: 50px; - height: 50px; - font-size: 50px; +// ---- Setting rows ---- +.profile-setting { + @include profile.setting-row; } -.push-opt-in { +.push-opt-in__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + min-width: 0; + button { max-width: 100%; height: auto; - min-height: 36px; + min-height: 44px; white-space: normal; overflow-wrap: anywhere; } - - // The rule above sizes the profile avatar icon at 50px. Left alone it would - // apply to the icon inside this button too and blow the button apart. - .mat-icon { - width: 20px; - height: 20px; - font-size: 20px; - margin-right: 6px; - } - - &__hint { - display: block; - margin-top: 6px; - opacity: 0.7; - } } -.peer-progress-preference { - // Divider token equals the raised surface in dark (#353c47), so this separator - // vanishes on the dialog. Use the border token in dark where it actually reads; - // keep the softer divider in light. - border-block: 1px solid var(--ot-color-divider); - :root[data-ot-theme='dark'] & { - border-block-color: var(--ot-color-border); - } - gap: 6px; - margin-block: 8px; - padding-block: 16px; - - h3 { - margin: 0; - } +.push-opt-in__hint { + display: block; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.45; +} - small { - color: var(--ot-color-text-muted); - max-width: 42rem; - } +.push-opt-in__instructions { + margin: 0; + padding-inline-start: 1.25rem; + color: var(--ot-color-text-muted); + font-size: 0.84rem; + line-height: 1.5; } +// ---- Save bar ---- +// Only on screen when there is something to act on, so it arrives as an answer to +// an edit rather than sitting there as chrome. Sticky at the foot of the content +// column, in flow after the last card, so at the end of the page it covers nothing. .profile-actions { - background: var(--ot-color-surface-raised); - border-top: 1px solid var(--ot-color-divider); - bottom: 0; - padding: 12px clamp(16px, 4vw, 28px) max(12px, env(safe-area-inset-bottom)); position: sticky; - width: 100%; + bottom: 0; z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px 12px; + box-sizing: border-box; + min-height: var(--profile-actions-height); + margin-block-start: 4px; + padding: 12px 16px max(12px, env(safe-area-inset-bottom, 0px)); + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md); + // Raised, not a full-bleed band: it lines up with the cards above it rather + // than reading as a separate strip pinned to the window. + background: var(--ot-color-surface-raised); + box-shadow: var(--ot-elevation-1, 0 1px 2px rgb(0 0 0 / 12%)); + animation: profile-actions-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; - button { - min-height: 44px; + @media (max-width: 599px) { + padding-inline: 16px; + + > button { + flex: 1 1 100%; + } + } +} + +.profile-actions__saved { + display: flex; + align-items: center; + gap: 6px; + min-height: 44px; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; } } .profile-save-status { - min-height: 24px; - width: 100%; + flex: 1 1 12rem; + min-width: 0; + font-size: 0.875rem; + line-height: 1.45; p { - margin: 4px 0 0; + margin: 0; } &__success { color: var(--ot-color-success); } + &__pending { + color: var(--ot-color-text); + font-weight: 600; + } + &__validation, &__error { color: var(--ot-color-error); @@ -189,33 +257,30 @@ section { } } -.profile-name-fields { - display: grid; - gap: 16px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - width: 100%; +// Nothing left to do, just the confirmation on its way out. +.profile-actions--settled { + justify-content: flex-start; - mat-form-field { - min-width: 0; - width: 100%; + .profile-save-status__success { + color: var(--ot-color-success); + font-weight: 600; } } -@media (max-width: 600px) { - .profile-name-fields { - gap: 0; - grid-template-columns: 1fr; +@keyframes profile-actions-in { + from { + opacity: 0; + transform: translateY(8px); } - .main { - padding-inline: 16px; - } - - .account-information dl { - grid-template-columns: 1fr; + to { + opacity: 1; + transform: none; } +} +@media (prefers-reduced-motion: reduce) { .profile-actions { - padding-inline: 16px; + animation: none; } } diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts index cfd2dce546..068fb96f7c 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.spec.ts @@ -1,10 +1,17 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; import {Directive, NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatCheckboxHarness} from '@angular/material/checkbox/testing'; import {MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {MatInputModule} from '@angular/material/input'; +import {MatSelectModule} from '@angular/material/select'; +import {MatSlideToggleModule} from '@angular/material/slide-toggle'; import {MatSnackBar} from '@angular/material/snack-bar'; import {Router} from '@angular/router'; -import {of} from 'rxjs'; +import {Subject, of} from 'rxjs'; import {User} from 'src/app/api/models/user/user'; import {AuthenticationService} from 'src/app/api/services/authentication.service'; import {PushNotificationService} from 'src/app/api/services/push-notification.service'; @@ -228,10 +235,56 @@ describe('EditProfileFormComponent', () => { expect(component.canEditStudentId).toBe(true); }); + it('treats an SSO name as read-only and a local one as editable', () => { + dialogData.user = makeUser({institutionalIdentityManaged: true, emailEditable: false}); + createComponent(); + expect(component.canEditName).toBe(false); + + dialogData.user = makeUser({institutionalIdentityManaged: false, emailEditable: true}); + createComponent(); + expect(component.canEditName).toBe(true); + }); + + it('previews the institution-managed page from a dev-only query parameter', () => { + const search = window.location.search; + window.history.replaceState({}, '', `${window.location.pathname}?identityManaged=1`); + try { + dialogData.user = makeUser({institutionalIdentityManaged: false, emailEditable: true}); + createComponent(); + expect(component.canEditName).toBe(false); + expect(component.canEditEmail).toBe(false); + expect(component.identityManagedView).toBe(true); + } finally { + window.history.replaceState({}, '', `${window.location.pathname}${search}`); + } + }); + + it('leaves institution-managed name and email out of the update', () => { + const user = makeUser({ + institutionalIdentityManaged: true, + emailEditable: false, + nickname: 'Preferred', + }); + dialogData.user = user; + userServiceStub.update.mockReturnValue(of(user)); + + createComponent(); + component.submit(); + + expect(userServiceStub.update).toHaveBeenCalledWith(user, { + entity: user, + ignoreKeys: ['firstName', 'lastName', 'email'], + }); + }); + it('reports explicit saving and success state while preserving genuine settings', () => { + // Database auth, so nothing on this account is institution-managed and the + // whole entity goes up. The SSO case is covered separately below. const updated = makeUser({ nickname: 'Preferred', receiveFeedbackNotifications: false, + institutionalIdentityManaged: false, + emailEditable: true, }); dialogData.user = updated; userServiceStub.update.mockReturnValue(of(updated)); @@ -245,6 +298,39 @@ describe('EditProfileFormComponent', () => { expect(component.user.nickname).toBe('Preferred'); expect(component.user.receiveFeedbackNotifications).toBe(false); }); + + it('lets a save land after the view goes, but arms no timer behind it', () => { + vi.useFakeTimers(); + + // A save that has left but has not been answered yet. + const response: Subject = new Subject(); + userServiceStub.update.mockReturnValue(response); + + createComponent(); + component.submit(); + expect(component.saving).toBe(true); + + // The user leaves the profile page before the server replies. ngOnDestroy + // has already had its one chance to clear the confirmation timer, so a + // next handler running after this point would arm one nothing can clear. + // The request is deliberately not cancelled: the save itself must survive. + fixture.destroy(); + const pendingAfterDestroy = vi.getTimerCount(); + expect(response.observed).toBe(true); + + response.next(makeUser({nickname: 'Preferred'})); + response.complete(); + + expect(component.justSaved).toBe(false); + expect(component.saveMessage).toBe(''); + expect(vi.getTimerCount()).toBe(pendingAfterDestroy); + + // And nothing turns up later either. + vi.advanceTimersByTime(10_000); + expect(component.justSaved).toBe(false); + + vi.useRealTimers(); + }); }); // A11Y-FORM06: WCAG 1.3.5 Identify Input Purpose (AA). @@ -407,3 +493,304 @@ describe('EditProfileFormComponent notifications page link', () => { expect(notificationsLink()).toBeNull(); }); }); + +// The save bar only exists while there is something to act on. +@Directive({selector: 'form', exportAs: 'ngForm', standalone: false}) +class StubNgFormSaveBar { + public invalid = false; + public dirty = false; + public pristine = true; +} + +describe('EditProfileFormComponent save bar and labels', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + const currentUser = makeUser({firstName: 'Ada', lastName: 'Lovelace', username: 'ada'}); + + await TestBed.configureTestingModule({ + declarations: [EditProfileFormComponent, StubNgFormSaveBar, StubNotificationNgModel], + providers: [ + {provide: AlertService, useValue: {error: vi.fn()}}, + { + provide: DoubtfireConstants, + useValue: {ExternalName: {value: 'OnTrack'}, IsTiiEnabled: {value: false}}, + }, + {provide: UserService, useValue: {currentUser}}, + {provide: Router, useValue: {}}, + {provide: AuthenticationService, useValue: {}}, + {provide: MAT_DIALOG_DATA, useValue: null}, + {provide: MatSnackBar, useValue: {}}, + {provide: PushNotificationService, useValue: pushServiceStub}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(EditProfileFormComponent); + fixture.componentRef.setInput('mode', 'edit'); + fixture.detectChanges(); + }); + + const form = (): StubNgFormSaveBar => + fixture.debugElement.children[0].injector.get(StubNgFormSaveBar); + const text = (): string => fixture.nativeElement.textContent; + + it('shows no bar at all when there is nothing to act on', () => { + expect(fixture.nativeElement.querySelector('.profile-actions')).toBeNull(); + expect(fixture.nativeElement.querySelector('button[type="submit"]')).toBeNull(); + expect(text()).not.toContain('All changes saved'); + }); + + it('brings the bar in with both actions once the form has changes', () => { + form().dirty = true; + form().pristine = false; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.profile-actions')).not.toBeNull(); + expect(text()).toContain('You have unsaved changes'); + + const submit: HTMLButtonElement = fixture.nativeElement.querySelector('button[type="submit"]'); + expect(submit.textContent).toContain('Save changes'); + expect(text()).toContain('Discard'); + }); + + it('keeps the bar while a save is in flight and while the result is still showing', () => { + const component = fixture.componentInstance; + + component.saving = true; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.profile-actions')).not.toBeNull(); + + component.saving = false; + component.justSaved = true; + component.saveMessage = 'Profile saved.'; + fixture.detectChanges(); + expect(text()).toContain('Profile saved.'); + + // Once the confirmation has had its moment the bar goes with it. + component.justSaved = false; + component.saveMessage = ''; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.profile-actions')).toBeNull(); + }); + + it('puts every edited field back when the changes are discarded', () => { + const component = fixture.componentInstance; + const original = component.user.firstName; + + component.user.firstName = 'Edited'; + component.user.nickname = 'Edited too'; + component.discard(); + + expect(component.user.firstName).toBe(original); + expect(component.user.nickname).not.toBe('Edited too'); + }); + + it('labels the name fields in sentence case', () => { + const labels = Array.from( + fixture.nativeElement.querySelectorAll( + '.profile-name-fields mat-label', + ) as NodeListOf, + ).map((label) => label.textContent.trim()); + + expect(labels).toEqual(['First name', 'Last name', 'Preferred name', 'Custom pronouns']); + expect(text()).not.toContain('Second Name'); + }); + + it('puts the display name and username in the header', () => { + expect(fixture.nativeElement.querySelector('.profile-header h1').textContent.trim()).toBe( + 'Ada Lovelace', + ); + expect(fixture.nativeElement.querySelector('.profile-header__meta').textContent).toContain( + 'ada', + ); + }); +}); + +// Identity the institution asserts is shown, not offered for editing. Renders +// the real template under both auth methods, because the difference between the +// two is entirely in what the form puts on the page. +describe('EditProfileFormComponent institution-managed identity', () => { + let fixture: ComponentFixture; + + const render = async (user: User): Promise => { + TestBed.resetTestingModule(); + + await TestBed.configureTestingModule({ + declarations: [EditProfileFormComponent, StubNgFormProfile, StubNotificationNgModel], + providers: [ + {provide: AlertService, useValue: {error: vi.fn()}}, + { + provide: DoubtfireConstants, + useValue: {ExternalName: {value: 'OnTrack'}, IsTiiEnabled: {value: false}}, + }, + {provide: UserService, useValue: {currentUser: user}}, + {provide: Router, useValue: {}}, + {provide: AuthenticationService, useValue: {}}, + {provide: MAT_DIALOG_DATA, useValue: {user, mode: 'edit', modal: false}}, + {provide: MatSnackBar, useValue: {}}, + {provide: PushNotificationService, useValue: pushServiceStub}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(EditProfileFormComponent); + fixture.detectChanges(); + }; + + const input = (name: string): Element | null => + fixture.nativeElement.querySelector(`input[name="${name}"]`); + const accountFacts = (): string[] => + Array.from( + fixture.nativeElement.querySelectorAll('.account-information dt') as NodeListOf, + ).map((term) => term.textContent.trim()); + + const ssoUser = (): User => + makeUser({ + firstName: 'Ada', + lastName: 'Lovelace', + username: 'ada', + email: 'ada@institution.edu', + nickname: 'Addy', + institutionalIdentityManaged: true, + emailEditable: false, + }); + + const localUser = (): User => + makeUser({ + firstName: 'Ada', + lastName: 'Lovelace', + username: 'ada', + email: 'ada@local.test', + institutionalIdentityManaged: false, + emailEditable: true, + }); + + it('shows the managed name and email as account facts under SSO', async () => { + await render(ssoUser()); + + expect(accountFacts()).toContain('First name'); + expect(accountFacts()).toContain('Last name'); + expect(accountFacts()).toContain('Institutional / sign-in email'); + expect(fixture.nativeElement.querySelector('.account-information dl').textContent).toContain( + 'Lovelace', + ); + expect(input('first')).toBeNull(); + expect(input('last')).toBeNull(); + expect(input('email')).toBeNull(); + }); + + it('explains where the managed details come from under SSO', async () => { + await render(ssoUser()); + + expect( + fixture.nativeElement.querySelector('.account-information__managed').textContent, + ).toContain('Your name and email come from your institution account.'); + }); + + it('keeps the preferred name editable and hinted under SSO', async () => { + await render(ssoUser()); + + expect(input('preferred_name')).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'Shown to your tutors instead of your first name.', + ); + }); + + it('keeps name and email editable under database auth', async () => { + await render(localUser()); + + expect(input('first')).not.toBeNull(); + expect(input('last')).not.toBeNull(); + expect(input('email')).not.toBeNull(); + expect(accountFacts()).not.toContain('First name'); + expect(fixture.nativeElement.querySelector('.account-information__managed')).toBeNull(); + }); +}); + +// The save action only appears once the form is dirty. The notification +// category checkboxes live in a child component with standalone ngModels, so +// they never join this form. Renders the real NgForm and NgModel so the +// pristine state is the one the page actually uses. +describe('EditProfileFormComponent save state', () => { + let fixture: ComponentFixture; + let userServiceStub: {currentUser: User; update: ReturnType}; + + beforeEach(async () => { + const currentUser = makeUser({ + firstName: 'Ada', + lastName: 'Lovelace', + email: 'ada@example.com', + studentId: 's1234567', + username: 'ada', + }); + userServiceStub = {currentUser, update: vi.fn().mockReturnValue(of(currentUser))}; + + await TestBed.configureTestingModule({ + declarations: [EditProfileFormComponent, NotificationSettingsComponent], + imports: [ + FormsModule, + MatCheckboxModule, + MatInputModule, + MatSelectModule, + MatSlideToggleModule, + ], + providers: [ + {provide: AlertService, useValue: {error: vi.fn()}}, + { + provide: DoubtfireConstants, + useValue: {ExternalName: {value: 'OnTrack'}, IsTiiEnabled: {value: false}}, + }, + {provide: UserService, useValue: userServiceStub}, + {provide: Router, useValue: {navigateByUrl: vi.fn()}}, + {provide: AuthenticationService, useValue: {}}, + {provide: MAT_DIALOG_DATA, useValue: null}, + {provide: MatSnackBar, useValue: {open: vi.fn()}}, + { + provide: PushNotificationService, + useValue: { + subscription$: of(null), + blocker: () => 'no-service-worker', + permissionDeniedInstructions: () => [], + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(EditProfileFormComponent); + fixture.componentRef.setInput('mode', 'edit'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }); + + const saveButton = (): HTMLButtonElement => + fixture.nativeElement.querySelector('.profile-actions button[type="submit"]'); + + // The redesigned save bar is not rendered at all while the form is pristine. + it('offers no save action until something changes', () => { + expect(saveButton()).toBeNull(); + }); + + it('offers Save changes when only a notification category changes', async () => { + const loader = TestbedHarnessEnvironment.loader(fixture); + const feedback = await loader.getHarness( + MatCheckboxHarness.with({label: 'Feedback notifications'}), + ); + + await feedback.check(); + fixture.detectChanges(); + + expect(saveButton()).not.toBeNull(); + expect(saveButton().disabled).toBe(false); + expect(saveButton().textContent).toContain('Save changes'); + + saveButton().click(); + + expect(userServiceStub.update).toHaveBeenCalled(); + expect(userServiceStub.update.mock.calls[0][0]).toEqual( + expect.objectContaining({receiveFeedbackNotifications: true}), + ); + }); +}); diff --git a/src/app/common/edit-profile-form/edit-profile-form.component.ts b/src/app/common/edit-profile-form/edit-profile-form.component.ts index 7760497140..94002347b6 100644 --- a/src/app/common/edit-profile-form/edit-profile-form.component.ts +++ b/src/app/common/edit-profile-form/edit-profile-form.component.ts @@ -7,6 +7,7 @@ import { OnDestroy, OnInit, Optional, + isDevMode, } from '@angular/core'; import {NgForm} from '@angular/forms'; import {MAT_DIALOG_DATA} from '@angular/material/dialog'; @@ -20,6 +21,9 @@ import {UserService} from 'src/app/api/services/user.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +/** How long the save confirmation stays before the bar leaves with it. */ +const SAVED_CONFIRMATION_MS = 2600; + @Component({ selector: 'f-edit-profile-form', templateUrl: './edit-profile-form.component.html', @@ -58,6 +62,8 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { public formPronouns = {pronouns: ''}; public saving = false; public saveMessage = ''; + /** Keeps the confirmation on screen for a moment after the bar would otherwise go. */ + public justSaved = false; public saveError = ''; public get customPronouns(): boolean { return this.formPronouns.pronouns === '__customPronouns'; @@ -90,6 +96,16 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { this.user.displayPeerProgress = true; } + // The values Discard puts back. Retaken after every successful save. + this.takeSnapshot(); + + // The same for Unit Hub updates, which are on in the bell and off everywhere + // else until the user opts in. + this.user.receiveUnitHubNotifications ??= true; + this.user.receiveUnitHubEmailNotifications ??= false; + this.user.receiveUnitHubPushNotifications ??= false; + this.user.receiveUnitHubSessionReminders ??= false; + if (!this.user.hasRunFirstTimeSetup) { this.user.optInToResearch = false; this.user.receiveFeedbackNotifications = true; @@ -101,6 +117,19 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.pushSubscription?.unsubscribe(); + + // Clearing the timer here is not enough on its own. The save response can + // land after the view has gone, and the handler confirms the save, which + // arms a fresh timer against a component nothing will destroy again. The + // handlers check this flag instead of the request being cancelled here: + // unsubscribing would abort the PUT, so closing the dialog straight after + // pressing Save would silently lose the save. + this.destroyed = true; + + if (this.justSavedTimer) { + clearTimeout(this.justSavedTimer); + this.justSavedTimer = null; + } } /** @@ -179,6 +208,13 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { this.authService.signOut(); } + /** The name shown in the profile header: preferred or first name, then last name. */ + public get displayName(): string { + const first = this.user?.nickname?.trim() || this.user?.firstName?.trim() || ''; + const name = [first, this.user?.lastName?.trim()].filter(Boolean).join(' '); + return name || this.user?.username || ''; + } + public get newUser(): boolean { return this.mode === 'new'; } @@ -193,11 +229,55 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { } public get canEditEmail(): boolean { - return this.newUser || this.user.emailEditable === true; + return this.newUser || (this.user.emailEditable === true && !this.identityManaged); + } + + /** + * First and last name are asserted by the institution on every deployment + * that is not local database auth, so the API rejects a change to either. + * `institutionalIdentityManaged` is the server's own answer to that question, + * carried on the user we already fetched, so the page needs no extra request. + */ + public get canEditName(): boolean { + return this.newUser || !this.identityManaged; + } + + /** + * The server decides this, and a local database-auth demo always answers no. A dev + * build accepts ?identityManaged=1 so the institution-managed page can be seen + * without an SSO deployment. It changes nothing that is saved. + */ + public get identityManagedView(): boolean { + return this.identityManaged; + } + + private get identityManaged(): boolean { + if (this.user?.institutionalIdentityManaged) { + return true; + } + return ( + isDevMode() && new URLSearchParams(window.location.search).get('identityManaged') === '1' + ); + } + + /** + * Identity the deployment manages. These are rendered as facts rather than + * inputs, so they are left out of the update as well. Sending a value the + * user was never able to change is at best noise and at worst a 422. + */ + private get readOnlyIdentityKeys(): string[] { + const keys: string[] = []; + if (!this.canEditName) { + keys.push('firstName', 'lastName'); + } + if (!this.canEditEmail) { + keys.push('email'); + } + return keys; } public get canEditStudentId(): boolean { - return this.newUser || (!this.user.institutionalIdentityManaged && !this.managingOwnProfile); + return this.newUser || (!this.identityManaged && !this.managingOwnProfile); } public get canEditSystemRole(): boolean { @@ -215,6 +295,87 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { return this.constants.IsTiiEnabled.value; } + /** The fields this form edits, for taking a snapshot to discard back to. */ + private static readonly EDITED_FIELDS = [ + 'firstName', + 'lastName', + 'nickname', + 'email', + 'studentId', + 'pronouns', + 'username', + 'systemRole', + 'optInToResearch', + 'displayPeerProgress', + 'acceptedTiiEula', + ] as const; + + private savedSnapshot: Record = {}; + /** The pronouns select sits outside the user object, so it is snapshotted too. */ + private savedFormPronouns = ''; + private justSavedTimer: ReturnType | null = null; + /** Set once the view has gone, so a late save response cannot touch it. */ + private destroyed = false; + + /** The user as it was when the form was last clean. */ + private takeSnapshot(): void { + const snapshot: Record = {}; + EditProfileFormComponent.EDITED_FIELDS.forEach((field) => { + snapshot[field] = (this.user as unknown as Record)[field]; + }); + this.savedSnapshot = snapshot; + this.savedFormPronouns = this.formPronouns.pronouns; + } + + /** + * In edit mode the bar earns its place only when it has something to say: an + * edit to save, a save in flight, a problem, or a confirmation that has not + * faded yet. The other modes always need their main action on screen. + * + * Dirtiness comes from the template's own form reference, so this does not + * depend on how the form directive happens to be resolved. + */ + public showActions(dirty: boolean): boolean { + if (this.mode !== 'edit') { + return true; + } + return dirty || this.saving || this.justSaved || !!this.saveError; + } + + /** Puts every edited field back to how it was when the form was last clean. */ + public discard(form?: NgForm): void { + if (this.saving) { + return; + } + + const target = this.user as unknown as Record; + Object.entries(this.savedSnapshot).forEach(([field, value]) => { + target[field] = value; + }); + + this.formPronouns.pronouns = this.savedFormPronouns; + this.saveMessage = ''; + this.saveError = ''; + form?.form.markAsPristine(); + form?.form.markAsUntouched(); + } + + private confirmSaved(message: string, form?: NgForm): void { + this.saveMessage = message; + form?.form.markAsPristine(); + this.takeSnapshot(); + + // The bar leaves on its own once the confirmation has been seen. + this.justSaved = true; + if (this.justSavedTimer) { + clearTimeout(this.justSavedTimer); + } + this.justSavedTimer = setTimeout(() => { + this.justSaved = false; + this.justSavedTimer = null; + }, SAVED_CONFIRMATION_MS); + } + public submit(form?: NgForm): void { if (this.saving || form?.invalid) { return; @@ -229,11 +390,13 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { if (this.newUser) { this.userService.create(this.user).subscribe({ next: (updatedUser) => { + if (this.destroyed) { + return; + } this.saving = false; this.user = updatedUser; this.initialFirstName = this.user.firstName; - form?.form.markAsPristine(); - this.saveMessage = 'User created.'; + this.confirmSaved('User created.', form); this._snackBar.open('User created', 'dismiss', { duration: 1500, @@ -244,16 +407,23 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { error: (error: unknown) => this.handleSaveError(error), }); } else { - this.userService.update(this.user).subscribe({ + const ignoreKeys = this.readOnlyIdentityKeys; + const request = ignoreKeys.length + ? this.userService.update(this.user, {entity: this.user, ignoreKeys}) + : this.userService.update(this.user); + + request.subscribe({ next: (updatedUser) => { + if (this.destroyed) { + return; + } this.saving = false; if (this.mode === 'create') { this.router.navigateByUrl('/home'); } else { this.user = updatedUser; this.initialFirstName = this.user.firstName; - form?.form.markAsPristine(); - this.saveMessage = 'Profile saved.'; + this.confirmSaved('Profile saved.', form); // TODO: refactor into new alertService // this is a new snackbar alert test @@ -270,13 +440,20 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { } private handleSaveError(error: unknown): void { - this.saving = false; const serverMessage = error instanceof HttpErrorResponse ? error.error?.error : null; const message = typeof error === 'string' ? error : serverMessage; - this.saveError = + const text = typeof message === 'string' && message.trim() ? message : 'Profile could not be saved. Check your connection and try again.'; - this.alerts.error(this.saveError, 6000); + + // The alert still goes up after the view has gone: a save that failed is + // worth saying wherever the user ended up, and the message says which one. + // Only the in-form state is skipped, because there is no form left to show it. + if (!this.destroyed) { + this.saving = false; + this.saveError = text; + } + this.alerts.error(text, 6000); } } diff --git a/src/app/common/file-uploader/file-uploader.accessibility.spec.ts b/src/app/common/file-uploader/file-uploader.accessibility.spec.ts index c0e82c8e0f..8bf6105515 100644 --- a/src/app/common/file-uploader/file-uploader.accessibility.spec.ts +++ b/src/app/common/file-uploader/file-uploader.accessibility.spec.ts @@ -61,7 +61,7 @@ describe('File uploader accessible controls', () => { fixture.nativeElement.querySelector('button.file-drop-zone'); expect(dropZone).not.toBeNull(); expect(dropZone.closest('[hidden]')).toBeNull(); - expect(dropZone.textContent).toContain('Drop PDF file here'); + expect(dropZone.textContent).toContain('Drop your PDF here'); }, ); diff --git a/src/app/common/file-uploader/file-uploader.component.html b/src/app/common/file-uploader/file-uploader.component.html index 590b3c73fa..74a6ac1f73 100644 --- a/src/app/common/file-uploader/file-uploader.component.html +++ b/src/app/common/file-uploader/file-uploader.component.html @@ -1,5 +1,5 @@ - - +
    +
    @if (!showUploader) { } -
    - @if (showUploader && uploadingInfo === null && shownUploadZones.length) { -
    - @for (upload of shownUploadZones; track upload) { - @if (!singleDropZone && showName) { -
    +
    + @if (showUploader && uploadingInfo === null && renderedUploadZones.length) { +
    + @for (upload of renderedUploadZones; track upload) { + @if (!showSummaryColumn && showName) { +
    {{ uploadZones.length === 1 ? '' : $index + 1 + ' - ' }} {{ upload.display.name }}
    } - @if (singleDropZone && showName) { -
    Select {{ upload.display.name }}
    + @if (showSummaryColumn && showName) { +
    {{ upload.display.name }}
    }
    @@ -66,10 +72,19 @@
    Select {{ upload.display.name }}
    />
    - @if (!singleDropZone && upload.model?.length > 0) { -
    - - {{ upload.model[0].name }} + @if (!showSummaryColumn && upload.model?.length > 0) { +
    + + + {{ upload.model[0].name }} + {{ formatSize(upload.model[0].size) }} +
    } - @if (showUploader && singleDropZone && uploadingInfo === null) { -
    -
    Upload Summary
    - - @for (upload of uploadZones; track upload) { -
    -
    - - {{ upload.display.name }} -
    + @if (showUploader && showSummaryColumn && uploadingInfo === null) { +
    +
    Selected files
    +
      + @for (upload of uploadZones; track upload) { @if (upload.model?.length > 0) { - {{ upload.model[0].name }} - +
    • + + + {{ upload.model[0].name }} + {{ upload.display.name }} · + {{ formatSize(upload.model[0].size) }} + + Ready + +
    • } @else { - File pending +
    • + + + {{ upload.display.name }} + {{ acceptedFormatsLabel(upload) }} + + @if (upload.display.error) { + Wrong type + } @else { + Waiting for file + } +
    • } -
    - } + } +
    }
    @@ -137,55 +176,78 @@
    Upload Summary
    } @if (showUploader && readyToUpload() && isUploading) { - @if (!uploadingInfo?.complete) { -
    -
    - @for (upload of uploadZones; track upload) { - {{ upload.display.icon }} - } - arrow_right_alt - -
    + + @if (showProgressPanel) { +
    + - {{ uploadTitle }}

    +

    {{ uploadingFileLabel }}

    + +
    - - + +
    + + @if (!uploadSettling && !uploadComplete) { +

    {{ uploadProgress }}%

    + + }
    } - @if (uploadingInfo?.complete) { -
    -
    - - {{ uploadingInfo.success ? 'check_circle' : 'cancel' }} - - - - File Upload {{ uploadingInfo.success ? 'Successful' : 'Failed' }} - + + @if (uploadingInfo?.complete && !uploadingInfo.success) { +
    + +

    Upload failed

    +

    {{ uploadingInfo.error }}

    + +
    + +
    - - @if (!uploadingInfo.success) { -
    -
    -

    Error Message: {{ uploadingInfo.error }}

    - -
    - - -
    -
    - }
    } } - - +
    +
    diff --git a/src/app/common/file-uploader/file-uploader.component.spec.ts b/src/app/common/file-uploader/file-uploader.component.spec.ts index 07855faa53..7d8462d58d 100644 --- a/src/app/common/file-uploader/file-uploader.component.spec.ts +++ b/src/app/common/file-uploader/file-uploader.component.spec.ts @@ -42,7 +42,7 @@ describe('FileUploaderComponent responsive selected-file state', () => { fixture.detectChanges(); }); - it('wraps a long selected filename and exposes a named remove control', () => { + it('keeps a long selected filename readable and exposes a named remove control', () => { const file = new File( ['portfolio'], 'A very long learning summary report filename that must remain readable on a phone.pdf', @@ -59,6 +59,7 @@ describe('FileUploaderComponent responsive selected-file state', () => { '.selected-upload button', ) as HTMLButtonElement; expect(name.textContent).toContain(file.name); + expect(name.getAttribute('title')).toBe(file.name); expect(remove.getAttribute('aria-label')).toBe(`Remove ${file.name}`); expect(component.readyToUpload()).toBe(true); }); diff --git a/src/app/common/file-uploader/file-uploader.component.ts b/src/app/common/file-uploader/file-uploader.component.ts index c401561970..5ccdb07183 100644 --- a/src/app/common/file-uploader/file-uploader.component.ts +++ b/src/app/common/file-uploader/file-uploader.component.ts @@ -4,10 +4,12 @@ import { EventEmitter, Input, OnChanges, + OnDestroy, OnInit, Output, SimpleChanges, } from '@angular/core'; +import {Subscription} from 'rxjs'; import {UserService} from 'src/app/api/services/user.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {ACCEPTED_TYPES} from './file-upload-types'; @@ -50,7 +52,7 @@ interface UploadingInfo { changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class FileUploaderComponent implements OnInit, OnChanges { +export class FileUploaderComponent implements OnInit, OnChanges, OnDestroy { @Input() files: FileUploadSpec; @Input() url: string; @Input() method = 'POST'; @@ -69,8 +71,26 @@ export class FileUploaderComponent implements OnInit, OnChanges { @Input() showName: boolean = true; @Input() asButton: boolean = false; @Input() singleDropZone: boolean = false; + /** + * Set false when the host shows its own confirmation after a successful upload, + * so the two do not play one after the other. A failure still reports here, + * because only this component knows how to retry it. + */ + @Input() showSuccessState: boolean = true; + /** + * Set false when the host draws the in-flight state itself, so one panel can + * carry the upload all the way into its own confirmation instead of handing + * over to a second one. A failure still reports here. + */ + @Input() showProgressState: boolean = true; @Input() showUploadButton: boolean = true; @Input() resetAfterUpload: boolean = true; + /** + * What the in-flight panel calls what is being sent. This component is shared + * with the CSV importers and the group-set editor, where "your work" is a + * tutor's enrolment file and belongs to nobody. + */ + @Input() uploadingLabel: string = 'Uploading'; @Input() initiateUpload?: () => void; @@ -89,6 +109,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { public shownUploadZones: UploadZone[] = []; public uploadZones: UploadZone[] = []; public dropSupported: boolean = true; + /** The zone a file is being dragged over, for the drag-over style only. */ + public dragOverZone: UploadZone | null = null; constructor( private userService: UserService, @@ -96,6 +118,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { ) {} private externalName: string = 'OnTrack'; + private externalNameSub: Subscription | null = null; + private completionTimer: ReturnType | null = null; private activeRequest?: XMLHttpRequest; private uploadWasCancelled = false; @@ -111,11 +135,28 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.resetUploader(); - this.constants.ExternalName.subscribe((name) => { + this.externalNameSub = this.constants.ExternalName.subscribe((name) => { this.externalName = name; }); } + ngOnDestroy(): void { + // ExternalName is a BehaviorSubject on a root service, so it never + // completes. Left subscribed, every dialog that has ever held an uploader + // stays in memory for the session. + this.externalNameSub?.unsubscribe(); + this.externalNameSub = null; + + // The completion callback runs on a delay, and the host it calls back into + // may be gone by then: closing the dialog in that window had a destroyed + // component apply the submission and claim its confirmation, so the student + // was told nothing at all. + if (this.completionTimer) { + clearTimeout(this.completionTimer); + this.completionTimer = null; + } + } + ngOnChanges(changes: SimpleChanges): void { if (changes['files']) { this.createUploadZones(changes.files.currentValue); @@ -128,19 +169,22 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.uploadingInfo = null; } - public onDragOver(event: DragEvent) { + public onDragOver(event: DragEvent, upload?: UploadZone) { event.preventDefault(); event.stopPropagation(); + this.dragOverZone = upload ?? null; } public onDragLeave(event: DragEvent) { event.preventDefault(); event.stopPropagation(); + this.dragOverZone = null; } public onFileDropped(event: DragEvent, upload: UploadZone) { event.preventDefault(); event.stopPropagation(); + this.dragOverZone = null; const file = event.dataTransfer?.files?.[0]; if (file) { @@ -189,6 +233,88 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.updateReadyState(this.readyToUpload()); } + /** + * The summary column beside the drop zone is a second copy of the same state. + * It earns its place only when there is more than one file to keep track of; + * with one, the zone becomes the selected file in place instead. + */ + public get showSummaryColumn(): boolean { + return this.singleDropZone && this.uploadZones.length > 1; + } + + /** + * With the summary column the left side narrows to the next zone still waiting + * for a file. Without it, every zone stays on screen and each one turns into + * its own selected file, so nothing disappears when a file is chosen. + */ + public get renderedUploadZones(): UploadZone[] { + return this.showSummaryColumn ? this.shownUploadZones : this.uploadZones; + } + + /** + * The request has landed but the host has not taken over yet. Completion fires + * `onComplete` on a short delay, and unmounting this panel the moment the bytes + * arrive left the dialog empty for that whole window. + */ + public get uploadSettling(): boolean { + return ( + !this.showSuccessState && + this.uploadingInfo?.complete === true && + this.uploadingInfo?.success === true + ); + } + + /** A success this component is showing itself, rather than handing over. */ + public get uploadComplete(): boolean { + return this.showSuccessState && this.uploadLanded; + } + + public get showProgressPanel(): boolean { + if (!this.showProgressState) { + return false; + } + return !this.uploadingInfo?.complete || this.uploadSettling || this.uploadComplete; + } + + public get uploadTitle(): string { + if (this.uploadComplete || this.uploadSettling) { + return 'Uploaded'; + } + return this.uploadingLabel; + } + + /** Percent sent so far, for a host drawing the in-flight state itself. */ + public get uploadProgress(): number { + return this.uploadingInfo?.progress ?? 0; + } + + /** True once the bytes are away, whether or not the host has taken over. */ + public get uploadLanded(): boolean { + return this.uploadingInfo?.complete === true && this.uploadingInfo?.success === true; + } + + /** + * Bytes actually on the wire. `isUploading` stays true after the request + * settles, because the panels that report the outcome are rendered under it, + * so a host asking "is there something here I would interrupt?" has to ask + * this instead. + */ + public get uploadInFlight(): boolean { + return this.isUploading && this.uploadingInfo?.complete !== true; + } + + /** The file being sent, or a count once there is more than one. */ + public get uploadingFileLabel(): string { + const named = this.uploadZones + .map((zone) => zone.model?.[0]?.name) + .filter((name): name is string => !!name); + + if (named.length === 0) { + return ''; + } + return named.length === 1 ? named[0] : `${named.length} files`; + } + readyToUpload(): boolean { return this.uploadZones.every((zone) => zone.model?.length); } @@ -287,7 +413,8 @@ export class FileUploaderComponent implements OnInit, OnChanges { if (xhr.status >= 200 && xhr.status < 300) { this.onSuccess?.(response); this.uploadingInfo.success = true; - setTimeout(() => { + this.completionTimer = setTimeout(() => { + this.completionTimer = null; this.onComplete?.(); if (this.resetAfterUpload) { this.resetUploader(); @@ -323,6 +450,45 @@ export class FileUploaderComponent implements OnInit, OnChanges { this.onCancelUpload?.(); } + /** What the drop zone asks for, e.g. "PDF" or "code file". */ + public dropNoun(upload: UploadZone): string { + const type = upload.display.type; + if (type === 'PDF' || type === 'image') { + return type; + } + return `${type === 'zip' ? 'ZIP' : type} file`; + } + + /** The accepted formats in words, e.g. "PDF or PS", shortened when the list is long. */ + public acceptedFormatsLabel(upload: UploadZone): string { + const formats = upload.accepts.map((ext) => ext.toUpperCase()); + const previewLimit = 4; + if (formats.length > previewLimit) { + const rest = formats.length - previewLimit; + return `${formats.slice(0, previewLimit).join(', ')} and ${rest} more`; + } + if (formats.length <= 1) { + return formats.join(''); + } + return `${formats.slice(0, -1).join(', ')} or ${formats[formats.length - 1]}`; + } + + /** A file size in words, e.g. "1.2 MB". */ + public formatSize(bytes: number | undefined): string { + if (bytes == null || !Number.isFinite(bytes)) { + return ''; + } + if (bytes < 1024) { + return `${bytes} B`; + } + const kb = bytes / 1024; + if (kb < 1024) { + return `${kb.toFixed(kb < 10 ? 1 : 0)} KB`; + } + const mb = kb / 1024; + return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`; + } + // onClickFailureCancelInternal() { // console.log('onClickFailureCancelInternal'); // } diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html index 8f73ba8580..748bfe1340 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/submission-files-modal/submission-files-modal.component.html @@ -16,7 +16,7 @@

    - +
    diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html index 070410f46a..5187952c28 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html @@ -80,13 +80,13 @@

    Your submissio } @if (canRegeneratePdf) { - } @if (task?.inSubmittedState()) { - diff --git a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html index 6fa8db5960..ee2c2c77f2 100644 --- a/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html +++ b/src/app/projects/states/portfolio/directives/portfolio-learning-summary-report-step/portfolio-learning-summary-report-step.component.html @@ -75,6 +75,7 @@

    [files]="learningSummaryReportFileUploadData.type" [onSuccess]="addNewFile.bind(this)" [payload]="learningSummaryReportFileUploadData.payload" + [uploadingLabel]="'Uploading your work'" [url]="project.portfolioUrl()" >

    diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html index 19ed440ed1..9b168548fc 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.html @@ -1,58 +1,73 @@
    - @if (hasRequirements) { -

    Files required: {{ requiredFileCount }}

    + -
      - @for (summary of summaries; track $index) { -
    • -

      - Required file type: {{ summary.categoryLabel }} -

      - @if (summary.name) { -

      {{ summary.name }}

      - } -

      - Accepted formats: - @if (summary.extensions.length) { - {{ summary.previewExtensions.join(', ') }} - @if (summary.hasMoreExtensions) { - , … +

      +

      What to upload

      + + @if (hasRequirements) { +
        +
      • + + {{ requiredFileCountLabel }} +
      • + + @for (summary of summaries; track $index) { +
      • + +
        +

        + {{ summary.categoryLabel }} + &ngsp;·&ngsp; + + @if (summary.extensions.length) { + @if (summary.hasMoreExtensions) { + {{ summary.previewExtensions.join(', ') }}, and more + } @else { + {{ formatList(summary.previewExtensions) }} + } + } @else { + Not specified for this task + } + +

        + + @if (summary.maxSizeLabel) { +

        Up to {{ summary.maxSizeLabel }}

        } - } @else { - Not specified for this task - } -

        -

        - Maximum size: Not provided by the server. Upload limits still apply. -

        - @if (summary.hasMoreExtensions) { - -
          - @for (extension of summary.extensions; track extension) { -
        • {{ extension }}
        • + @if (showDescription(summary)) { +

          {{ summary.name }}

          } -
        - } -
      • - } -
      - } @else { -

      - Upload requirements for this task are not currently available. Contact your teaching team if - this message persists. -

      - } + + @if (summary.hasMoreExtensions) { + +
        + @for (extension of summary.extensions; track extension) { +
      • {{ extension }}
      • + } +
      + } +
      +
    • + } +
    + } @else { +

    + Upload requirements for this task are not currently available. Contact your teaching team if + this message persists. +

    + } +
    diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss index 37467efbf5..7aa51e7bd1 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.scss @@ -1,65 +1,122 @@ +@use '../../../../unit-hub/hub-card' as hub; + +@include hub.card-theme; + +// A quiet callout: info icon, then a heading and one icon row per fact. .task-upload-requirements { - margin-bottom: 1em; - margin-top: 1em; + @include hub.callout(raised); min-width: 0; } +.requirements-callout-icon { + flex-shrink: 0; +} + +.requirements-body { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; +} + +.requirements-heading { + margin: 0; + color: var(--ot-color-text); + font-size: 0.95rem; + font-weight: 650; + line-height: 1.4; +} + .requirements-list { - list-style: none; + display: flex; + flex-direction: column; + gap: 6px; margin: 0; padding: 0; + list-style: none; +} + +.requirement-row { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 10px; + color: var(--ot-color-text); + overflow-wrap: anywhere; + + > mat-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + margin-top: 2px; + color: var(--ot-color-text-muted); + font-size: 18px; + } +} + +.requirement-text { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + + p { + margin: 0; + } } -.requirement { - padding: 0.5em 0.75em; - margin-bottom: 0.5em; - // A text mix, not the divider: the dialog is the raised surface, which the dark - // divider matches exactly. In light this lands on the old #ddd. - border: 1px solid color-mix(in srgb, var(--ot-color-text) 15%, transparent); - border-radius: 4px; - word-break: break-word; +.requirement-label { + font-weight: 600; +} + +.requirement-separator { + color: var(--ot-color-text-muted); +} + +.requirement-name, +.requirement-max-size { + color: var(--ot-color-text-muted); } .requirements-missing { - color: inherit; - font-style: italic; + margin: 0; + color: var(--ot-color-text-muted); } .extensions-toggle { - background: none; - border: none; + align-self: flex-start; + margin-top: 2px; padding: 0; - color: inherit; - text-decoration: underline; + border: none; + border-radius: var(--ot-radius-xs); + background: none; + color: var(--ot-color-link); cursor: pointer; - font-size: inherit; + font: inherit; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; - &:hover, - &:focus { - outline: 2px solid currentColor; - outline-offset: 3px; + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; } } .extensions-list { display: flex; flex-wrap: wrap; - gap: 0.25em 0.75em; - list-style: none; - margin: 0.5em 0 0; + gap: 4px 6px; + margin: 6px 0 0; padding: 0; + list-style: none; li { - border: 1px solid currentColor; - border-radius: 3px; - padding: 0.1em 0.5em; - } -} - -// Narrow screens: stack extension chips full-width instead of relying on wrapping alone -@media (max-width: 480px) { - .extensions-list { - flex-direction: column; - gap: 0.25em; + padding: 1px 8px; + border: 1px solid color-mix(in srgb, var(--ot-color-border) 45%, transparent); + border-radius: var(--ot-radius-pill); + color: var(--ot-color-text-muted); + font-size: 0.78rem; } } diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts index 7f0e44163f..7e1a995f10 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.spec.ts @@ -1,4 +1,5 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatIconModule} from '@angular/material/icon'; import {UploadRequirement} from 'src/app/api/models/task-definition'; import {TaskUploadRequirementsComponent} from './task-upload-requirements.component'; @@ -8,6 +9,7 @@ describe('TaskUploadRequirementsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ + imports: [MatIconModule], declarations: [TaskUploadRequirementsComponent], }).compileComponents(); @@ -41,9 +43,8 @@ describe('TaskUploadRequirementsComponent', () => { ]); const text = (fixture.nativeElement as HTMLElement).textContent; - expect(text).toContain('Files required:'); - expect(text).toContain('2'); - expect(text).toContain('PDF'); + expect(text).toContain('What to upload'); + expect(text).toContain('2 files required'); expect(text).toContain('PDF'); expect(text).toContain('Spreadsheet'); expect(text).toContain('CSV'); @@ -51,6 +52,22 @@ describe('TaskUploadRequirementsComponent', () => { expect(text).toContain('XLSX'); }); + it('reads as icon rows: count, then type and formats, then a differing description', () => { + setRequirements([{key: 'file0', name: 'Demo document', type: 'document'}]); + + const text = (fixture.nativeElement as HTMLElement).textContent!.replace(/\s+/g, ' '); + expect(text).toContain('1 file required'); + expect(text).toContain('Document · PDF'); + expect(text).toContain('Demo document'); + expect(fixture.nativeElement.querySelectorAll('.requirement-row mat-icon').length).toBe(2); + }); + + it('hides the description when it only repeats the type name', () => { + setRequirements([{key: 'file0', name: 'Document', type: 'document'}]); + + expect(fixture.nativeElement.querySelector('.requirement-name')).toBeNull(); + }); + it('does not show an expand control when the extension list is short', () => { setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); @@ -101,8 +118,20 @@ describe('TaskUploadRequirementsComponent', () => { setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); const text = (fixture.nativeElement as HTMLElement).textContent; - expect(text).toContain('Maximum size:'); - expect(text).toContain('Not provided by the server'); + expect(component.summaries[0].maxSizeLabel).toBeNull(); + expect(fixture.nativeElement.querySelector('.requirement-max-size')).toBeNull(); + expect(text).not.toContain('Not provided by the server'); + expect(text).not.toMatch(/\d+\s?(KB|MB|GB)/); + }); + + it('shows a size limit only when one is provided', () => { + setRequirements([{key: 'file0', name: 'Report', type: 'document'}]); + component.summaries[0].maxSizeLabel = '10 MB'; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.requirement-max-size').textContent).toContain( + '10 MB', + ); }); }); diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts index 3e9652f0e5..680de9cd8a 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component.ts @@ -43,6 +43,25 @@ export class TaskUploadRequirementsComponent implements OnChanges { return this.summaries.length; } + public get requiredFileCountLabel(): string { + const count = this.requiredFileCount; + return `${count} ${count === 1 ? 'file' : 'files'} required`; + } + + /** Joins a list as "A, B or C". */ + public formatList(items: readonly string[]): string { + if (items.length <= 1) { + return items.join(''); + } + return `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`; + } + + /** The file's own description, only when it says more than the type name does. */ + public showDescription(summary: UploadRequirementSummary): boolean { + const name = summary.name?.trim().toLowerCase(); + return !!name && name !== summary.categoryLabel.toLowerCase(); + } + public isExpanded(summary: UploadRequirementSummary): boolean { return this.expandedKeys.has(summary.key); } diff --git a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts index 9d9fa79be6..c776c6a629 100644 --- a/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts +++ b/src/app/tasks/modals/upload-submission-modal/task-upload-requirements/upload-category.ts @@ -2,19 +2,32 @@ import {UploadRequirement} from 'src/app/api/models/task-definition'; import {ACCEPTED_TYPES} from 'src/app/common/file-uploader/file-upload-types'; const CATEGORY_LABELS: Record = { - document: 'PDF', + document: 'Document', csv: 'Spreadsheet', code: 'Code', image: 'Image', zip: 'Archive', }; +const CATEGORY_ICONS: Record = { + document: 'description', + csv: 'table_chart', + code: 'code', + image: 'image', + zip: 'folder_zip', +}; + export const EXTENSION_PREVIEW_LIMIT = 8; export interface UploadRequirementSummary { key: string; name: string; categoryLabel: string; + icon: string; + // The API does not expose a per-file size limit today. When it does, set this + // and the requirement row shows it. Until then nothing is shown, so no limit is + // invented for the student. + maxSizeLabel: string | null; extensions: string[]; previewExtensions: string[]; hasMoreExtensions: boolean; @@ -34,6 +47,8 @@ export function summariseUploadRequirement( categoryLabel: knownType ? CATEGORY_LABELS[type as keyof typeof ACCEPTED_TYPES] : type || 'File', + icon: knownType ? CATEGORY_ICONS[type as keyof typeof ACCEPTED_TYPES] : 'insert_drive_file', + maxSizeLabel: null, extensions, previewExtensions: extensions.slice(0, EXTENSION_PREVIEW_LIMIT), hasMoreExtensions: extensions.length > EXTENSION_PREVIEW_LIMIT, diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html index 4717eeebf0..5960dc82d4 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.html @@ -1,233 +1,372 @@ -
    -

    - Submit {{ task.definition.abbreviation }} {{ task.definition.name }} -

    - - Submission type - - @for (option of submissionTypeOptions; track option.id) { - {{ option.label }} + +@if (!uploadStarted) { +
    +

    Submit task

    +
    + {{ task.definition.abbreviation }} + {{ task.definition.name }} + @if (dueDate) { + + + Due {{ dueDate | date: 'EEE d MMM' }} + } - - -
    +
    + +} - + +

    {{ uploadAnnouncement }}

    -
    - @if (isGroupStage && !uploadStarted) { - - -
    -

    Team Contribution

    -

    - Please rate each team member's effort on {{ task.definition.name }}. -

    -

    Click the same rating twice to record no contribution.

    -
    + + @if (showSubmitFlow) { +
    + + + +

    {{ flowTitle }}

    +

    {{ flowDetail }}

    + +
    + +
    + + + +
    + } + + +
    + + @if (!uploadStarted) { +
    + Submit as + + + + @if (statusFor(submissionType); as status) { + + } @else { + + } + {{ selectedSubmissionTypeLabel }} + + + @for (option of submissionTypeOptions; track option.id) { + + + @if (statusFor(option.id); as status) { + + } @else { + + } + {{ option.label }} + + + } + +
    + } - 1 && !uploadStarted) { +
      + @for (step of steps; track step.stage) { +
    1. - - - + + {{ step.label }} + @if ($index < currentStepIndex) { + (done) + } +
    2. + } +
    } + @if (isGroupStage && !uploadStarted) { +
    +
    +

    Team contribution

    +

    + Please rate each team member's effort on {{ task.definition.name }}. +

    +

    + Click the same rating twice to record no contribution. +

    +
    - - - @if (!uploadStarted) { -
    -

    - Select {{ submissionType === 'need_help' ? 'files' : 'evidence' }} to upload -

    + + +
    + } + +
    + @if (!uploadStarted) { +

    + Select {{ submissionType === 'need_help' ? 'files' : 'evidence' }} to upload +

    + } + @if (!uploadStarted && submissionType === 'need_help') { +

    + Upload the required files so your tutor can help with this task, even if they are not + finished yet. +

    + } + + + - @if (submissionType === 'need_help') { -

    - Upload the required files so your tutor can help with this task, even if they are - not finished yet. + + +

    + + @if (!uploadStarted && isCommentsStage) { +
    +
    + @if (submissionType === 'need_help') { +

    + What do you need help with? +

    +

    + Add a short comment so your tutor knows what help you need on this task. +

    + } @else { +

    + Final comments +

    + @if (task.definition.assessInPortfolioOnly) { +

    + Please specify which areas of your submission you would like feedback on.

    } @else { -

    - Upload files that show your evidence of work until each required file has been - selected. +

    + Add an optional note for your tutor to read while assessing this submission.

    } + } +
    + + + Comment + + + @if (requiresComment) { + Required: at least {{ minCommentLength }} characters, excluding leading and trailing + spaces. + } @else { + Optional comment. + } + Character count: {{ comment.trim().length }}. + + Enter at least {{ minCommentLength }} characters, excluding leading and trailing + spaces. + +
    + + @if (privacyPolicy.privacy) { +
    +
    +

    Declaration

    +

    {{ privacyPolicy.privacy }}

    - } - - - - - - +
    + + - @if (!uploadStarted && isCommentsStage) { -
    - - -
    - @if (submissionType === 'need_help') { -

    - What do you need help with? -

    -

    - Add a short comment so your tutor knows what help you need on this task. -

    - } @else { -

    Final comments

    - @if (task.definition.assessInPortfolioOnly) { -

    - Please specify which areas of your submission you would like feedback on. -

    - } @else { -

    - Add an optional note for your tutor to read while assessing this submission. -

    - } - } -
    - - - Comment - - - @if (requiresComment) { - Required: at least {{ minCommentLength }} characters, excluding leading and - trailing spaces. - } @else { - Optional comment. - } - Character count: {{ comment.trim().length }}. - - Enter at least {{ minCommentLength }} characters, excluding leading and trailing - spaces. - -
    -
    - - @if (privacyPolicy.privacy) { - - -
    -

    Declaration

    -

    {{ privacyPolicy.privacy }}

    -
    - -
    - - - @if (showPlagiarism) { -
    - {{ privacyPolicy.plagiarism }} -
    - } -
    -
    -
    - } -
    + @if (showPlagiarism) { +

    + {{ privacyPolicy.plagiarism }} +

    + } +
    +
    + } }
    -@if (!uploadStarted) { - - - @if (isGroupStage) { - - } +
    + @if (continueHint; as hint) { + {{ hint }} + } - @if (isDetailsStage && showCommentsSection) { - - } + @if (isDetailsStage && showGroupSection) { + + } - @if (isDetailsStage && showGroupSection) { - - } + @if (isCommentsStage) { + + } - @if (isCommentsStage) { - - } + @if (isGroupStage) { + + } - @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { - - } + @if (isDetailsStage && showCommentsSection) { + + } + + @if ((!showCommentsSection && isDetailsStage) || isCommentsStage) { + + } +
    } diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts index 9cc0dd670c..8813280807 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.spec.ts @@ -1,4 +1,4 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {type Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormsModule} from '@angular/forms'; @@ -78,6 +78,7 @@ describe('UploadSubmissionModalComponent', () => { (component as unknown as {fileUploader: unknown}).fileUploader = { isUploading: false, + uploadInFlight: false, hasSelectedFiles: () => true, }; const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); @@ -94,6 +95,7 @@ describe('UploadSubmissionModalComponent', () => { it('cannot dismiss while the upload request is active', () => { (component as unknown as {fileUploader: unknown}).fileUploader = { isUploading: true, + uploadInFlight: true, hasSelectedFiles: () => true, }; const confirm = vi.spyOn(window, 'confirm'); @@ -103,6 +105,97 @@ describe('UploadSubmissionModalComponent', () => { confirm.mockRestore(); }); + it('can be dismissed and retried once a failed upload has settled', () => { + // The uploader keeps isUploading set after the request lands, because its + // own outcome panels render under it. Reading that as "still uploading" + // trapped the student on the error: no backdrop, no Escape, no retry. + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: true, + uploadInFlight: false, + uploadingInfo: {complete: true, success: false}, + hasSelectedFiles: () => true, + }; + component.uploadStarted = true; + // What the uploader's onFailure callback does, rather than a hand-set: the + // Submit button stays locked without it and the retry below is unreachable. + component.onUploadFailure(); + + expect(component.uploadFailed).toBe(true); + + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + expect(component.canClose()).toBe(true); + expect(confirm).toHaveBeenCalled(); + confirm.mockRestore(); + + const startUpload = vi.fn(); + component.onUploaderReady(startUpload); + component.uploadButtonClicked(); + expect(startUpload).toHaveBeenCalled(); + }); + + it('never asks about discarding work the server has already taken', () => { + // canClose is the dialog's closePredicate, and Material runs it for + // programmatic closes too. Prompting here both lied to the student and + // vetoed the dialog closing itself: answering "Cancel" to "discard your + // files?" kept them in it. + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: true, + uploadInFlight: false, + hasSelectedFiles: () => true, + }; + const confirm = vi.spyOn(window, 'confirm'); + + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + + // The response is in, the confirmation has not been worked out yet. + expect(component.celebration).toBeNull(); + expect(component.canClose()).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); + + it('records the submission when the dialog is dismissed before the callback', () => { + // Escape or the backdrop on the "Uploaded" panel destroys the dialog before + // the uploader reports completion. The bytes are already accepted, so the + // status change still has to land, with the celebration left for elsewhere. + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + + component.ngOnDestroy(); + + expect(task.updateFromJson).toHaveBeenCalled(); + expect(task.processTaskStatusChange).toHaveBeenCalledWith( + 'ready_for_feedback', + expect.anything(), + true, + false, + ); + }); + + it('still records the status change when Done is pressed before the callback fires', () => { + // Done is on screen from the moment the bytes land, which is about a second + // before the uploader reports completion. Leaving in that window used to + // close the dialog without ever applying the response. + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + + component.finishCelebration(); + + expect(task.updateFromJson).toHaveBeenCalled(); + // Unclaimed, so the dashboard gets to show what this dialog no longer will. + expect(task.processTaskStatusChange).toHaveBeenCalledWith( + 'ready_for_feedback', + expect.anything(), + true, + false, + ); + expect(dialogRef.close).toHaveBeenCalledWith({value: task}); + + // And the uploader's own callback must not apply it a second time. + component.onUploadComplete(); + expect(task.processTaskStatusChange).toHaveBeenCalledTimes(1); + }); + it('marks the task queued immediately and closes on the same task after upload', () => { component.submissionType = 'ready_for_feedback'; component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); @@ -117,10 +210,95 @@ describe('UploadSubmissionModalComponent', () => { 'ready_for_feedback', expect.anything(), true, + true, ); expect(dialogRef.close).toHaveBeenCalledWith({value: task}); }); + it('holds the dialog open on the confirmation, then closes on the same task', () => { + vi.useFakeTimers(); + (task.processTaskStatusChange as unknown as Mock).mockReturnValue({ + timing: 'on_time', + resubmission: false, + tone: 'success', + particles: true, + headline: 'Submitted on time. Ready for feedback', + detail: 'T1 A task', + }); + + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + component.onUploadComplete(); + + // The handover plays first, so the panel is mid-swap and not yet showing + // the confirmation. + expect(component.flowSwapping).toBe(true); + expect(component.celebration).toBeNull(); + + vi.advanceTimersByTime(600); + expect(component.flowSwapping).toBe(false); + expect(component.celebration?.headline).toBe('Submitted on time. Ready for feedback'); + expect(dialogRef.close).not.toHaveBeenCalled(); + + // Still open well after the sequence itself has finished arriving, so there + // is time to read it rather than catch it. + vi.advanceTimersByTime(2500); + expect(dialogRef.close).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1500); + expect(dialogRef.close).toHaveBeenCalledWith({value: task}); + vi.useRealTimers(); + }); + + it('lets someone in a hurry leave on the confirmation without a warning', () => { + (component as unknown as {fileUploader: unknown}).fileUploader = { + isUploading: true, + uploadInFlight: true, + hasSelectedFiles: () => true, + }; + const confirm = vi.spyOn(window, 'confirm'); + + // Mid-upload the dialog holds on, files selected and request in flight. + expect(component.canClose()).toBe(false); + + // Once it is confirmed the work is saved, so the backdrop and Escape work. + component.celebration = { + timing: 'on_time', + resubmission: false, + tone: 'success', + particles: false, + headline: 'Submitted on time. Ready for feedback', + detail: '1.1P Hello World', + }; + expect(component.canClose()).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); + + it('closes early when the confirmation is dismissed, and only once', () => { + vi.useFakeTimers(); + (task.processTaskStatusChange as unknown as Mock).mockReturnValue({ + timing: 'after_due', + resubmission: false, + tone: 'neutral', + particles: false, + headline: 'Submitted after the due date', + detail: 'T1 A task', + }); + + component.submissionType = 'ready_for_feedback'; + component.onUploadSuccess({id: 8, project_id: 1, status: 'ready_for_feedback'}); + component.onUploadComplete(); + vi.advanceTimersByTime(600); + + component.finishCelebration(); + expect(dialogRef.close).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5000); + expect(dialogRef.close).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + it('restores selection controls after a cancelled slow upload', () => { component.uploadStarted = true; component.uploadSubmitLocked = true; @@ -131,6 +309,92 @@ describe('UploadSubmissionModalComponent', () => { expect(component.uploadSubmitLocked).toBe(false); expect(component.currentStage).toBe('details'); }); + + it('lists the steps and marks the current one', () => { + expect(component.steps.map((step) => step.label)).toEqual(['Upload files', 'Comments']); + expect(component.currentStepIndex).toBe(0); + + component.onReadyChange(true); + component.goToCommentsStage(); + expect(component.currentStepIndex).toBe(1); + }); + + it('explains a disabled forward button and clears once the file is chosen', () => { + expect(component.shouldDisableNext()).toBe(true); + expect(component.continueHint).toBe('Add the required file to continue'); + + component.onReadyChange(true); + expect(component.continueHint).toBeNull(); + }); + + it('carries one panel from sending through to the confirmation', () => { + const uploader = { + uploadProgress: 40, + uploadLanded: false, + uploadingFileLabel: 'report.pdf', + uploadingInfo: {success: null}, + cancelUpload: vi.fn(), + }; + (component as unknown as {fileUploader: unknown}).fileUploader = uploader; + + // Nothing until Submit is pressed. + expect(component.showSubmitFlow).toBe(false); + + component.uploadStarted = true; + expect(component.showSubmitFlow).toBe(true); + expect(component.flowTitle).toBe('Uploading your work'); + expect(component.flowDetail).toBe('report.pdf'); + expect(component.flowProgress).toBe(40); + expect(component.flowLanded).toBe(false); + + // The bytes are away, but the confirmation has not arrived yet. + uploader.uploadLanded = true; + expect(component.flowLanded).toBe(true); + expect(component.flowTitle).toBe('Uploaded'); + + // The same panel becomes the confirmation. + component.celebration = { + timing: 'on_time', + resubmission: false, + tone: 'success', + particles: true, + headline: 'Submitted on time. Ready for feedback', + detail: '1.1P Hello World', + }; + expect(component.showSubmitFlow).toBe(true); + expect(component.flowTitle).toBe('Submitted on time. Ready for feedback'); + expect(component.flowDetail).toBe('1.1P Hello World'); + expect(component.flowProgress).toBe(100); + }); + + it('hands a failure back to the uploader rather than holding the panel', () => { + (component as unknown as {fileUploader: unknown}).fileUploader = { + uploadProgress: 100, + uploadLanded: false, + uploadingFileLabel: 'report.pdf', + uploadingInfo: {success: false}, + }; + component.uploadStarted = true; + + expect(component.uploadFailed).toBe(true); + expect(component.showSubmitFlow).toBe(false); + }); + + it('drops the requirements callout when one drop zone already says the same thing', () => { + expect(component.showUploadRequirements).toBe(false); + + task.definition.uploadRequirements = [ + {key: 'file0', name: 'Report', type: 'document'}, + {key: 'file1', name: 'Code', type: 'code'}, + ] as never; + expect(component.showUploadRequirements).toBe(true); + }); + + it('shows a status icon only for submission types that are task statuses', () => { + expect(component.statusFor('ready_for_feedback')).toBe('ready_for_feedback'); + expect(component.statusFor('reupload_evidence')).toBeNull(); + expect(component.selectedSubmissionTypeLabel).toBe(''); + }); }); // The guidance and file selection checks for this setup are in the reviewed spec. @@ -262,7 +526,7 @@ describe('UploadSubmissionModalComponent rendered stages', () => { expect(region.textContent).toContain('Submission upload failed'); }); - it('announces a retry and moves focus away from the removed Retry Upload button', async () => { + it('announces a retry and moves focus away from the removed Try again button', async () => { vi.spyOn(XMLHttpRequest.prototype, 'open').mockImplementation(() => {}); vi.spyOn(XMLHttpRequest.prototype, 'setRequestHeader').mockImplementation(() => {}); const send = vi.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(() => {}); @@ -286,9 +550,9 @@ describe('UploadSubmissionModalComponent rendered stages', () => { uploader.onFailure?.({error: 'Demonstration error'}); fixture.detectChanges(); const region = fixture.nativeElement.querySelector('mat-dialog-content > [role="status"]'); - expect(region.textContent).toContain('Retry Upload or Cancel'); + expect(region.textContent).toContain('Try again or Cancel'); const retry = [...fixture.nativeElement.querySelectorAll('button')].find( - (button: HTMLButtonElement) => button.textContent?.trim() === 'Retry Upload', + (button: HTMLButtonElement) => button.textContent?.trim() === 'Try again', ) as HTMLButtonElement; retry.focus(); retry.click(); @@ -297,8 +561,9 @@ describe('UploadSubmissionModalComponent rendered stages', () => { expect(send).toHaveBeenCalledOnce(); expect(region.textContent).toContain('Uploading submission'); expect(retry.isConnected).toBe(false); + // the header is gone while the upload runs, so the panel's button takes focus expect(document.activeElement).toBe( - fixture.nativeElement.querySelector('h2[mat-dialog-title]'), + fixture.nativeElement.querySelector('.submit-flow__action'), ); }); }); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts index 8f0c22bbd2..46a0e76463 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.component.ts @@ -4,6 +4,7 @@ import { ElementRef, Inject, Injector, + OnDestroy, OnInit, ViewChild, afterNextRender, @@ -13,13 +14,30 @@ import {ErrorStateMatcher} from '@angular/material/core'; import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; import {MemberContribution} from 'src/app/api/models/groups/group'; import {Task} from 'src/app/api/models/task'; -import {TaskStatusEnum} from 'src/app/api/models/task-status'; +import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; import {ProjectService} from 'src/app/api/services/project.service'; import {TaskService} from 'src/app/api/services/task.service'; +import {prefersReducedMotion} from 'src/app/common/celebrate/reduced-motion'; +import type {SubmissionCelebration} from 'src/app/common/celebrate/submission-timing'; import {FileUploaderComponent} from 'src/app/common/file-uploader/file-uploader.component'; import {AlertService} from 'src/app/common/services/alert.service'; import {EmojiService} from 'src/app/common/services/emoji.service'; import {PrivacyPolicy} from 'src/app/config/privacy-policy/privacy-policy'; +import {summariseUploadRequirement} from './task-upload-requirements/upload-category'; + +/** + * The confirmation finishes arriving at about 1.6s. This leaves roughly two more + * seconds to take it in before the dialog closes itself, and the Done button is + * there for anyone who would rather not wait. + */ +const SUBMISSION_CELEBRATION_HOLD_MS = 3600; + +/** + * The handover: long enough for the bar to draw into the middle (300ms) and the + * circle to answer it (from 240ms). Matches the "collapse" timing on + * /submit-motion, where the six candidates were compared. + */ +const FLOW_SWAP_MS = 480; type UploadStage = 'group' | 'details' | 'comments'; type UploadSubmissionType = TaskStatusEnum | 'reupload_evidence' | 'test_submission'; @@ -29,6 +47,11 @@ interface UploadSubmissionTypeOption { label: string; } +export interface UploadSubmissionStep { + stage: UploadStage; + label: string; +} + interface UploadSubmissionFileSpec { name: string; type: string; @@ -72,12 +95,13 @@ export type UploadSubmissionModalResult = changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class UploadSubmissionModalComponent implements OnInit { +export class UploadSubmissionModalComponent implements OnInit, OnDestroy { @ViewChild(FileUploaderComponent) private fileUploader?: FileUploaderComponent; @ViewChild('groupHeading') private groupHeading?: ElementRef; @ViewChild('detailsHeading') private detailsHeading?: ElementRef; @ViewChild('commentsHeading') private commentsHeading?: ElementRef; @ViewChild('dialogTitle') private dialogTitle?: ElementRef; + @ViewChild('flowAction', {read: ElementRef}) private flowAction?: ElementRef; private readonly injector = inject(Injector); public readonly minCommentLength = 25; @@ -112,9 +136,18 @@ export class UploadSubmissionModalComponent implements OnInit { this.requiresComment && this.comment.trim().length < this.minCommentLength, }; + /** Set once the submission has landed. It takes over the dialog until it closes. */ + public celebration: SubmissionCelebration | null = null; + /** The handover: the bar collapses and the circle absorbs it. */ + public flowSwapping = false; private uploadResponse: UploadSubmissionResponse | null = null; + /** Guards the status change against running twice, or not at all. */ + private completionApplied = false; private startUpload?: () => void; + private celebrationTimer: ReturnType | null = null; + private swapTimer: ReturnType | null = null; + private closingAfterUpload = false; constructor( @Inject(MAT_DIALOG_DATA) public data: UploadSubmissionModalData, @@ -140,6 +173,16 @@ export class UploadSubmissionModalComponent implements OnInit { return this.fileUploader?.isUploading ?? false; } + /** + * Bytes on the wire, as opposed to a request that has landed and is reporting + * its outcome. `isUploading` covers both, so using it to decide whether the + * dialog may close left it shut after a failed upload: the error panel offers + * Try again and Cancel, but Escape and the backdrop did nothing. + */ + public get uploadInFlight(): boolean { + return this.fileUploader?.uploadInFlight ?? false; + } + public get showGroupSection(): boolean { return this.submissionType === 'ready_for_feedback' && this.task.isGroupTask(); } @@ -190,6 +233,141 @@ export class UploadSubmissionModalComponent implements OnInit { return 'Make a comment...'; } + // ---- The submit panel ---- + // One panel carries the whole thing: the same circle, the same two lines of + // text and the same bar go from sending to sent to confirmed. Nothing is + // unmounted and remounted in between, so there is nothing to read as a new + // screen appearing. + + /** True from the moment Submit is pressed until the dialog closes. */ + public get showSubmitFlow(): boolean { + return this.uploadStarted && !this.uploadFailed; + } + + public get uploadFailed(): boolean { + return this.fileUploader?.uploadingInfo?.success === false; + } + + public get flowProgress(): number { + return this.celebration ? 100 : (this.fileUploader?.uploadProgress ?? 0); + } + + /** The bytes are away. The panel starts becoming the confirmation here. */ + public get flowLanded(): boolean { + return !!this.celebration || this.fileUploader?.uploadLanded === true; + } + + public get flowTitle(): string { + if (this.celebration) { + return this.celebration.headline; + } + return this.flowLanded ? 'Uploaded' : 'Uploading your work'; + } + + public get flowDetail(): string { + return this.celebration?.detail ?? this.fileUploader?.uploadingFileLabel ?? ''; + } + + public cancelUpload(): void { + this.fileUploader?.cancelUpload(); + } + + /** + * The callout above the drop zones only earns its place when it says something + * they do not. A single requirement is already named, typed and stated as + * required by its own zone, so repeating it three ways just adds to the page. + */ + public get showUploadRequirements(): boolean { + const requirements = this.task.definition.uploadRequirements ?? []; + if (requirements.length !== 1) { + return requirements.length > 1; + } + + const summary = summariseUploadRequirement(requirements[0]); + return summary.hasMoreExtensions || summary.maxSizeLabel !== null; + } + + /** The steps this submission goes through, in order. */ + public get steps(): UploadSubmissionStep[] { + const steps: UploadSubmissionStep[] = []; + if (this.showGroupSection) { + steps.push({stage: 'group', label: 'Rate team'}); + } + steps.push({stage: 'details', label: 'Upload files'}); + if (this.showCommentsSection) { + steps.push({ + stage: 'comments', + label: this.submissionType === 'need_help' ? 'Ask for help' : 'Comments', + }); + } + return steps; + } + + public get currentStepIndex(): number { + return Math.max( + 0, + this.steps.findIndex((step) => step.stage === this.currentStage), + ); + } + + /** The task's due or target date, or null when it cannot be worked out. */ + public get dueDate(): Date | null { + try { + const date = this.task.localDueDate?.(); + return date instanceof Date && !Number.isNaN(date.getTime()) ? date : null; + } catch { + return null; + } + } + + public get isPastDue(): boolean { + try { + return this.task.isPastDueDate?.() === true; + } catch { + return false; + } + } + + /** A task status for the icon beside a submission type, when that type is a status. */ + public statusFor(type: UploadSubmissionType): TaskStatusEnum | null { + return TaskStatus.STATUS_KEYS.includes(type as TaskStatusEnum) + ? (type as TaskStatusEnum) + : null; + } + + public get selectedSubmissionTypeLabel(): string { + return ( + this.submissionTypeOptions.find((option) => option.id === this.submissionType)?.label ?? '' + ); + } + + /** Says why the forward button is disabled, or null when it is enabled. */ + public get continueHint(): string | null { + if (this.isGroupStage) { + return this.shouldDisableNext() ? 'Rate a team member to continue' : null; + } + + const forwardDisabled = + this.isDetailsStage && this.showCommentsSection + ? this.shouldDisableNext() + : this.shouldDisableSubmit(); + if (!forwardDisabled) { + return null; + } + + if (!this.isUploaderReady) { + return this.task.definition.uploadRequirements.length > 1 + ? 'Add the required files to continue' + : 'Add the required file to continue'; + } + + if (this.requiresComment && this.comment.trim().length < this.minCommentLength) { + return `Add a comment of at least ${this.minCommentLength} characters`; + } + + return null; + } + public get hasRatedTeamMember(): boolean { return this.team.memberContributions.some((member) => !!member.rating); } @@ -246,8 +424,9 @@ export class UploadSubmissionModalComponent implements OnInit { private focusStageHeading(): void { afterNextRender( () => { + // the header goes once the upload starts, so the panel's one button takes focus const heading = this.uploadStarted - ? this.dialogTitle + ? (this.flowAction ?? this.dialogTitle) : this.isGroupStage ? this.groupHeading : this.isCommentsStage @@ -264,7 +443,19 @@ export class UploadSubmissionModalComponent implements OnInit { }; public canClose(): boolean { - if (this.isUploading) { + // Material runs this for programmatic closes as well as Escape and the + // backdrop, so it decides whether the dialog may close itself. The closes + // this component makes once the server has answered must not be held back + // or asked about. Once the server has taken the submission there is nothing + // left to discard, and a prompt here would both lie to the student and veto + // the dialog's own exit: answering "Cancel" to "discard your files?" would + // keep them in it. + if (this.closingAfterUpload || this.celebration || this.uploadResponse) { + return true; + } + // Only bytes on the wire hold the dialog. A failed response hands control + // back to the student. + if (this.uploadInFlight) { return false; } if (!this.isDirty) { @@ -345,7 +536,7 @@ export class UploadSubmissionModalComponent implements OnInit { } console.error('Invalid response', response); - this.dialogRef.close({value: this.task}); + this.closeAfterUpload(); this.alertService.error( 'Upload failed. Please try again, or contact your tutor if the issue continues.', 8000, @@ -353,8 +544,12 @@ export class UploadSubmissionModalComponent implements OnInit { }; public onUploadFailure = (): void => { + // Only the success path cleared this, so after a failed upload the dialog's + // own Submit stayed locked for good and the uploader's Try again was the + // only way back. + this.uploadSubmitLocked = false; this.uploadAnnouncement = - 'Submission upload failed. Review the error below, then choose Retry Upload or Cancel.'; + 'Submission upload failed. Review the error below, then choose Try again or Cancel.'; }; public onUploadComplete = (): void => { @@ -364,23 +559,110 @@ export class UploadSubmissionModalComponent implements OnInit { return; } - const response = this.uploadResponse; + // The dialog the student started this from is still the thing they are + // looking at, so the confirmation belongs here rather than over the page + // they are about to be returned to. + const celebration = this.applyCompletion(true); + + if (celebration) { + this.showCelebration(celebration); + return; + } + + this.closeAfterUpload(); + }; - if (!this.data.isTestSubmission) { - const expectedStatus = - this.submissionType === 'need_help' || this.submissionType === 'ready_for_feedback' - ? this.submissionType - : response.status; + /** + * Fold the response into the task. Done can be pressed in the second or so + * between the bytes landing and this callback firing, so it runs at most once + * and either caller may be the one to run it. + * + * Leaving early passes `claimCelebration: false`, which hands the moment to + * the dashboard rather than spending it on a dialog that is already closing. + */ + private applyCompletion(claimCelebration: boolean): SubmissionCelebration | null { + if (this.completionApplied || !this.uploadResponse?.id) { + return null; + } + this.completionApplied = true; - this.task.updateFromJson(response, this.taskService.mapping); - this.task.processTaskStatusChange(expectedStatus as TaskStatusEnum, this.alertService, true); + if (this.data.isTestSubmission) { + return null; } + const response = this.uploadResponse; + const expectedStatus = + this.submissionType === 'need_help' || this.submissionType === 'ready_for_feedback' + ? this.submissionType + : response.status; + + this.task.updateFromJson(response, this.taskService.mapping); + return this.task.processTaskStatusChange( + expectedStatus as TaskStatusEnum, + this.alertService, + true, + claimCelebration, + ); + } + + /** Hold long enough for the mark to draw and the words to be read, then leave. */ + private showCelebration(celebration: SubmissionCelebration): void { + this.uploadAnnouncement = celebration.headline; + if (prefersReducedMotion()) { + this.celebration = celebration; + this.celebrationTimer = setTimeout(() => this.finishCelebration(), 900); + return; + } + + // Play the handover before swapping the words. Without the pause the text is + // replaced between two frames, which reads as a cut however well the box + // around it is transitioning. + this.flowSwapping = true; + this.swapTimer = setTimeout(() => { + this.swapTimer = null; + this.celebration = celebration; + this.flowSwapping = false; + this.celebrationTimer = setTimeout( + () => this.finishCelebration(), + SUBMISSION_CELEBRATION_HOLD_MS, + ); + }, FLOW_SWAP_MS); + } + + /** + * Also the Done button, so nobody has to wait out the hold. Done is on screen + * from the moment the upload lands, which can be before the completion + * callback has run, so apply what it would have applied before leaving. + */ + public finishCelebration(): void { + this.clearFlowTimers(); + this.applyCompletion(false); this.dialogRef.close({value: this.task}); - }; + } + + private clearFlowTimers(): void { + if (this.celebrationTimer) { + clearTimeout(this.celebrationTimer); + this.celebrationTimer = null; + } + if (this.swapTimer) { + clearTimeout(this.swapTimer); + this.swapTimer = null; + } + } + + public ngOnDestroy(): void { + this.clearFlowTimers(); + // The dialog can go before the uploader's completion callback arrives: + // Escape or the backdrop on the "Uploaded" panel, or Done during the + // handover. The submission is already on the server either way, so record + // it, and leave the celebration unclaimed for the dashboard to show. Does + // nothing when the response never arrived, or has already been applied. + this.applyCompletion(false); + } public uploadButtonClicked(): void { - if (this.uploadSubmitLocked || this.isUploading) { + if (this.uploadSubmitLocked || this.uploadInFlight) { return; } @@ -390,6 +672,11 @@ export class UploadSubmissionModalComponent implements OnInit { this.startUpload?.(); } + private closeAfterUpload(): void { + this.closingAfterUpload = true; + this.dialogRef.close({value: this.task}); + } + private buildSubmissionTypeOptions(): UploadSubmissionTypeOption[] { if (this.data.isTestSubmission) { return [{id: 'test_submission', label: 'Test Submission'}]; @@ -414,6 +701,7 @@ export class UploadSubmissionModalComponent implements OnInit { this.uploadAnnouncement = ''; this.uploadSubmitLocked = false; this.uploadResponse = null; + this.completionApplied = false; this.currentStage = this.showGroupSection ? 'group' : 'details'; } diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.component.ts b/src/app/tasks/task-comment-composer/task-comment-composer.component.ts index ec7fbbf76d..348a10165a 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.component.ts +++ b/src/app/tasks/task-comment-composer/task-comment-composer.component.ts @@ -51,6 +51,21 @@ interface ApiError { status?: number; } +/** + * The draft a Send or Save was pressed on. The inbox and the project dashboard + * reuse one composer while the open task changes, so a response can arrive after + * another task is showing. It has to land on this draft, not on the open one. + */ +interface PendingSend { + task: Task; + context: FeedbackDraftContext | null; + key: string | null; + originalComment: TaskComment | null; + // The text in the field when this draft stopped showing. Once the composer is + // rebuilt, the stored draft belongs to the new one and may hold newer words. + text: string | null; +} + /** * The task comment viewer needs to share data with the Task Comment Composer. The data needed * id defined through this interface. @@ -97,10 +112,17 @@ export class TaskCommentComposerComponent private submittedTaskIds: Set = new Set(); public isSending: boolean = false; + // Drafts with a request still in flight, so returning to one shows it as sending. + private readonly sendingDraftKeys: Set = new Set(); + private destroyed = false; + private readonly inFlightSends: Set = new Set(); public stagedAttachments: StagedFeedbackAttachment[] = []; private draftClientRequestId: string | null = null; + private draftClientRequestFingerprint: string | null = null; private draftReplyToId: number | null = null; private draftBeforeEdit: string = ''; + // The comment whose text is in the field, or null when the field holds the draft. + private editedComment: TaskComment | null = null; comment = { text: '', @@ -204,9 +226,16 @@ export class TaskCommentComposerComponent const newTask = changes.task.currentValue as Task; const previousTask = changes.task.previousValue as Task; if (previousTask) { + const previousText = + this.editedComment !== null ? this.draftBeforeEdit : this.currentInputText; + const previousKey = this.getDraftKey(previousTask); + this.keepTextOfSends( + (send) => send.task === previousTask || (send.key !== null && send.key === previousKey), + previousText, + ); this.saveDraftForTask( previousTask, - this.currentInputText, + previousText, this.sharedData?.originalComment?.id ?? this.draftReplyToId, ); } @@ -217,8 +246,10 @@ export class TaskCommentComposerComponent this.sharedData.originalComment = null; this.stagedAttachments = []; this.draftClientRequestId = null; + this.draftClientRequestFingerprint = null; this.draftReplyToId = null; this.clearInput(); + this.isSending = this.sendingDraftKeys.has(this.getDraftKey(newTask) ?? ''); if (newTask) { this.loadDraftForTask(newTask); @@ -236,7 +267,12 @@ export class TaskCommentComposerComponent } ngOnDestroy(): void { + this.keepTextOfSends( + (send) => this.isShowing(send), + this.editedComment !== null ? this.draftBeforeEdit : this.currentInputText, + ); this.saveCurrentDraft(); + this.destroyed = true; this.cancelDraftLoadTimers(); this.draftLoadGeneration += 1; this.routeSubscription.unsubscribe(); @@ -345,13 +381,22 @@ export class TaskCommentComposerComponent try { let raw: string; - if (this.task?.id === task.id && this.input?.first) { + if (_rawFromDom === undefined && this.editedComment !== null) { + // While editing, the field holds the comment being edited and the draft is set aside. + raw = this.draftBeforeEdit; + } else if (this.task?.id === task.id && this.input?.first) { raw = _rawFromDom ?? this.input.first.nativeElement.value; } else { raw = _rawFromDom ?? ''; } - this.draftStore.save(context, raw, replyToId, this.draftClientRequestId); + this.draftStore.save( + context, + raw, + replyToId, + this.draftClientRequestId, + this.draftClientRequestFingerprint, + ); } catch (error) { console.error('saveDraftForTask error:', error); } @@ -372,6 +417,7 @@ export class TaskCommentComposerComponent const draft = this.draftStore.load(context); this.stagedAttachments = this.draftStore.attachments(context); this.draftClientRequestId = draft.clientRequestId; + this.draftClientRequestFingerprint = draft.clientRequestFingerprint; this.draftReplyToId = draft.replyToId; if (!draft.text && draft.replyToId === null && this.stagedAttachments.length === 0) { @@ -699,57 +745,152 @@ export class TaskCommentComposerComponent if (this.isSending) { return; } - const originalComment = this.sharedData.originalComment; + const send = this.pendingSend(); if (this.stagedAttachments.length > 0) { - this.uploadAttachmentQueue([...this.stagedAttachments], 0, originalComment); + this.uploadAttachmentQueue(send, [...this.stagedAttachments], 0); return; } - this.postDraftText(originalComment); + this.postDraftText(send); + } + + private pendingSend(): PendingSend { + const context = this.draftContext(this.task); + return { + task: this.task, + context, + key: context ? this.draftStore.key(context) : null, + originalComment: this.sharedData.originalComment, + text: null, + }; + } + + private keepTextOfSends(leaving: (send: PendingSend) => boolean, text: string): void { + for (const send of this.inFlightSends) { + if (send.text === null && leaving(send)) { + send.text = text; + } + } + } + + // True while the composer still shows the draft this send was pressed on. + private isShowing(send: PendingSend): boolean { + if (this.destroyed) { + return false; + } + return ( + this.task === send.task || (send.key !== null && this.getDraftKey(this.task) === send.key) + ); } - private postDraftText(originalComment: TaskComment | null): void { - if (!this.hasContent(this.currentInputText)) { - this.finishSuccessfulDraft(); + private setSending(send: PendingSend, sending: boolean): void { + if (sending) { + this.inFlightSends.add(send); + } else { + this.inFlightSends.delete(send); + } + if (send.key !== null) { + if (sending) { + this.sendingDraftKeys.add(send.key); + } else { + this.sendingDraftKeys.delete(send.key); + } + } + if (this.isShowing(send)) { + this.isSending = sending; + } + } + + private attachmentsFor(send: PendingSend): StagedFeedbackAttachment[] { + if (send.context) { + return this.draftStore.attachments(send.context); + } + return this.isShowing(send) ? this.stagedAttachments : []; + } + + private postDraftText(send: PendingSend): void { + const showing = this.isShowing(send); + // Once another task is open, the field belongs to that task. This draft's text + // was saved when the task changed, so post that. + const stored = showing || !send.context ? null : this.draftStore.load(send.context); + const raw = showing ? this.currentInputText : (send.text ?? stored?.text ?? ''); + if (!this.hasContent(raw)) { + this.finishSuccessfulDraft(send, raw); return; } - this.isSending = true; - this.draftClientRequestId ??= this.newClientRequestId(); - this.saveCurrentDraft(); + this.setSending(send, true); + const text = this.emojiService.nativeEmojiToColons(raw); + // The API answers a repeated request id with the comment it already stored and + // ignores the new text. Reuse an id only for the words and reply it was issued + // for, so a retry after a lost response is safe but a corrected message is not + // silently dropped. + const fingerprint = JSON.stringify([send.originalComment?.id ?? null, text]); + let clientRequestId: string; + if (stored && send.context) { + clientRequestId = + stored.clientRequestId && stored.clientRequestFingerprint === fingerprint + ? stored.clientRequestId + : this.newClientRequestId(); + // Only record the id against the stored draft when it still holds these words. + if (stored.text === raw) { + this.draftStore.save( + send.context, + stored.text, + stored.replyToId, + clientRequestId, + fingerprint, + ); + } + } else { + if (!this.draftClientRequestId || this.draftClientRequestFingerprint !== fingerprint) { + this.draftClientRequestId = this.newClientRequestId(); + this.draftClientRequestFingerprint = fingerprint; + } + clientRequestId = this.draftClientRequestId; + this.saveCurrentDraft(); + } - const text = this.emojiService.nativeEmojiToColons(this.currentInputText); this.taskCommentService - .addComment(this.task, text, 'text', originalComment, undefined, this.draftClientRequestId) + .addComment(send.task, text, 'text', send.originalComment, undefined, clientRequestId) .subscribe({ next: (_tc: TaskComment) => { - this.finishSuccessfulDraft(); + this.finishSuccessfulDraft(send, raw); }, error: (error: ApiError) => { - this.isSending = false; + this.setSending(send, false); this.alerts.error(this.uploadErrorMessage(error, 'Failed to send this message.'), 6000); }, }); } private uploadAttachmentQueue( + send: PendingSend, queue: StagedFeedbackAttachment[], index: number, - originalComment: TaskComment | null, ): void { if (index >= queue.length) { - this.isSending = false; - if (this.stagedAttachments.length === 0) { - this.postDraftText(originalComment); + this.setSending(send, false); + if (this.attachmentsFor(send).length === 0) { + this.postDraftText(send); } else { this.alerts.error('Some attachments could not be sent. Remove them or retry.', 6000); } return; } - this.isSending = true; - const attachment = queue[index]; - this.updateStagedAttachment(attachment.clientRequestId, { + // The queue is a copy taken when Send was pressed. Upload the live item so a file + // removed while earlier ones were uploading is skipped. + const attachment = this.attachmentsFor(send).find( + (item) => item.clientRequestId === queue[index].clientRequestId, + ); + if (!attachment) { + this.uploadAttachmentQueue(send, queue, index + 1); + return; + } + + this.setSending(send, true); + this.updateStagedAttachment(send, attachment.clientRequestId, { status: 'uploading', progress: 0, error: undefined, @@ -757,50 +898,59 @@ export class TaskCommentComposerComponent this.taskCommentService .uploadStagedAttachment( - this.task, + send.task, attachment.data, attachment.fileName, '', - originalComment, + send.originalComment, attachment.clientRequestId, ) .subscribe({ next: (state) => { - this.updateStagedAttachment(attachment.clientRequestId, { + this.updateStagedAttachment(send, attachment.clientRequestId, { status: 'uploading', progress: state.progress, }); if (state.state === 'complete') { - this.removeStagedAttachment(attachment.clientRequestId, false, true); + this.removeSentAttachment(send, attachment.clientRequestId); } }, error: (error: ApiError) => { - this.updateStagedAttachment(attachment.clientRequestId, { + this.updateStagedAttachment(send, attachment.clientRequestId, { status: 'failed', error: this.uploadErrorMessage(error, 'Upload failed. Retry or remove this file.'), }); - this.uploadAttachmentQueue(queue, index + 1, originalComment); + this.uploadAttachmentQueue(send, queue, index + 1); }, - complete: () => this.uploadAttachmentQueue(queue, index + 1, originalComment), + complete: () => this.uploadAttachmentQueue(send, queue, index + 1), }); } - private finishSuccessfulDraft(): void { - this.isSending = false; - const taskKey = - this.task.id || `${this.task.projectId || this.task.project?.id}_${this.task.definition?.id}`; + private finishSuccessfulDraft(send: PendingSend, sentText?: string): void { + this.setSending(send, false); + const task = send.task; + const taskKey = task.id || `${task.projectId || task.project?.id}_${task.definition?.id}`; this.submittedTaskIds.add(taskKey); const submittedKey = this.submittedKey(); if (submittedKey) { sessionStorage.setItem(submittedKey, JSON.stringify([...this.submittedTaskIds])); } - const context = this.draftContext(this.task); - if (context) { - this.draftStore.clear(context); + if (send.context) { + const showing = this.isShowing(send); + const stored = showing ? null : this.draftStore.load(send.context); + // A rebuilt composer may have saved newer words for this task since Send was + // pressed. Those were never sent, so leave them. + if (showing || sentText === undefined || stored.text === sentText) { + this.draftStore.clear(send.context); + } + } + if (!this.isShowing(send)) { + return; } this.stagedAttachments = []; this.draftClientRequestId = null; + this.draftClientRequestFingerprint = null; this.draftReplyToId = null; this.sharedData.originalComment = null; this.clearInput(); @@ -814,20 +964,26 @@ export class TaskCommentComposerComponent return; } - this.isSending = true; + const send = this.pendingSend(); + const editing = this.editingComment; + this.setSending(send, true); const text = this.emojiService.nativeEmojiToColons(this.currentInputText); - this.taskCommentService.editComment(this.editingComment, text).subscribe({ + this.taskCommentService.editComment(editing, text).subscribe({ next: (_tc: TaskComment) => { - this.isSending = false; + this.setSending(send, false); + // Opening another task or cancelling ends the edit and puts that draft back + // before this lands, so there is nothing left here to clear. + if (!this.isShowing(send) || this.editingComment !== editing) { + return; + } this.sharedData.editingComment = null; - this.draftBeforeEdit = ''; this.emojiSearchMode = false; this.dismissEmojiPicker(); - this.clearInput(); + this.restoreDraftAfterEdit(); }, error: (error: ApiError) => { - this.isSending = false; + this.setSending(send, false); this.alerts.error(this.uploadErrorMessage(error, 'Failed to edit this comment.'), 6000); }, }); @@ -952,11 +1108,7 @@ export class TaskCommentComposerComponent this.saveCurrentDraft(); } - removeStagedAttachment( - clientRequestId: string, - save: boolean = true, - allowUploading: boolean = false, - ): void { + removeStagedAttachment(clientRequestId: string, save: boolean = true): void { const context = this.draftContext(this.task); if (!context) { return; @@ -964,7 +1116,7 @@ export class TaskCommentComposerComponent const attachment = this.stagedAttachments.find( (item) => item.clientRequestId === clientRequestId, ); - if (attachment?.status === 'uploading' && !allowUploading) { + if (attachment?.status === 'uploading') { return; } this.draftStore.removeAttachment(context, clientRequestId); @@ -974,6 +1126,16 @@ export class TaskCommentComposerComponent } } + private removeSentAttachment(send: PendingSend, clientRequestId: string): void { + if (!send.context) { + return; + } + this.draftStore.removeAttachment(send.context, clientRequestId); + if (this.isShowing(send)) { + this.stagedAttachments = this.draftStore.attachments(send.context); + } + } + retryStagedAttachment(clientRequestId: string): void { if (this.isSending) { return; @@ -982,7 +1144,7 @@ export class TaskCommentComposerComponent (item) => item.clientRequestId === clientRequestId, ); if (attachment) { - this.uploadAttachmentQueue([attachment], 0, this.originalComment); + this.uploadAttachmentQueue(this.pendingSend(), [attachment], 0); } } @@ -993,6 +1155,7 @@ export class TaskCommentComposerComponent } this.stagedAttachments = []; this.draftClientRequestId = null; + this.draftClientRequestFingerprint = null; this.draftReplyToId = null; this.sharedData.originalComment = null; this.clearInput(); @@ -1026,16 +1189,18 @@ export class TaskCommentComposerComponent } private updateStagedAttachment( + send: PendingSend, clientRequestId: string, update: Partial, ): void { - const context = this.draftContext(this.task); - if (!context) { + if (!send.context) { return; } - this.draftStore.updateAttachment(context, clientRequestId, update); - this.stagedAttachments = this.draftStore.attachments(context); - this.cdRef.detectChanges(); + this.draftStore.updateAttachment(send.context, clientRequestId, update); + if (this.isShowing(send)) { + this.stagedAttachments = this.draftStore.attachments(send.context); + this.cdRef.detectChanges(); + } } // The policy decides which formats and sizes are allowed. The extra DOCX and PDF @@ -1167,6 +1332,10 @@ export class TaskCommentComposerComponent return; } + // Reply ends an edit without cancelling it, so the draft has to come back first. + if (this.editedComment !== null) { + this.restoreDraftAfterEdit(); + } this.draftReplyToId = this.originalComment?.id ?? null; this.saveCurrentDraft(); setTimeout(() => { @@ -1175,16 +1344,20 @@ export class TaskCommentComposerComponent } private beginEditingComment() { - const currentText = this.currentInputText; - const nextText = this.editingComment?.text ?? ''; + const editing = this.editingComment; if (this.sharedData.originalComment != null) { this.sharedData.originalComment = null; } - if (currentText !== nextText) { - this.draftBeforeEdit = currentText; - this.setComposerText(nextText); + if (this.editedComment !== editing) { + // Set the draft aside only when editing starts. Moving on to edit another + // comment must not replace it with the first comment's text. + if (this.editedComment === null) { + this.draftBeforeEdit = this.currentInputText; + } + this.editedComment = editing; + this.setComposerText(editing?.text ?? ''); } setTimeout(() => { @@ -1195,6 +1368,7 @@ export class TaskCommentComposerComponent private restoreDraftAfterEdit() { const draft = this.draftBeforeEdit; this.draftBeforeEdit = ''; + this.editedComment = null; this.setComposerText(draft); } diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts b/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts index 6cd9419c55..fc66c160a2 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts +++ b/src/app/tasks/task-comment-composer/task-comment-composer.touch-targets.spec.ts @@ -126,6 +126,40 @@ describe('TaskCommentComposerComponent phone actions', () => { fixture.detectChanges(); }; + // jsdom has no implicit submission, so this does what a browser does when Enter + // is pressed in a single-line field: click the form's default button if it has + // one, otherwise submit only when no other field blocks it. + const pressEnterIn = (field: HTMLInputElement): void => { + const enter = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: 'Enter'}); + field.dispatchEvent(enter); + const form = field.form; + if (enter.defaultPrevented || !form) { + return; + } + + const controls = Array.from(form.elements); + const defaultButton = controls.find( + (control) => + (control instanceof HTMLButtonElement || control instanceof HTMLInputElement) && + control.type === 'submit', + ) as HTMLButtonElement | HTMLInputElement | undefined; + if (defaultButton) { + if (!defaultButton.disabled) { + defaultButton.click(); + } + return; + } + + const blockingFields = controls.filter( + (control) => + control instanceof HTMLInputElement && + ['text', 'search', 'url', 'tel', 'email', 'password', 'number'].includes(control.type), + ); + if (blockingFields.length === 1) { + form.requestSubmit(); + } + }; + it('keeps clear accessible names on the attachment and microphone controls', () => { const attach = fixture.nativeElement.querySelector( 'button[aria-label="Attach a file"]', @@ -184,6 +218,39 @@ describe('TaskCommentComposerComponent phone actions', () => { expect(taskCommentService.addComment).not.toHaveBeenCalled(); }); + it.each([ + ['emoji search', '#feedback-emoji-picker', 'search'], + ['feedback template search', 'f-task-feedback-templates', 'text'], + ])( + 'does not send a half-written message on Enter in the %s box', + (_name, hostSelector, inputType) => { + enterText('Half-written feedback'); + const host = fixture.nativeElement.querySelector(hostSelector) as HTMLElement; + // Stands in for the search field the picker renders inside the composer form. + const search = document.createElement('input'); + search.type = inputType; + host.appendChild(search); + + pressEnterIn(search); + fixture.detectChanges(); + + expect(taskCommentService.addComment).not.toHaveBeenCalled(); + expect(textarea().value).toBe('Half-written feedback'); + }, + ); + + it('does not send when a button without a type inside the composer is clicked', () => { + enterText('Half-written feedback'); + // The audio recorder renders its start, stop and play buttons with no type. + const recorderButton = document.createElement('button'); + fixture.nativeElement.querySelector('#textFieldContainer').appendChild(recorderButton); + + recorderButton.click(); + fixture.detectChanges(); + + expect(taskCommentService.addComment).not.toHaveBeenCalled(); + }); + it('prevents whitespace sends and sends non-empty text only from the explicit action', () => { enterText(' \n '); expect(sendButton().disabled).toBe(true);
    Student - {{ project.student.studentId || project.student.username }} + + Name + Name - {{ project.student.name }} + + Student ID + {{ project.student?.studentId || project.student?.username }} Tutor - {{ project.tutorNames() }} + + @if (project.tutorNames()) { + {{ project.tutorNames() }} + } @else { + None + } Tutorial + {{ project.shortTutorialDescription() }} Target Submitted asApplied for - + @if (hasSubmittedGrade(project)) { + + } @else { + Not chosen + } Submission Date - {{ project.portfolioSubmissionDate | date: 'EEE d MMM y, h:mm a' }} + + Submitted + @if (project.portfolioSubmissionDate) { + {{ project.portfolioSubmissionDate | date: 'd MMM y, h:mm a' }} + } @else { + Not submitted + } Has PortfolioHas portfolio {{ project.hasPortfolio ? 'Yes' : 'No' }} StatsProgress -
    - @for (bar of project.taskStats; track bar) { -
    - @if (bar.key === 'not_started') { - {{ bar.value }}% - } - @if (bar.key === 'complete') { - {{ bar.value }}% - } -
    - } + @let progress = progressFor(project); +
    + +
    Grade - {{ project.grade }} + + @if (isGraded(project)) { + {{ project.grade }} + } @else { + Not graded + } View + Student dashboard + -