diff --git a/.gitignore b/.gitignore index 9912f41615..a125dafc9f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .DS_Store build/ dist/ +coverage/ bin/ node_modules/ vendor/ @@ -13,6 +14,7 @@ vendor/ tmp.scss .tscache a.env +.env dist/ .angulardoc.json .nx diff --git a/.prettierignore b/.prettierignore index d541041bd2..2b82fb1787 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,9 +7,5 @@ e2e CHANGELOG.md JPlag-Report-Viewer/ .github/ -karma/ -Gruntfile.js -karma.conf.js polyfills.ts -test.ts main.ts diff --git a/angular.json b/angular.json index 029aa7eafc..6c5694d24c 100644 --- a/angular.json +++ b/angular.json @@ -79,7 +79,7 @@ } ], "optimization": true, - "outputHashing": "bundles", + "outputHashing": "all", "sourceMap": false, "extractLicenses": true, "serviceWorker": "ngsw-config.json" @@ -87,7 +87,8 @@ "development": { "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "serviceWorker": "ngsw-config.json" }, "testing": { "aot": false, diff --git a/docs/ISSUE-1290-testing-strategy.md b/docs/ISSUE-1290-testing-strategy.md new file mode 100644 index 0000000000..c28b993de1 --- /dev/null +++ b/docs/ISSUE-1290-testing-strategy.md @@ -0,0 +1,130 @@ +# Vitest coverage and next integration checks (#1290) + +This records the September 2026 test audit for +[doubtfire-web #1290](https://github.com/doubtfire-lms/doubtfire-web/issues/1290). +The application uses Angular's unit-test builder with Vitest, a jsdom environment, +and the setup file in `src/vitest-setup.ts`. The normal pull request suite does not +require a running API, browser installation, production account, or secrets. + +## Running the checks + +Use the Node version in `.nvmrc` (Node 22.22.3 or later in the supported Node 22 +line), then run: + +```sh +npm ci +npm run test:ci +npm run build +``` + +For a focused iteration, keep Angular's compilation step and select the spec: + +```sh +npm run test:ci -- --include='src/app/common/file-downloader/file-downloader.service.spec.ts' +``` + +Run the whole suite before opening a PR. A focused run cannot detect interactions +with module registration, template compilation, or the rest of the application's +shared fixtures. Existing GitHub `Test CI` and `Node.js CI` workflows run the full +unit suite and application build on pull requests. + +## Recorded validation + +On the FL-22 / #1290 branch based on `11.0.x` commit `a35f2826d`, a clean +`npm ci`, `npm run build`, and the full `npm run test:ci` passed with Node 22.23.2. +The suite contains 146 files and 1107 passing tests. Targeted ESLint also passed. +The runner reports an existing nested `vi.mock` warning in the PDF viewer spec; +it is not a test failure. These results cover this branch, not future merges or +the longer-term browser/API jobs below. + +## Gaps addressed + +The audit found 143 existing spec files under `src/app`. The new tests focus on +observable behaviour in shared code that previously had no dedicated tests: + +| Area | Regression checks | Defect caught | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| File downloader | Complete and multipart responses, requested byte bounds, missing/malformed ranges, gaps/duplicates, changing totals, interrupted transfers, and a server ignoring Range | Invalid ranges could leave the caller waiting or publish corrupt data; continuation end indexes were one too large; a full response was appended to already received bytes | +| Authentication interceptor | API and LTI credentials, anonymous requests, external/static requests, preservation of caller headers and immutable requests | These cases now protect the shared request boundary | +| Authentication return URL | Blocked browser storage, malformed persisted entries, future timestamps, exact expiry boundary, and one-use consumption | Extends existing unsafe URL, SSO, logout, and expiry checks | + +The downloader tests use `HttpTestingController` to assert the real `HttpClient` +request and response contract, without reaching an actual server. They verify the +assembled Blob contents rather than only checking that a callback ran. Every +case verifies no unmatched requests remain. The auth tests use harmless fixture +tokens and exercise the interceptor/service's public methods. + +## Remaining coverage to prioritise + +This is a risk-based audit, not a claim of complete statement or branch coverage. +Add tests with each change rather than treating creation-only specs as evidence +that every interaction works. + +1. **Authentication refresh concurrency:** several simultaneous expired requests, + failed refresh releasing every waiting subscriber, retry headers, and logout + while a refresh is in flight. `HttpErrorInterceptor` currently has error-body + extraction coverage but needs these stateful scenarios. +2. **Submission state transitions:** combine the upload modal, processing/retry API + response, and selected task route to show that failed uploads retain the user's + context and a successful upload returns to the intended dashboard. +3. **Staff workflows:** task-sheet navigation, tutorial changes, bulk portfolio + operations, and permission refusals. Prefer DOM events and rendered state over + direct calls to private component methods. +4. **Real browser behaviours:** focus restoration, drag/drop, recorded audio, + actual CSS breakpoints, Blob downloads, and service workers. jsdom does not + establish visual layout, accessibility, or browser permission behaviour. +5. **API compatibility:** serialization of deadlines, extensions and upload errors + across the web/API boundary. A frontend mock cannot prove the server emits the + expected data or enforces permissions. + +## Longer-term API test approach + +Keep deterministic frontend contract tests in the normal Vitest suite using +[`provideHttpClientTesting` and `HttpTestingController`](https://angular.dev/guide/http/testing). +Assert request methods, authorization conditions, query/body formats, and error +responses; do not simply return whatever object the component expects. Keep +fixture payloads near the consuming feature and link the corresponding API +endpoint/regression test in its PR. + +Add a separate integration CI job for contracts that require the real API. Boot +pinned web/API/deploy revisions against an isolated disposable database, run +migrations and a small deterministic seed, wait for readiness, then authenticate +through the same endpoints used by the client. Use two fixture students and a +staff member in distinct units to test both allowed requests and cross-user +refusals. Run API model/request tests in the API repository as well; frontend +integration checks supplement them. Tear down the database after each job and +avoid production credentials or public notification delivery. + +Start with three contracts: loading a student's enrolled units; reading the same +task's actual effective deadline; submitting a small fixture file and polling its +processing status. Add an explicit assertion for each relevant 4xx error. This +job should have its own failure report so an API startup failure is not presented +as a frontend assertion failure. + +## Longer-term headless browser approach + +Use two separate layers with a documented purpose: + +- **Browser component tests:** Angular supports + [Vitest browser mode](https://angular.dev/guide/testing) for interactions requiring + a real DOM/browser. Pilot focus and download behaviours before moving large + numbers of otherwise fast jsdom tests. +- **Student journey tests:** a Playwright suite against the built application. + Its [`webServer` configuration](https://playwright.dev/docs/test-webserver) can + manage an isolated local server. For fast PR checks, + [route mocks](https://playwright.dev/docs/network) should cover login, loading a + unit, selecting a task, submitting a file, and the resulting dashboard state. + Add failures for rejected credentials, unavailable units, and failed uploads. + Start with Chromium, then add Firefox/WebKit for browser-specific controls. + +The real API job should reuse the same journeys without route mocks, against the +seeded stack. Keep mocked and real API runs explicitly named; passing a mocked +journey does not prove server compatibility. Use role/name locators and web-first +assertions rather than fixed sleeps. Store a trace/screenshot only on failure, +redact test authorization headers, and keep fixture users' data synthetic. A +service-worker-specific test must run separately from tests that block service +workers for reliable network interception. + +These longer-term jobs are an investigated implementation plan, not new CI +requirements in this PR. They need their own pinned runtime fixtures and review +before becoming required checks. diff --git a/docs/cross-project-ui.md b/docs/cross-project-ui.md new file mode 100644 index 0000000000..f71bcf92e6 --- /dev/null +++ b/docs/cross-project-ui.md @@ -0,0 +1,68 @@ +# Cross-project UI handover + +Tickets: CPD-FB02, CPD-FB03, CPD-UI03–06, PR-XU-12, PR-XU-13 and PR-XU-20. +The API's projects logging and access-control tests are maintained separately. +Calendar features are unchanged. + +## Feedback and filtering + +`has_feedback` maps to `Task.hasFeedback`. `true` means staff feedback exists; +`false` means none exists; missing metadata stays unavailable. No feedback body, +marker note or additional student identity is fetched for a card. The unread +comment count is separate: it cannot establish whether staff feedback is new. +The UI therefore labels it **unread comments**, never **new feedback**. + +The Staff feedback selector combines with status, grade, date range and the +existing global/per-unit searches. Search includes the displayed phrases +`staff feedback available`, `no staff feedback`, `feedback unavailable`, and +`unread comments` where applicable. Searching just `feedback` can match any of +these states; use the selector to ask specifically for available feedback. +Clear all restores all feedback states while preserving per-unit controls. + +The Staff feedback action navigates to +`/projects/:projectId/dashboard/:abbreviation/feedback`, which opens the existing +feedback pane on narrow screens even after comments have been read. The normal +task title links to task details. Neither route bypasses API authorisation. + +## Layout, controls and recovery + +The grid wraps cards in DOM order using a minimum bounded by the available width. +Project name, task completion count, deadline warning and project link remain +available in phone summaries. Comfortable/compact density changes spacing, +not data. Long names wrap. Task controls and focus outlines retain their size. + +The local `panelState` template accepts `title`, `message` and optional `busy`. +It gives each panel a named section, minimum height and polite status text. +Use plain messages without response bodies or stack traces. Active units, +previous units and recommended order have separate busy/error state. Failed +refreshes retain the last successful data; initial empty results get explicit +messages. Refresh buttons remain mounted and keyboard-focusable while busy, +use `aria-disabled`, and guard repeat activation in their handlers. + +Active projects initially come from the application's global loading contract. +The dashboard does not replace the app's sign-in/bootstrap error handling. +Previous projects use a separate cache so loading history cannot contaminate +the active project cache. All component-owned requests stop on destruction. + +Filter/sort menus use native buttons with checked states. The result count is +announced in one polite live region; input focus remains on the control that +changed the results. Phone disclosures retain their toggle and `aria-expanded`. +Count badges use the existing `#3939ff` token with white text (6.56:1 contrast), +and explicitly name the count and task. Unread comments are not exposed as an +unexplained digit. + +## Validation + +Use synthetic projects for browser checks at 320, 390, 768, 1024 and 1440 px, +in both density modes, with long names and enough tasks to scroll. Exercise +available/no/unavailable feedback, no matches, empty units, previous-unit +failure/retry, and a failed refresh with existing data. Tab through every +control; open filters with the keyboard; verify focus survives refresh. At +200% zoom check wrapping and the continued availability of the project link. + +The focused component tests cover combined filters, missing metadata, actual +task/feedback hrefs, stale data and recovery, duplicate refresh suppression, +focus retention, counts and density. Route tests cover student access and +scope restoration. Browser, test and build results for this change are recorded +in the [validation record](evidence/cross-project-ui/README.md) and PR. Real screen-reader sign-off remains +a reviewer activity; automated accessible-name assertions do not claim it. diff --git a/docs/cross-unit-dashboard.md b/docs/cross-unit-dashboard.md new file mode 100644 index 0000000000..9648e4f317 --- /dev/null +++ b/docs/cross-unit-dashboard.md @@ -0,0 +1,36 @@ +# Cross-project dashboard + +The student-only `/dashboard` route shows authorised tasks across active and +previous units. It supports combined unit/task search, status, grade, date and +staff-feedback filters, task ordering, and direct project/task/feedback links. + +The dashboard was ported from the old 10.x cross-unit branch onto 11.0.x and +translated from UI-Router to Angular Router. It is now implemented and tested +on `11.0.x`; the original root-level skeleton status is retired. + +## Sources and verification + +- `src/app/dashboard/f-cross-dashboard.component.{ts,html,scss}` owns mapping, + filters, grid, density and panel states. +- `src/app/dashboard/list-item/` owns task rows and expanded actions. +- `src/app/app.routes.ts` owns student route access and feedback destinations. +- `src/app/projects/states/index/global-state.service.ts` loads active projects; + `ProjectService` requests previous units with task definitions in a separate cache. +- The API's `GET /projects` scopes project/task metadata to the authenticated user. + +Run `npm run test:ci -- --include='src/app/dashboard/**/*.spec.ts'` using the +repository's Angular Vitest builder. See [UI handover](cross-project-ui.md) for +current validation results, state behaviour and feedback semantics. Review +[privacy-safe evidence guidance](CPD-Q06-privacy-safe-screenshot-checklist.md) +before capturing examples. + +## Sample data + +These values are entirely synthetic and are intended only as a visual reference for dashboard +mockups. They are not an API schema or an automated-test fixture. Dates use `DD/MM/YYYY`. + +| Unit code | Example task due date | Unit completion | Example task status | +| --------- | --------------------- | --------------- | ------------------- | +| COS10001 | 17/10/2026 | 10% | `not_started` | +| COS20007 | 19/08/2026 | 100% | `complete` | +| COS30046 | 25/08/2026 | 90% | `working_on_it` | diff --git a/src/app/account/edit-profile/edit-profile.component.html b/src/app/account/edit-profile/edit-profile.component.html index 43c2d6788c..6fb40b647e 100644 --- a/src/app/account/edit-profile/edit-profile.component.html +++ b/src/app/account/edit-profile/edit-profile.component.html @@ -1,9 +1,13 @@ -
-
-
+
+
+
@if (!loading) { }
-
+
diff --git a/src/app/account/edit-profile/edit-profile.component.scss b/src/app/account/edit-profile/edit-profile.component.scss index 627d96752e..88592487c1 100644 --- a/src/app/account/edit-profile/edit-profile.component.scss +++ b/src/app/account/edit-profile/edit-profile.component.scss @@ -1,7 +1,13 @@ #parent { - margin-top: 30px; + margin: 0 auto; + min-width: 0; + padding-block: clamp(12px, 3vw, 30px); + width: 100%; } .form-container { + min-width: 0; + width: 100%; max-width: 800px; + padding-inline: 1rem; } diff --git a/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.html b/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.html index 7d53f9eb83..68267e0dca 100644 --- a/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.html +++ b/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.html @@ -7,30 +7,18 @@

Activities

@if (loadingActivities) {
-
- @for (width of ['30%', '22%', '4%']; track $index) { - - } -
- - @for (row of skeletonRows; track row) { -
- - - -
- } + +
} @@ -125,7 +113,7 @@

Activities

-
@@ -134,6 +122,15 @@

Activities

+ + + + +
diff --git a/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.ts b/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.ts index 87f9b7e71c..f2fc05fa45 100644 --- a/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.ts +++ b/src/app/admin/institution-settings/activity-type-list/activity-type-list.component.ts @@ -5,6 +5,7 @@ import {MatTable, MatTableDataSource} from '@angular/material/table'; import {finalize} from 'rxjs'; import {ActivityType, ActivityTypeService} from 'src/app/api/models/doubtfire-model'; import {EntityFormComponent} from 'src/app/common/entity-form/entity-form.component'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; import {AlertService} from 'src/app/common/services/alert.service'; @Component({ @@ -26,13 +27,13 @@ export class ActivityTypeListComponent activityTypes: ActivityType[] = new Array(); dataSource = new MatTableDataSource(this.activityTypes); loadingActivities = true; - skeletonRows = Array.from({length: 3}, (_, index) => index); // Calls the parent's constructor, passing in an object // that maps all of the form controls that this form consists of. constructor( private activityTypeService: ActivityTypeService, private alertService: AlertService, + private confirmationModal: ConfirmationModalService, ) { super( { @@ -84,14 +85,22 @@ export class ActivityTypeListComponent } deleteActivity(activity: ActivityType) { - this.delete(activity, this.activityTypes, this.activityTypeService).subscribe({ - next: () => { - this.alertService.success(`${activity.name} has been deleted.`, 2000); + this.confirmationModal.show( + `Delete activity type ${activity.name}`, + 'This activity type is used by tutorial streams across every unit. Deleting it cannot be undone.', + () => { + this.delete(activity, this.activityTypes, this.activityTypeService).subscribe({ + next: () => { + this.alertService.success(`${activity.name} has been deleted.`, 2000); + }, + error: (response) => { + this.alertService.error(response.error?.error || 'Unable to delete activity type.'); + }, + }); }, - error: (response) => { - this.alertService.error(response.error?.error || 'Unable to delete activity type.'); - }, - }); + undefined, + 'Delete', + ); } // Sorting function to sort data when sort diff --git a/src/app/admin/institution-settings/campuses/campus-list/campus-list.component.ts b/src/app/admin/institution-settings/campuses/campus-list/campus-list.component.ts index b953bc3f16..ed5b1b9852 100644 --- a/src/app/admin/institution-settings/campuses/campus-list/campus-list.component.ts +++ b/src/app/admin/institution-settings/campuses/campus-list/campus-list.component.ts @@ -4,6 +4,7 @@ import {MatSort, Sort} from '@angular/material/sort'; import {MatTable, MatTableDataSource} from '@angular/material/table'; import {Campus, CampusService} from 'src/app/api/models/doubtfire-model'; import {EntityFormComponent} from 'src/app/common/entity-form/entity-form.component'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; import {AlertService} from 'src/app/common/services/alert.service'; @Component({ @@ -29,6 +30,7 @@ export class CampusListComponent extends EntityFormComponent implements constructor( private campusService: CampusService, private alerts: AlertService, + private confirmationModal: ConfirmationModalService, ) { super( { @@ -80,14 +82,22 @@ export class CampusListComponent extends EntityFormComponent implements // This method is called when the delete button is clicked deleteCampus(campus: Campus) { - this.delete(campus, this.campuses, this.campusService).subscribe({ - next: () => { - this.alerts.success(`${campus.name} has been deleted.`, 2000); + this.confirmationModal.show( + `Delete campus ${campus.name}`, + 'This campus is used by tutorials and students across every unit. Deleting it cannot be undone.', + () => { + this.delete(campus, this.campuses, this.campusService).subscribe({ + next: () => { + this.alerts.success(`${campus.name} has been deleted.`, 2000); + }, + error: (response) => { + this.alerts.error(response.error?.error || 'Unable to delete campus.'); + }, + }); }, - error: (response) => { - this.alerts.error(response.error?.error || 'Unable to delete campus.'); - }, - }); + undefined, + 'Delete', + ); } // Sorting function to sort data when sort diff --git a/src/app/admin/institution-settings/institution-settings.component.html b/src/app/admin/institution-settings/institution-settings.component.html index e57317aca4..b10bafbc64 100644 --- a/src/app/admin/institution-settings/institution-settings.component.html +++ b/src/app/admin/institution-settings/institution-settings.component.html @@ -1,4 +1,4 @@ -
+
@switch (currentTab.routeSegment) { @case ('campuses') { - + + + } @case ('activities') { - + + + } @case ('teaching-periods') { - + + + } @case ('learning-outcomes') { -
-
-
-

Learning Outcomes

-

Manage global learning outcomes for the institution

+ +
+
+
+

Learning Outcomes

+

Manage global learning outcomes for the institution

+
+
- -
+ } @case ('overseer-images') { - + + + } @case ('turnitin') { - +
+ +
} }
diff --git a/src/app/admin/institution-settings/institution-settings.component.scss b/src/app/admin/institution-settings/institution-settings.component.scss index 7017d84036..b1baa5aebe 100644 --- a/src/app/admin/institution-settings/institution-settings.component.scss +++ b/src/app/admin/institution-settings/institution-settings.component.scss @@ -1,6 +1,3 @@ -.institution-settings-container { - padding: 0 5rem 0 5rem; -} .learning-outcomes-container { padding: 0 1rem 1rem 1rem; } diff --git a/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.html b/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.html index c9b0d15df4..acc19ebbaf 100644 --- a/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.html +++ b/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.html @@ -18,38 +18,18 @@

@if (loadingImages) {
-
- @for (width of ['22%', '18%', '8%', '16%', '10%', '4%']; track $index) { - - } -
- - @for (row of skeletonRows; track row) { -
- - - - - -
- } + +
} @@ -154,17 +134,22 @@

}" > @if (overseerImage.pulledImageStatus === 'success') { - } @if (overseerImage.pulledImageStatus === 'loading') { - } @if (overseerImage.pulledImageStatus === 'failed') {

} @else {
- - -
@@ -214,7 +204,7 @@

-
@@ -223,6 +213,15 @@

+ + + + +

diff --git a/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.ts b/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.ts index 9f7ab13091..52d9476030 100644 --- a/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.ts +++ b/src/app/admin/institution-settings/overseer-images/overseer-image-list.component.ts @@ -39,7 +39,6 @@ export class OverseerImageListComponent dataSource = new MatTableDataSource(this.overseerImages); loading = false; loadingImages = true; - skeletonRows = Array.from({length: 2}, (_, index) => index); public diskSpace: number | null = null; diff --git a/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.spec.ts b/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.spec.ts index 6b38a84b69..c589b103d8 100644 --- a/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.spec.ts +++ b/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.spec.ts @@ -1,10 +1,14 @@ -import {beforeEach, describe, expect, it} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {MatDialog} from '@angular/material/dialog'; +import {of} from 'rxjs'; import {TeachingPeriodService} from 'src/app/api/services/teaching-period.service'; import {TeachingPeriodUnitImportService} from '../teaching-period-unit-import/teaching-period-unit-import.dialog'; -import {TeachingPeriodListComponent} from './teaching-period-list.component'; +import { + NewTeachingPeriodDialogComponent, + TeachingPeriodListComponent, +} from './teaching-period-list.component'; const emptyProvider = {}; @@ -35,3 +39,20 @@ describe('TeachingPeriodListComponent', () => { expect(component).toBeTruthy(); }); }); + +describe('NewTeachingPeriodDialogComponent deleteBreak feedback', () => { + it('confirms to the user when a break is removed', () => { + const alertService = {success: vi.fn(), error: vi.fn()}; + const component = Object.create(NewTeachingPeriodDialogComponent.prototype) as { + alertService: typeof alertService; + deleteBreak(teachingPeriod: unknown, teachingBreak: unknown): void; + }; + component.alertService = alertService; + const teachingPeriod = {removeBreak: vi.fn(() => of({}))}; + + component.deleteBreak(teachingPeriod, {id: 3}); + + expect(teachingPeriod.removeBreak).toHaveBeenCalledWith(3); + expect(alertService.success).toHaveBeenCalledWith('Break removed', 2000); + }); +}); diff --git a/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.ts b/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.ts index 430dcd9dfd..99dde3f1ff 100644 --- a/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.ts +++ b/src/app/admin/states/teaching-periods/teaching-period-list/teaching-period-list.component.ts @@ -119,9 +119,8 @@ export class NewTeachingPeriodDialogComponent { this.newOrSelectedTeachingPeriod .addBreak(this.tempBreak.startDate, this.tempBreak.numberOfWeeks) .subscribe({ - next: (teachingPeriodBreak) => { + next: () => { this.alertService.success('Break added'); - console.log(teachingPeriodBreak); }, error: (response) => { this.alertService.error(`Error adding break. ${response}`); @@ -131,8 +130,8 @@ export class NewTeachingPeriodDialogComponent { deleteBreak(teachingPeriod: TeachingPeriod, teachingBreak: TeachingPeriodBreak): void { teachingPeriod.removeBreak(teachingBreak.id).subscribe({ - next: (teachingPeriodBreak) => { - console.log(teachingPeriodBreak); + next: () => { + this.alertService.success('Break removed', 2000); }, error: (response) => { this.alertService.error(`Error deleting break. ${response}`); diff --git a/src/app/admin/states/users/users.component.ts b/src/app/admin/states/users/users.component.ts index 63a39166bd..9ac86459e2 100644 --- a/src/app/admin/states/users/users.component.ts +++ b/src/app/admin/states/users/users.component.ts @@ -86,7 +86,6 @@ export class FUsersComponent implements OnInit, AfterViewInit, OnDestroy { this.fileDownloaderService.downloadFile(this.userService.csvURL, 'Users.csv'); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars // TODO: This needs to be brought out into a service which replaces the old csv-result-modal protected onUserUploadSuccess(event) { const max_full_errors = 5; @@ -106,9 +105,13 @@ export class FUsersComponent implements OnInit, AfterViewInit, OnDestroy { if (num_errors > max_full_errors) { error_string += `... and ${num_errors - max_full_errors} more`; } - this.alerts.error(error_string); + if (num_errors === 0) { + this.alerts.success(error_string); + } else { + this.alerts.error(error_string); + } - this.userService.query(); + this.userService.fetchAll().subscribe({error: (error) => this.alerts.error(error)}); } public showUserModal(user?: User) { diff --git a/src/app/admin/tii-action-log/tii-action-log.component.html b/src/app/admin/tii-action-log/tii-action-log.component.html index 4590ee9977..a06cdae2de 100644 --- a/src/app/admin/tii-action-log/tii-action-log.component.html +++ b/src/app/admin/tii-action-log/tii-action-log.component.html @@ -27,44 +27,44 @@

Turnitin Actions

{{ action.description }} - + Last Run {{ action.lastRun ? (action.lastRun | date: 'd LLL y') : '' }} - + Retries {{ action.retries }} - + Retry? {{ action.retry }} - + Error Code {{ action.errorCode }} - + Complete? {{ action.complete }} - + Error Message {{ action.errorMessage }} - + @if (!(action.complete || action.retry)) { @@ -84,7 +84,18 @@

Turnitin Actions

+ + + + + + + diff --git a/src/app/admin/tii-action-log/tii-action-log.component.spec.ts b/src/app/admin/tii-action-log/tii-action-log.component.spec.ts index d9cd6e6310..c1a10ce7a8 100644 --- a/src/app/admin/tii-action-log/tii-action-log.component.spec.ts +++ b/src/app/admin/tii-action-log/tii-action-log.component.spec.ts @@ -1,6 +1,10 @@ import {beforeEach, describe, expect, it} from 'vitest'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatSortModule} from '@angular/material/sort'; +import {MatTableModule} from '@angular/material/table'; +import {from} from 'rxjs'; import {TiiActionService} from 'src/app/api/services/tii-action.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {TiiActionLogComponent} from './tii-action-log.component'; @@ -33,3 +37,58 @@ describe('TiiActionLogComponent', () => { expect(component).toBeTruthy(); }); }); + +describe('TiiActionLogComponent sticky columns (STAFF-15)', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TiiActionLogComponent], + imports: [FormsModule, MatSortModule, MatTableModule], + providers: [ + // A Promise-backed observable defers emission to a microtask, same as + // the real HTTP call would - avoids mutating tiiActionsSource inside + // the same synchronous pass ngAfterViewInit runs in. + {provide: TiiActionService, useValue: {query: () => from(Promise.resolve([]))}}, + {provide: AlertService, useValue: emptyProvider}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TiiActionLogComponent); + fixture.detectChanges(); // triggers ngAfterViewInit, which subscribes + await fixture.whenStable(); // let the microtask set tiiActionsSource + fixture.detectChanges(); // render the table against that data source + }); + + // CDK Table applies `position: sticky` via the mat-mdc-table-sticky class (in its + // stylesheet, not inline) and sets the per-instance left/right offset inline. It + // never renders errorCode or complete at all - they're declared in the template + // but absent from the `columns` array, so they don't reach the header row. + function headerCell(column: string): HTMLElement { + return fixture.nativeElement.querySelector(`.mat-column-${column}`); + } + + it('only pins the identifying column (type) to the start', () => { + const type = headerCell('type'); + expect(type.classList.contains('mat-mdc-table-sticky')).toBe(true); + expect(type.style.left).toBe('0px'); + expect(type.style.right).toBe(''); + + for (const column of ['lastRun', 'retries', 'retry', 'errorMessage']) { + const cell = headerCell(column); + expect( + cell.classList.contains('mat-mdc-table-sticky'), + `${column} should not be sticky`, + ).toBe(false); + expect(cell.style.left, `${column} should have no sticky offset`).toBe(''); + } + }); + + it('pins the tools column to the end, not the start, matching the tutorials table', () => { + const tools = headerCell('tiiActionTools'); + expect(tools.classList.contains('mat-mdc-table-sticky')).toBe(true); + expect(tools.style.right).toBe('0px'); + expect(tools.style.left).toBe(''); + }); +}); diff --git a/src/app/admin/tii-action-log/tii-action-log.component.ts b/src/app/admin/tii-action-log/tii-action-log.component.ts index 1f7559a9f9..2f6b29da8b 100644 --- a/src/app/admin/tii-action-log/tii-action-log.component.ts +++ b/src/app/admin/tii-action-log/tii-action-log.component.ts @@ -37,7 +37,6 @@ export class TiiActionLogComponent implements AfterViewInit { ngAfterViewInit(): void { this.tiiActionService.query().subscribe((actions) => { - console.log(actions); this.tiiActionsSource = new MatTableDataSource(actions); this.tiiActionsSource.paginator = this.paginator; this.tiiActionsSource.sort = this.sort; diff --git a/src/app/api/models/course-flow.ts b/src/app/api/models/course-flow.ts new file mode 100644 index 0000000000..63dc9e2d95 --- /dev/null +++ b/src/app/api/models/course-flow.ts @@ -0,0 +1,48 @@ +/** Versioned catalog rules for planning; these do not certify degree eligibility. */ +export interface CourseFlowUnit { + code: string; + name: string; + required: boolean; + prerequisites: string[]; + offered_trimesters: number[]; +} + +export interface CourseFlowCourse { + id: number; + code: string; + name: string; + version: string; + elective_count: number; + units: CourseFlowUnit[]; +} + +export interface CourseFlowPeriod { + year: number; + trimester: number; +} + +export interface CourseFlowSlot extends CourseFlowPeriod { + unit_code: string; + position: number; +} + +export interface CourseFlowIssue { + code: 'missing_required' | 'elective_count' | 'prerequisite' | 'unavailable_trimester'; + message: string; + unit_code?: string; +} + +export interface CourseFlowDraft { + course_id: number; + name: string; + periods: CourseFlowPeriod[]; + slots: CourseFlowSlot[]; +} + +export interface CourseFlowMap extends CourseFlowDraft { + id: number; + lock_version: number; + issues: CourseFlowIssue[]; + complete: boolean; + updated_at: string; +} diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts index 60b3bfb8b9..5712349732 100644 --- a/src/app/api/models/doubtfire-model.ts +++ b/src/app/api/models/doubtfire-model.ts @@ -41,6 +41,7 @@ export * from './task-comment/scorm-extension-comment'; export * from './feedback-template'; export * from './communication'; export * from './engagement'; +export * from './course-flow'; // Users -- are students or staff export * from './user/user'; @@ -75,3 +76,7 @@ export * from '../services/communication-condition.service'; export * from '../services/communication-action.service'; export * from '../services/engagement.service'; export * from '../services/engagement-comment.service'; + +// Peer progress -- anonymous cohort progress for a project's target grade +export * from './peer-progress'; +export * from '../services/peer-progress.service'; diff --git a/src/app/api/models/project.ts b/src/app/api/models/project.ts index 48c2720701..7d6469db92 100644 --- a/src/app/api/models/project.ts +++ b/src/app/api/models/project.ts @@ -24,6 +24,11 @@ import {Engagement} from './engagement'; import {StaffNote} from './staff-note'; import {TaskOutcomeAlignment} from './task-outcome-alignment'; +export interface ProjectBurndownSeries { + key: string; + values: [timestamp: number, remaining: number][]; +} + export class Project extends Entity { public id: number; public unit: Unit; @@ -52,7 +57,7 @@ export class Project extends Entity { }[]; public orderScale: number; - public burndownChartData: {key: string; values: number[]}[]; + public burndownChartData: ProjectBurndownSeries[]; public readonly taskCache: EntityCache = new EntityCache(); public readonly staffNoteCache: EntityCache = new EntityCache(); public readonly engagementCache: EntityCache = new EntityCache(); @@ -240,6 +245,7 @@ export class Project extends Entity { const alerts = AppInjector.get(AlertService); const projectService: ProjectService = AppInjector.get(ProjectService); const oldGrade: number = this.grade || 0; + const oldRationale = this.gradeRationale; this.grade = score; this.gradeRationale = rationale; @@ -257,7 +263,9 @@ export class Project extends Entity { alerts.success('Grade updated.'); }, error: (message) => { + // Put back both halves of the old grade, so the rationale shown matches it. this.grade = oldGrade; + this.gradeRationale = oldRationale; alerts.error(`Grade was not updated: ${message}`); }, }); @@ -359,8 +367,9 @@ export class Project extends Entity { } } - public isEnrolledIn(tutorial: Tutorial): boolean { - return this.tutorials.some((t) => t.id === tutorial.id); + public isEnrolledIn(tutorial: Tutorial | undefined): boolean { + // A group can be left without a tutorial, and no one is enrolled in no tutorial. + return !!tutorial && this.tutorials.some((t) => t.id === tutorial.id); } public updateUnitEnrolment(): void { @@ -398,15 +407,15 @@ export class Project extends Entity { } public refreshBurndownChartData(): void { - const result: {key: string; values: number[]}[] = []; + const result: ProjectBurndownSeries[] = []; // Setup the dictionaries to contain the keys and values // key = series name // values = array of [ x, y ] values - const projectedResults = {key: 'Projected', values: []}; - const targetTaskResults = {key: 'Target', values: []}; - const doneTaskResults = {key: 'To Submit', values: []}; - const completeTaskResults = {key: 'To Complete', values: []}; + const projectedResults: ProjectBurndownSeries = {key: 'Projected', values: []}; + const targetTaskResults: ProjectBurndownSeries = {key: 'Target', values: []}; + const doneTaskResults: ProjectBurndownSeries = {key: 'To Submit', values: []}; + const completeTaskResults: ProjectBurndownSeries = {key: 'To Complete', values: []}; result.push(targetTaskResults); result.push(projectedResults); @@ -496,7 +505,7 @@ export class Project extends Entity { dates.forEach((date) => { // get the target values - those from the task definitions // amount remaining is the sum of all tasks due after the date - const targetVal = [ + const targetVal: ProjectBurndownSeries['values'][number] = [ date.getTime(), (targetTasks .filter((taskDef) => taskDef.targetDate >= date) @@ -505,7 +514,7 @@ export class Project extends Entity { ]; // get the done values - those done up to today, or the end of the unit - const doneVal = [ + const doneVal: ProjectBurndownSeries['values'][number] = [ date.getTime(), (total - doneTasks @@ -516,7 +525,7 @@ export class Project extends Entity { ]; // get the completed values - those signed off - const completeVal = [ + const completeVal: ProjectBurndownSeries['values'][number] = [ date.getTime(), (total - completedTasks @@ -527,7 +536,10 @@ export class Project extends Entity { ]; // projected value is based on amount done - const projectedVal = [date.getTime(), projectedRemaining / total]; + const projectedVal: ProjectBurndownSeries['values'][number] = [ + date.getTime(), + projectedRemaining / total, + ]; // add one week's worth of completion data projectedRemaining -= completionRate; diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index bac5be4451..09e557023e 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -2,8 +2,10 @@ import {Entity, EntityCache, RequestOptions} from 'ngx-entity-service'; import {formatDate} from '@angular/common'; import {HttpClient} from '@angular/common/http'; import {LOCALE_ID} from '@angular/core'; -import {Observable, firstValueFrom, map} from 'rxjs'; +import {Observable, finalize, firstValueFrom, map} from 'rxjs'; import {AppInjector} from 'src/app/app-injector'; +import {SubmissionCelebrationService} from 'src/app/common/celebrate/submission-celebration.service'; +import type {SubmissionCelebration} from 'src/app/common/celebrate/submission-timing'; import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {GradeTaskModalService} from 'src/app/tasks/modals/grade-task-modal/grade-task-modal.service'; @@ -45,11 +47,54 @@ export const FeedbackModerationAction = { export type FeedbackModerationActionType = (typeof FeedbackModerationAction)[keyof typeof FeedbackModerationAction]; +export type SubmissionProcessingState = + | 'not_submitted' + | 'queued' + | 'processing' + | 'ready' + | 'failed' + | 'timed_out'; + +export interface SubmissionProcessingResponse { + has_pdf?: boolean; + pdf_ready?: boolean; + submission_files_ready?: boolean; + processing_pdf?: boolean; + processing_state?: SubmissionProcessingState; + processing_started_at?: string | null; + processing_finished_at?: string | null; + processing_error_code?: string | null; + processing_attempts?: number; + retryable?: boolean; + poll_after_seconds?: number | null; + submission_date?: string | null; + task_status?: TaskStatusEnum; + claimed_by_unit_role_id?: number | null; +} + export class Task extends Entity { id: number; status: TaskStatusEnum = 'not_started'; dueDate: Date; + effectiveDeadline?: Date; + effectiveDeadlineDate?: Date; + effectiveDeadlineReason?: string; + effectiveDeadlineSourceId?: number; + + public get effectiveDeadlineExplanation(): string { + switch (this.effectiveDeadlineReason) { + case 'post_feedback_extension': + return 'Extended after feedback requiring further action.'; + case 'approved_extension': + return 'Includes an approved extension.'; + case 'flexible_date': + return 'Your planned submission date.'; + default: + return 'The standard due date for this task.'; + } + } + extensions: number; scormExtensions: number; submissionDate: Date; @@ -60,6 +105,7 @@ export class Task extends Entity { includeInPortfolio: boolean = true; similarityFlag: boolean = false; numNewComments: number = 0; + hasFeedback?: boolean; hasExtensions: boolean; moderationType: 'random_sample' | 'escalation' | 'first_feedback'; @@ -68,9 +114,17 @@ export class Task extends Entity { definition: TaskDefinition; tutorialId: number; - //TODO: map task submission details hasPdf: boolean = false; processingPdf: boolean = false; + submissionPdfReady: boolean = false; + submissionFilesReady: boolean = false; + submissionProcessingState: SubmissionProcessingState = 'not_submitted'; + submissionProcessingStartedAt: Date | null = null; + submissionProcessingFinishedAt: Date | null = null; + submissionProcessingErrorCode: string | null = null; + submissionProcessingAttempts = 0; + submissionRetryable = false; + submissionPollAfterSeconds: number | null = null; claimedByUnitRoleId: number | null; loadingSubmissionDetails: boolean = false; @@ -90,6 +144,9 @@ export class Task extends Entity { suggestedTaskStatus; + /** Status held before the student's current submission began. Cleared once it settles. */ + private statusBeforeSubmission?: TaskStatusEnum; + private _unit: Unit; constructor(data?: Project | Unit) { @@ -136,12 +193,19 @@ export class Task extends Entity { } public get tutor(): UnitRole { - const enrolments = this.project.tutorialEnrolmentsCache.currentValues.filter( - (t) => t.tutorialStream.name === this.definition.tutorialStream.name, + // A unit without streams has no stream on its tutorials or its task definitions, + // so neither side can be read blindly. A tutorial with no stream covers every + // task, which is also how the server picks the tutorial for a task. + const streamName = this.definition?.tutorialStream?.name; + const enrolments = (this.project?.tutorialEnrolmentsCache.currentValues ?? []).filter( + (t) => !t.tutorialStream || (!!streamName && t.tutorialStream.name === streamName), ); if (enrolments.length === 1) { const user = enrolments[0].tutor; - return this.unit.staff.find((ur) => ur.user.id === user.id); + if (!user) { + return undefined; + } + return this.unit?.staff?.find((ur) => ur.user?.id === user.id); } } @@ -695,6 +759,38 @@ export class Task extends Entity { return TaskStatus.SUBMITTED_STATUSES.includes(this.status); } + /** + * Whether the Your Submission tab and card have anything to show. A tutor can + * return a submitted task to Redo/Resubmit, and the submission timestamp and + * artifacts remain authoritative in that state. A task that takes no uploads + * never has files to show, whatever its status. + * + * This is not the rule for which upload action to offer. The status card + * follows inSubmittedState() for that, so a returned task goes back through + * the full Ready for Feedback flow. + */ + public hasSubmissionHistory(): boolean { + if (!this.requiresFileUpload()) { + return false; + } + + return !!( + this.hasValidSubmissionDate() || + this.hasPdf || + this.processingPdf || + this.submissionPdfReady || + this.submissionFilesReady || + this.submissionProcessingState !== 'not_submitted' || + this.inSubmittedState() || + TaskStatus.MARKED_STATUSES.includes(this.status) + ); + } + + // An Invalid Date is still a truthy object, so check the time value itself. + private hasValidSubmissionDate(): boolean { + return !!this.submissionDate && !Number.isNaN(new Date(this.submissionDate).getTime()); + } + public inAwaitingFeedbackState(): boolean { return this.status === 'ready_for_feedback'; } @@ -736,22 +832,72 @@ export class Task extends Entity { }/submission_details`, ) .pipe( - map((response: object) => { + map((response: SubmissionProcessingResponse) => this.applySubmissionDetails(response)), + finalize(() => { this.loadingSubmissionDetails = false; - this.hasPdf = response['has_pdf']; - this.processingPdf = response['processing_pdf']; - this.submissionDate = MappingFunctions.mapDate(response, 'submission_date', this); - if (response['task_status'] && TaskStatus.STATUS_KEYS.includes(response['task_status'])) { - this.status = response['task_status']; - } - if ('claimed_by_unit_role_id' in response) { - this.claimedByUnitRoleId = response['claimed_by_unit_role_id'] as number | null; - } - return this; }), ); } + public retrySubmissionProcessing(): Observable { + const http: HttpClient = AppInjector.get(HttpClient); + this.loadingSubmissionDetails = true; + return http.post(`${this.submissionUrl()}/retry`, {}).pipe( + map((response) => this.applySubmissionDetails(response)), + finalize(() => { + this.loadingSubmissionDetails = false; + }), + ); + } + + public get submissionProcessingActive(): boolean { + return ( + this.submissionProcessingState === 'queued' || this.submissionProcessingState === 'processing' + ); + } + + private applySubmissionDetails(response: SubmissionProcessingResponse): Task { + const legacyProcessing = response.processing_pdf === true; + const legacyHasPdf = response.has_pdf === true; + const state = + response.processing_state ?? + (legacyProcessing ? 'processing' : legacyHasPdf ? 'ready' : 'not_submitted'); + + this.submissionProcessingState = state; + this.hasPdf = legacyHasPdf; + this.processingPdf = state === 'queued' || state === 'processing'; + this.submissionPdfReady = response.pdf_ready ?? legacyHasPdf; + this.submissionFilesReady = response.submission_files_ready ?? legacyHasPdf; + this.submissionProcessingStartedAt = this.parseApiDate(response.processing_started_at); + this.submissionProcessingFinishedAt = this.parseApiDate(response.processing_finished_at); + this.submissionProcessingErrorCode = response.processing_error_code ?? null; + this.submissionProcessingAttempts = response.processing_attempts ?? 0; + this.submissionRetryable = response.retryable === true; + this.submissionPollAfterSeconds = response.poll_after_seconds ?? null; + // The API sends submission_date: null for a task that was never submitted, + // and leaves the key out when the task takes no uploads. mapDate would turn + // those into the 1970 epoch and an Invalid Date, both truthy. + if ('submission_date' in response) { + this.submissionDate = this.parseApiDate(response.submission_date) ?? undefined; + } + + if (response.task_status && TaskStatus.STATUS_KEYS.includes(response.task_status)) { + this.status = response.task_status; + } + if ('claimed_by_unit_role_id' in response) { + this.claimedByUnitRoleId = response.claimed_by_unit_role_id ?? null; + } + return this; + } + + private parseApiDate(value: string | null | undefined): Date | null { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + private mapUnitTaskPrerequisites(prerequisites: TaskPrerequisite[]): TaskPrerequisite[] { const definitions = this.unit.taskDefinitions; @@ -874,6 +1020,9 @@ export class Task extends Entity { isTestSubmission: boolean = false, ) { const oldStatus = this.status; + // A new submission remembers where it came from, so a resubmission reads as one. + // New evidence and test submissions are not status changes and are not celebrated. + this.statusBeforeSubmission = !reuploadEvidence && !isTestSubmission ? oldStatus : undefined; if (!isTestSubmission) { this.status = status; @@ -883,6 +1032,7 @@ export class Task extends Entity { const modal = uploadModal.show(this, reuploadEvidence, isTestSubmission); // Modal failed to present if (!modal) { + this.statusBeforeSubmission = undefined; if (!isTestSubmission) { this.status = oldStatus; } @@ -896,6 +1046,7 @@ export class Task extends Entity { }, // Grade was not selected (modal was dismissed) (_dismissed) => { + this.statusBeforeSubmission = undefined; if (!isTestSubmission) { this.status = oldStatus; } @@ -905,7 +1056,18 @@ export class Task extends Entity { ); } - public processTaskStatusChange(expectedStatus: TaskStatusEnum, alerts: AlertService) { + /** + * Set `claimCelebration` when the caller has a surface of its own to show the + * confirmation in, such as the submission dialog it was started from. The + * celebration is returned instead of being raised over the page, and the + * snackbar stays suppressed either way. + */ + public processTaskStatusChange( + expectedStatus: TaskStatusEnum, + alerts: AlertService, + submissionCompleted: boolean = false, + claimCelebration: boolean = false, + ): SubmissionCelebration | null { if (this.inTimeExceeded() && !this.isPastDeadline()) { alerts.message( 'You have submitted after the deadline for feedback. Your task will not be reviewed by a tutor. It is now your responsibility to ensure this task meets the required standard.', @@ -913,14 +1075,52 @@ export class Task extends Entity { ); } + const previousStatus = this.statusBeforeSubmission; + this.statusBeforeSubmission = undefined; + const eligible = + submissionCompleted && + expectedStatus === 'ready_for_feedback' && + previousStatus !== undefined; + + let claimed: SubmissionCelebration | null = null; + let celebrated = false; + if (eligible) { + if (claimCelebration) { + claimed = this.describeSubmissionCelebration(previousStatus); + celebrated = claimed !== null; + } else { + celebrated = this.celebrateSubmission(previousStatus); + } + } + if (this.status !== expectedStatus) { alerts.message(`Status changed to ${this.statusLabel()}.`, 4000); - } else { + } else if (!celebrated) { + // The submission confirmation already says this, so it replaces the snackbar. alerts.success(`Status changed to ${this.statusLabel()}.`); } this.getSubmissionDetails().subscribe(); const taskService: TaskService = AppInjector.get(TaskService); - taskService.notifyStatusChange(this); + taskService.notifyTransitionComplete(this, submissionCompleted); + return claimed; + } + + private celebrateSubmission(previousStatus: TaskStatusEnum): boolean { + try { + return AppInjector.get(SubmissionCelebrationService).celebrate(this, previousStatus); + } catch { + return false; + } + } + + private describeSubmissionCelebration( + previousStatus: TaskStatusEnum, + ): SubmissionCelebration | null { + try { + return AppInjector.get(SubmissionCelebrationService).describe(this, previousStatus); + } catch { + return null; + } } public async markAsDiscussed(reasonText?: string) { @@ -994,6 +1194,7 @@ export class Task extends Entity { status: TaskStatusEnum, markAsDiscussed?: boolean, triggerRecursiveFix?: boolean, + submissionCompleted: boolean = false, ) { const oldStatus = this.status; const oldGrade = this.grade; @@ -1043,7 +1244,8 @@ export class Task extends Entity { this.project.taskCache.delete(this.definition.abbreviation); this.project.taskCache.add(this); } - this.processTaskStatusChange(status, alerts); + this.statusBeforeSubmission = submissionCompleted ? oldStatus : undefined; + this.processTaskStatusChange(status, alerts, submissionCompleted); taskService.notifyStatusChange(this); }, error: (error) => { @@ -1093,7 +1295,12 @@ export class Task extends Entity { } else if (requiresFileUpload && !this.isReadyForUpload) { alerts.error('Complete Knowledge Check first to submit files', 6000); } else { - await this.updateTaskStatus(status); + await this.updateTaskStatus( + status, + undefined, + undefined, + TaskStatus.SUBMITTABLE_STATUSES.includes(status), + ); } } diff --git a/src/app/api/models/unit-role.ts b/src/app/api/models/unit-role.ts index 1436c96847..f3d138ec24 100644 --- a/src/app/api/models/unit-role.ts +++ b/src/app/api/models/unit-role.ts @@ -13,7 +13,7 @@ export class UnitRole extends Entity { unit: Unit; observerOnly: boolean; canMarkOverflowTasks: boolean; - mentorId: number; + mentorId: number | null; tutorNoteCount: number; public get mentor(): UnitRole { diff --git a/src/app/api/models/unit.spec.ts b/src/app/api/models/unit.spec.ts new file mode 100644 index 0000000000..34b42a1afe --- /dev/null +++ b/src/app/api/models/unit.spec.ts @@ -0,0 +1,50 @@ +import {describe, expect, it} from 'vitest'; +import {Unit} from './unit'; + +describe('Unit.findStudent', () => { + // Build a Unit without running the constructor so the test stays free of the + // Angular injector, then give it a stub studentCache. + function unitWithCache(entries: Record): Unit { + const unit = Object.create(Unit.prototype) as Unit; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (unit as any).studentCache = { + get: (key: number) => entries[key], + }; + return unit; + } + + it('returns the same instance the cache holds for that id', () => { + const project = {id: 7} as unknown; + const unit = unitWithCache({7: project}); + expect(unit.findStudent(7)).toBe(project); + }); + + it('returns undefined when no project has that id', () => { + const unit = unitWithCache({7: {id: 7}}); + expect(unit.findStudent(99)).toBeUndefined(); + }); +}); + +describe('Unit.findStudent (via stubbed cache)', () => { + // Build a Unit without running the constructor so the test stays free of the + // Angular injector, then give it a stub studentCache. + function unitWithCache(entries: Record): Unit { + const unit = Object.create(Unit.prototype) as Unit; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (unit as any).studentCache = { + get: (key: number) => entries[key], + }; + return unit; + } + + it('returns the same instance the cache holds for that id', () => { + const project = {id: 7} as unknown; + const unit = unitWithCache({7: project}); + expect(unit.findStudent(7)).toBe(project); + }); + + it('returns undefined when no project has that id', () => { + const unit = unitWithCache({7: {id: 7}}); + expect(unit.findStudent(99)).toBeUndefined(); + }); +}); diff --git a/src/app/api/models/unit.ts b/src/app/api/models/unit.ts index 6c5b87dcb7..40c3570385 100644 --- a/src/app/api/models/unit.ts +++ b/src/app/api/models/unit.ts @@ -195,7 +195,7 @@ export class Unit extends Entity { } public findStudent(id: number): Project { - return this.students.find((s) => s.id === id); + return this.studentCache.get(id); } public studentEnrolled(id: number): boolean { @@ -426,9 +426,6 @@ export class Unit extends Entity { AppInjector.get(UnitService) .fetch(this.id) .subscribe({ - next: (unit) => { - console.log(unit.teachingPeriod?.name); - }, error: (message) => alerts.error(message, 6000), }); } @@ -561,9 +558,15 @@ export class Unit extends Entity { ); } - public refreshStudents(includeWithdrawnStudents: boolean = false) { + public refreshStudents(includeWithdrawnStudents: boolean = false): void { const projectService: ProjectService = AppInjector.get(ProjectService); - projectService.loadStudents(this, includeWithdrawnStudents, true); + // The load is a cold observable. Without a subscriber the request never went out, + // so an enrolment change did not show until the page was reloaded. + projectService.loadStudents(this, includeWithdrawnStudents, true).subscribe({ + error: (message) => { + AppInjector.get(AlertService).error(`Could not refresh the students: ${message}`, 6000); + }, + }); } public findProjectForUsername(username: string): Project { diff --git a/src/app/api/models/user/user.ts b/src/app/api/models/user/user.ts index 5a98e22551..acdae98bf6 100644 --- a/src/app/api/models/user/user.ts +++ b/src/app/api/models/user/user.ts @@ -15,15 +15,28 @@ export class User extends Entity { public email: string; public username: string; public nickname: string; + public displayName: string; public systemRole: 'Admin' | 'Convenor' | 'Tutor' | 'Student' | 'Auditor'; public receiveTaskNotifications: boolean; public receivePortfolioNotifications: boolean; public receiveFeedbackNotifications: boolean; + /** Unit Hub updates in the bell. Email, push and reminders only apply while this is on. */ + public receiveUnitHubNotifications: boolean; + public receiveUnitHubEmailNotifications: boolean; + public receiveUnitHubPushNotifications: boolean; + public receiveUnitHubSessionReminders: boolean; + /** How often the unit summary email arrives: off, daily, weekly or monthly. */ + public digestFrequency: string; + public displayPeerProgress: boolean; + public themePreference: 'light' | 'dark' | 'system' | null; + public themePreferenceUpdatedAt: string | null; public hasRunFirstTimeSetup: boolean; public authenticationToken: string; public authenticationTokenExpiry: string; public pronouns: string | null; public acceptedTiiEula: boolean; + public institutionalIdentityManaged: boolean; + public emailEditable: boolean; // LTI Token public ltik: string; @@ -65,7 +78,7 @@ export class User extends Entity { public matches(text: string): boolean { return ( this.studentId?.toLowerCase().indexOf(text) >= 0 || - this.name.toLowerCase().indexOf(text) >= 0 || + this.displayName?.toLowerCase().indexOf(text) >= 0 || this.firstName.toLowerCase().indexOf(text) >= 0 || this.lastName.toLowerCase().indexOf(text) >= 0 || this.email.toLowerCase().indexOf(text) >= 0 || diff --git a/src/app/api/services/course-flow.service.spec.ts b/src/app/api/services/course-flow.service.spec.ts new file mode 100644 index 0000000000..60533fe805 --- /dev/null +++ b/src/app/api/services/course-flow.service.spec.ts @@ -0,0 +1,210 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + HTTP_INTERCEPTORS, + HttpClient, + HttpErrorResponse, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import {HttpAuthenticationInterceptor} from 'src/app/common/services/http-authentication.interceptor'; +import {PRESERVE_HTTP_ERROR_RESPONSE} from 'src/app/common/services/http-error-context'; +import {HttpErrorInterceptor} from 'src/app/common/services/http-error.interceptor'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {CourseFlowDraft} from '../models/course-flow'; +import {AuthenticationService} from './authentication.service'; +import {CourseFlowService} from './course-flow.service'; +import {UserService} from './user.service'; + +const draft: CourseFlowDraft = { + course_id: 7, + name: 'Personal study plan', + periods: [ + {year: 2026, trimester: 1}, + {year: 2027, trimester: 2}, + ], + slots: [{unit_code: 'REQ1', year: 2026, trimester: 1, position: 3}], +}; + +describe('Course Flow authenticated API contract', () => { + let service: CourseFlowService; + let http: HttpTestingController; + let currentUser: { + authenticationToken: string; + authenticationTokenExpiry: string | null; + username: string; + }; + let authentication: { + attemptLoginUsingRefreshToken: ReturnType< + typeof vi.fn<(callback: (success: boolean) => void) => void> + >; + timeoutAuthentication: ReturnType; + }; + const base = `${API_URL}/courseflow`; + + beforeEach(() => { + currentUser = { + authenticationToken: 'session-token', + authenticationTokenExpiry: null, + username: 'student', + }; + authentication = { + attemptLoginUsingRefreshToken: vi.fn((callback: (success: boolean) => void) => { + currentUser.authenticationToken = 'renewed-token'; + callback(true); + }), + timeoutAuthentication: vi.fn(), + }; + vi.spyOn(console, 'error').mockImplementation(() => {}); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + {provide: UserService, useValue: {currentUser, isAnonymousUser: () => false}}, + {provide: AuthenticationService, useValue: authentication}, + {provide: HTTP_INTERCEPTORS, useClass: HttpAuthenticationInterceptor, multi: true}, + {provide: HTTP_INTERCEPTORS, useClass: HttpErrorInterceptor, multi: true}, + ], + }); + service = TestBed.inject(CourseFlowService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => { + http.verify(); + vi.restoreAllMocks(); + }); + + it('reads the student-accessible catalog and owner-scoped plans without teaching-unit or user-ID requests', () => { + service.getCourses().subscribe(); + const catalog = http.expectOne({method: 'GET', url: `${base}/courses`}); + expect(catalog.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + expect(catalog.request.headers.get('Auth-Token')).toBe('session-token'); + expect(catalog.request.headers.get('Username')).toBe('student'); + catalog.flush([]); + service.getMaps().subscribe(); + const maps = http.expectOne({method: 'GET', url: `${base}/maps`}); + expect(maps.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + maps.flush([]); + service.getMap(12).subscribe(); + const map = http.expectOne({method: 'GET', url: `${base}/maps/12`}); + expect(map.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + map.flush({id: 12}); + http.expectNone((request) => request.url.includes('/api/units')); + }); + + it('sends a complete atomic draft body including empty periods and unit codes', () => { + service.createMap(draft).subscribe(); + const request = http.expectOne({method: 'POST', url: `${base}/maps`}); + expect(request.request.body).toEqual(draft); + expect(request.request.body.user_id).toBeUndefined(); + expect(request.request.body.params).toBeUndefined(); + expect(request.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + request.flush({...draft, id: 12, lock_version: 0}); + }); + + it('sends the expected lock version on updates and preserves server conflicts for the caller', () => { + let status: number; + service.updateMap(12, draft, 4).subscribe({error: (error) => (status = error.status)}); + const request = http.expectOne({method: 'PUT', url: `${base}/maps/12`}); + expect(request.request.body).toEqual({...draft, lock_version: 4}); + expect(request.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + request.flush({error: 'Conflict'}, {status: 409, statusText: 'Conflict'}); + expect(status).toBe(409); + }); + + it('sends a real query lock version on deletion', () => { + service.deleteMap(12, 4).subscribe(); + const request = http.expectOne({method: 'DELETE', url: `${base}/maps/12?lock_version=4`}); + expect(request.request.body).toBeNull(); + expect(request.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + request.flush(null, {status: 204, statusText: 'No Content'}); + }); + + it('preserves owner-scoped 404 responses through the real interceptor', () => { + let failure: HttpErrorResponse; + service.getMap(99).subscribe({error: (error) => (failure = error)}); + http + .expectOne(`${base}/maps/99`) + .flush({error: 'Plan not found'}, {status: 404, statusText: 'Not Found'}); + expect(failure).toBeInstanceOf(HttpErrorResponse); + expect(failure.status).toBe(404); + expect(failure.error).toEqual({error: 'Plan not found'}); + expect(authentication.attemptLoginUsingRefreshToken).not.toHaveBeenCalled(); + }); + + it('preserves conflicts after refreshing credentials without bypassing authentication', () => { + let failure: HttpErrorResponse; + service.updateMap(12, draft, 4).subscribe({error: (error) => (failure = error)}); + http.expectOne(`${base}/maps/12`).flush({}, {status: 419, statusText: 'Expired'}); + const retry = http.expectOne(`${base}/maps/12`); + expect(retry.request.headers.get('Auth-Token')).toBe('renewed-token'); + expect(retry.request.headers.get('Username')).toBe('student'); + expect(retry.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + expect(retry.request.body).toEqual({...draft, lock_version: 4}); + retry.flush({error: 'Conflict'}, {status: 409, statusText: 'Conflict'}); + expect(failure).toBeInstanceOf(HttpErrorResponse); + expect(failure.status).toBe(409); + expect(authentication.attemptLoginUsingRefreshToken).toHaveBeenCalledOnce(); + expect(authentication.timeoutAuthentication).not.toHaveBeenCalled(); + }); + + it('preserves errors for requests queued behind the same token refresh', () => { + let completeRefresh: (success: boolean) => void; + authentication.attemptLoginUsingRefreshToken.mockImplementation( + (callback) => (completeRefresh = callback), + ); + let firstFailure: HttpErrorResponse; + let queuedFailure: HttpErrorResponse; + service.getMap(12).subscribe({error: (error) => (firstFailure = error)}); + service.getMap(13).subscribe({error: (error) => (queuedFailure = error)}); + http.expectOne(`${base}/maps/12`).flush({}, {status: 419, statusText: 'Expired'}); + http.expectOne(`${base}/maps/13`).flush({}, {status: 419, statusText: 'Expired'}); + expect(authentication.attemptLoginUsingRefreshToken).toHaveBeenCalledOnce(); + currentUser.authenticationToken = 'shared-renewed-token'; + completeRefresh(true); + const firstRetry = http.expectOne(`${base}/maps/12`); + const queuedRetry = http.expectOne(`${base}/maps/13`); + expect(queuedRetry.request.headers.get('Auth-Token')).toBe('shared-renewed-token'); + expect(queuedRetry.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(true); + firstRetry.flush({}, {status: 404, statusText: 'Not Found'}); + queuedRetry.flush({}, {status: 409, statusText: 'Conflict'}); + expect(firstFailure.status).toBe(404); + expect(queuedFailure).toBeInstanceOf(HttpErrorResponse); + expect(queuedFailure.status).toBe(409); + }); + + it('keeps legacy message errors for callers that do not opt in', () => { + let failure: unknown; + TestBed.inject(HttpClient) + .get(`${API_URL}/units`) + .subscribe({error: (error) => (failure = error)}); + const request = http.expectOne(`${API_URL}/units`); + expect(request.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(false); + request.flush({error: 'Unit not found'}, {status: 404, statusText: 'Not Found'}); + expect(failure).toBe('Unit not found'); + }); + + it('still expires the session when refreshing credentials fails', () => { + authentication.attemptLoginUsingRefreshToken.mockImplementation((callback) => callback(false)); + let failure: Error; + service.getMaps().subscribe({error: (error) => (failure = error)}); + http.expectOne(`${base}/maps`).flush({}, {status: 419, statusText: 'Expired'}); + expect(authentication.timeoutAuthentication).toHaveBeenCalledOnce(); + expect(failure).toBeInstanceOf(Error); + expect(failure.message).toBe('Authentication timed out'); + }); + + it('keeps legacy message errors after token refresh too', () => { + let failure: unknown; + TestBed.inject(HttpClient) + .get(`${API_URL}/units`) + .subscribe({error: (error) => (failure = error)}); + http.expectOne(`${API_URL}/units`).flush({}, {status: 419, statusText: 'Expired'}); + const retry = http.expectOne(`${API_URL}/units`); + expect(retry.request.headers.get('Auth-Token')).toBe('renewed-token'); + expect(retry.request.context.get(PRESERVE_HTTP_ERROR_RESPONSE)).toBe(false); + retry.flush({error: 'Unit not found'}, {status: 404, statusText: 'Not Found'}); + expect(failure).toBe('Unit not found'); + }); +}); diff --git a/src/app/api/services/course-flow.service.ts b/src/app/api/services/course-flow.service.ts new file mode 100644 index 0000000000..54fa5bcbf8 --- /dev/null +++ b/src/app/api/services/course-flow.service.ts @@ -0,0 +1,56 @@ +import {HttpClient, HttpContext} from '@angular/common/http'; +import {Injectable, inject} from '@angular/core'; +import {Observable} from 'rxjs'; +import {PRESERVE_HTTP_ERROR_RESPONSE} from 'src/app/common/services/http-error-context'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {CourseFlowCourse, CourseFlowDraft, CourseFlowMap} from '../models/course-flow'; + +@Injectable({providedIn: 'root'}) +export class CourseFlowService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${API_URL}/courseflow`; + + getCourses(): Observable { + return this.http.get(`${this.baseUrl}/courses`, { + context: this.errorContext(), + }); + } + + getMaps(): Observable { + return this.http.get(`${this.baseUrl}/maps`, {context: this.errorContext()}); + } + + getMap(id: number): Observable { + return this.http.get(`${this.baseUrl}/maps/${id}`, { + context: this.errorContext(), + }); + } + + createMap(draft: CourseFlowDraft): Observable { + return this.http.post(`${this.baseUrl}/maps`, draft, { + context: this.errorContext(), + }); + } + + updateMap(id: number, draft: CourseFlowDraft, lockVersion: number): Observable { + return this.http.put( + `${this.baseUrl}/maps/${id}`, + { + ...draft, + lock_version: lockVersion, + }, + {context: this.errorContext()}, + ); + } + + deleteMap(id: number, lockVersion: number): Observable { + return this.http.delete(`${this.baseUrl}/maps/${id}`, { + params: {lock_version: lockVersion}, + context: this.errorContext(), + }); + } + + private errorContext(): HttpContext { + return new HttpContext().set(PRESERVE_HTTP_ERROR_RESPONSE, true); + } +} diff --git a/src/app/api/services/spec/task.service.spec.ts b/src/app/api/services/spec/task.service.spec.ts new file mode 100644 index 0000000000..81a13dba8f --- /dev/null +++ b/src/app/api/services/spec/task.service.spec.ts @@ -0,0 +1,109 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {provideHttpClient} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import {Unit} from 'src/app/api/models/doubtfire-model'; +import {Task} from 'src/app/api/models/task'; +import {TaskService} from '../task.service'; + +describe('TaskService', () => { + let service: TaskService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TaskService, provideHttpClient(), provideHttpClientTesting()], + }); + + service = TestBed.inject(TaskService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('maps has_feedback from the API to Task.hasFeedback', () => { + const project = { + id: 2, + unit: undefined, + }; + + const unit = { + id: 1, + findStudent: vi.fn(() => project), + taskDef: vi.fn((id: number) => ({id})), + incorporateTasks: vi.fn(), + } as unknown as Unit; + + project.unit = unit; + + let result: Task[]; + + service.queryTasksForTaskInbox(unit).subscribe((tasks) => { + result = tasks; + }); + + const request = httpMock.expectOne( + (req) => req.url.endsWith('/units/1/tasks/inbox') && req.method === 'GET', + ); + + request.flush([ + { + id: 27, + project_id: 2, + task_definition_id: 1, + has_feedback: true, + }, + ]); + + expect(result).toHaveLength(1); + expect(result[0].hasFeedback).toBe(true); + }); + + it('keeps general status changes separate from completed submissions', () => { + const task = {} as Task; + const statusUpdated = vi.fn(); + const submissionCompleted = vi.fn(); + + service.taskStatusUpdated$.subscribe(statusUpdated); + service.taskSubmissionCompleted$.subscribe(submissionCompleted); + + service.notifyStatusChange(task); + + expect(statusUpdated).toHaveBeenCalledOnce(); + expect(statusUpdated).toHaveBeenCalledWith(task); + expect(submissionCompleted).not.toHaveBeenCalled(); + }); + + it('broadcasts both events when a submission completes', () => { + const task = {} as Task; + const statusUpdated = vi.fn(); + const submissionCompleted = vi.fn(); + + service.taskStatusUpdated$.subscribe(statusUpdated); + service.taskSubmissionCompleted$.subscribe(submissionCompleted); + + service.notifyTransitionComplete(task, true); + + expect(statusUpdated).toHaveBeenCalledOnce(); + expect(statusUpdated).toHaveBeenCalledWith(task); + expect(submissionCompleted).toHaveBeenCalledOnce(); + expect(submissionCompleted).toHaveBeenCalledWith(task); + }); + + it('does not treat a non-submission transition as a completed submission', () => { + const task = {} as Task; + const statusUpdated = vi.fn(); + const submissionCompleted = vi.fn(); + + service.taskStatusUpdated$.subscribe(statusUpdated); + service.taskSubmissionCompleted$.subscribe(submissionCompleted); + + service.notifyTransitionComplete(task, false); + + expect(statusUpdated).toHaveBeenCalledOnce(); + expect(statusUpdated).toHaveBeenCalledWith(task); + expect(submissionCompleted).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/services/spec/user.service.spec.ts b/src/app/api/services/spec/user.service.spec.ts index 2e03e7e9c4..97c02ff4b4 100644 --- a/src/app/api/services/spec/user.service.spec.ts +++ b/src/app/api/services/spec/user.service.spec.ts @@ -53,6 +53,9 @@ describe('UserService', () => { firstName: 'Jake', lastName: 'renzella', email: 'jake@jake.jake', + displayPeerProgress: false, + institutionalIdentityManaged: true, + emailEditable: false, }); }); @@ -74,6 +77,43 @@ describe('UserService', () => { receive_portfolio_notifications: false, receive_feedback_notifications: false, receive_task_notifications: false, + display_peer_progress: false, + institutional_identity_managed: true, + email_editable: false, + }); + }); + + it('serialises the peer progress display preference when updating a profile', () => { + const user = new User(); + user.id = 1; + user.displayPeerProgress = false; + + userService.update(user).subscribe(); + + const req = httpMock.expectOne('http://localhost:3000/api/users/1'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toMatchObject({ + user: {display_peer_progress: false}, + }); + req.flush({id: 1, display_peer_progress: false}); + }); + + it('updates only the account theme preference and maps its server timestamp', () => { + const user = new User(); + user.id = 1; + + userService.updateThemePreference(user, 'dark').subscribe((updatedUser) => { + expect(updatedUser.themePreference).toBe('dark'); + expect(updatedUser.themePreferenceUpdatedAt).toBe('2026-09-01T01:02:03Z'); + }); + + const req = httpMock.expectOne('http://localhost:3000/api/users/1'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({user: {theme_preference: 'dark'}}); + req.flush({ + id: 1, + theme_preference: 'dark', + theme_preference_updated_at: '2026-09-01T01:02:03Z', }); }); @@ -157,127 +197,109 @@ describe('UserService', () => { u.receiveFeedbackNotifications = false; u.receiveTaskNotifications = false; - userService.update(u).subscribe( - (result) => { - expect(result.firstName).toBe(u.firstName); - }, - (error) => { - throw error; - }, - ); - - let req = httpMock.expectOne((request: HttpRequest): boolean => { - expect(request.url).toEqual('http://localhost:3000/api/users/1'); - expect(request.method).toBe('PUT'); - return true; - }); - req.flush(u); - - u.firstName = 'andrew'; + // The API answers a PUT with the saved user as snake_case JSON. Flush a + // literal in that shape whose values differ from the local `u`, so the + // assertions only pass if the snake_case -> camelCase mapping ran. Flushing + // `u` back, as this test used to, could not tell a working mapping from a + // broken one because both sides already held the same camelCase values. userService.update(u).subscribe({ next: (result) => { expect(result.firstName).toBe('andrew'); + expect(result.lastName).toBe('cain'); + expect(result.nickname).toBe('andy'); + expect(result.receiveTaskNotifications).toBe(true); }, error: (error) => { throw error; }, }); - req = httpMock.expectOne((request: HttpRequest): boolean => { - expect(request.url).toEqual('http://localhost:3000/api/users/1'); - expect(request.method).toBe('PUT'); - return true; - }); - - req.flush(u); - }); - - it('should cache the result of a get request', () => { - const user = new User(); - user.id = 1; - user.lastName = 'renzella'; - user.firstName = 'Jake'; - user.nickname = 'jake'; - user.hasRunFirstTimeSetup = false; - user.email = 'jake@jake.jake'; - user.studentId = '1'; - user.username = 'test'; - user.optInToResearch = true; - user.receivePortfolioNotifications = false; - user.receiveFeedbackNotifications = false; - user.receiveTaskNotifications = false; - - userService.get(1).subscribe(); - const req = httpMock.expectOne((request: HttpRequest): boolean => { expect(request.url).toEqual('http://localhost:3000/api/users/1'); - expect(request.method).toBe('GET'); + expect(request.method).toBe('PUT'); return true; }); - const user2 = user; - user2.id = 1; - req.flush(user2); - - userService.get(1).subscribe(); - - httpMock.expectNone((_request: HttpRequest): boolean => { - return true; + req.flush({ + id: 1, + first_name: 'andrew', + last_name: 'cain', + nickname: 'andy', + email: 'jake@jake.jake', + student_id: '1', + username: 'test', + opt_in_to_research: true, + has_run_first_time_setup: false, + receive_portfolio_notifications: false, + receive_feedback_notifications: false, + receive_task_notifications: true, }); }); - it('should cache fetch/get', () => { - let user = new User(); - user.id = 1; - user.lastName = 'renzella'; - user.firstName = 'Jake'; - user.nickname = 'jake'; - user.hasRunFirstTimeSetup = false; - user.email = 'jake@jake.jake'; - user.studentId = '1'; - user.username = 'test'; - user.optInToResearch = true; - user.receivePortfolioNotifications = false; - user.receiveFeedbackNotifications = false; - user.receiveTaskNotifications = false; + it('maps a snake_case response once and reuses the cached user on get', () => { + let fetchedUser: User; + userService.get(1).subscribe((user) => (fetchedUser = user)); - // 1 request here - userService.get(1).subscribe((data) => { - user = data; + const request = httpMock.expectOne('http://localhost:3000/api/users/1'); + expect(request.request.method).toBe('GET'); + request.flush({ + id: 1, + first_name: 'andrew', + last_name: 'cain', + student_id: 'student-1', + has_run_first_time_setup: true, + receive_feedback_notifications: true, }); - let req = httpMock.expectOne((request: HttpRequest): boolean => { - expect(request.url).toEqual('http://localhost:3000/api/users/1'); - expect(request.method).toBe('GET'); - return true; + expect(fetchedUser).toMatchObject({ + id: 1, + firstName: 'andrew', + lastName: 'cain', + studentId: 'student-1', + hasRunFirstTimeSetup: true, + receiveFeedbackNotifications: true, }); - const user2 = new User(); - Object.keys(user).forEach((key) => (user2[key] = user[key])); - user2.id = 1; - req.flush(user2); - - let user3: User; + let cachedUser: User; + userService.get(1).subscribe((user) => (cachedUser = user)); + expect(cachedUser).toBe(fetchedUser); + expect(cachedUser.firstName).toBe('andrew'); + httpMock.expectNone('http://localhost:3000/api/users/1'); + }); - // 1 request here - userService.fetch(1).subscribe((data) => { - expect(data).toBe(user); - user3 = data; + it('maps a refreshed response onto the same cached user', () => { + let cachedUser: User; + userService.get(1).subscribe((user) => (cachedUser = user)); + const initialRequest = httpMock.expectOne('http://localhost:3000/api/users/1'); + expect(initialRequest.request.method).toBe('GET'); + initialRequest.flush({ + id: 1, + first_name: 'andrew', + last_name: 'cain', + student_id: 'student-1', + receive_feedback_notifications: true, }); + expect(cachedUser.firstName).toBe('andrew'); - req = httpMock.expectOne((request: HttpRequest): boolean => { - expect(request.url).toEqual('http://localhost:3000/api/users/1'); - expect(request.method).toBe('GET'); - return true; + let refreshedUser: User; + userService.fetch(1).subscribe((user) => (refreshedUser = user)); + const refreshRequest = httpMock.expectOne('http://localhost:3000/api/users/1'); + expect(refreshRequest.request.method).toBe('GET'); + refreshRequest.flush({ + id: 1, + first_name: 'fred', + last_name: 'smith', + student_id: 'student-2', + receive_feedback_notifications: false, }); - const user4 = new User(); - Object.keys(user2).forEach((key) => (user4[key] = user2[key])); - user4.firstName = 'fred'; - req.flush(user4); - expect(user3).toBe(user); - - httpMock.expectNone((_request: HttpRequest): boolean => { - return true; + expect(refreshedUser).toBe(cachedUser); + expect(cachedUser).toMatchObject({ + firstName: 'fred', + lastName: 'smith', + studentId: 'student-2', + receiveFeedbackNotifications: false, }); + userService.get(1).subscribe((user) => expect(user).toBe(refreshedUser)); + httpMock.expectNone('http://localhost:3000/api/users/1'); }); }); diff --git a/src/app/api/services/task-similarity.service.ts b/src/app/api/services/task-similarity.service.ts index d002a3e68d..1578355df2 100644 --- a/src/app/api/services/task-similarity.service.ts +++ b/src/app/api/services/task-similarity.service.ts @@ -28,7 +28,6 @@ export class TaskSimilarityService extends CachedEntityService { this.mapping.addJsonKey('flagged'); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars public override createInstanceFrom(_json: object, constructorParams: Task): TaskSimilarity { return new TaskSimilarity(constructorParams); } diff --git a/src/app/api/services/task.service.ts b/src/app/api/services/task.service.ts index e82b781f12..659a60f28d 100644 --- a/src/app/api/services/task.service.ts +++ b/src/app/api/services/task.service.ts @@ -17,6 +17,7 @@ import {MappingFunctions} from './mapping-fn'; @Injectable() export class TaskService extends CachedEntityService { public readonly taskStatusUpdated$: EventEmitter = new EventEmitter(); + public readonly taskSubmissionCompleted$: EventEmitter = new EventEmitter(); protected readonly endpointFormat = '/projects/:projectId:/task_def_id/:taskDefId:'; @@ -58,6 +59,10 @@ export class TaskService extends CachedEntityService { keys: 'targetDueDate', toEntityFn: MappingFunctions.mapDateToEndOfDay, }, + {keys: 'effectiveDeadline', toEntityFn: MappingFunctions.mapDate}, + {keys: 'effectiveDeadlineDate', toEntityFn: MappingFunctions.mapDateToDay}, + 'effectiveDeadlineReason', + 'effectiveDeadlineSourceId', 'extensions', 'scormExtensions', { @@ -74,6 +79,7 @@ export class TaskService extends CachedEntityService { 'includeInPortfolio', 'similarityFlag', 'numNewComments', + 'hasFeedback', 'trigger', 'hasExtensions', 'pinned', @@ -243,6 +249,13 @@ export class TaskService extends CachedEntityService { this.taskStatusUpdated$.emit(task); } + public notifyTransitionComplete(task: Task, submissionCompleted: boolean): void { + this.notifyStatusChange(task); + if (submissionCompleted) { + this.taskSubmissionCompleted$.emit(task); + } + } + public readonly statusColors: Map = TaskStatus.STATUS_COLORS; public readonly switchableStates = TaskStatus.SWITCHABLE_STATES; diff --git a/src/app/api/services/user.service.ts b/src/app/api/services/user.service.ts index c63e903b2b..eb23c2d768 100644 --- a/src/app/api/services/user.service.ts +++ b/src/app/api/services/user.service.ts @@ -4,6 +4,7 @@ import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {UnitRole, UnitService, User} from 'src/app/api/models/doubtfire-model'; import {AppInjector} from 'src/app/app-injector'; +import {type ThemePreference} from 'src/app/common/theme/theme.service'; import API_URL from 'src/app/config/constants/apiUrl'; @Injectable() @@ -27,18 +28,32 @@ export class UserService extends CachedEntityService { 'email', 'username', 'nickname', + 'displayName', 'systemRole', 'receiveTaskNotifications', 'receivePortfolioNotifications', 'receiveFeedbackNotifications', + 'receiveUnitHubNotifications', + 'receiveUnitHubEmailNotifications', + 'receiveUnitHubPushNotifications', + 'receiveUnitHubSessionReminders', + 'digestFrequency', + 'displayPeerProgress', + 'themePreference', + 'themePreferenceUpdatedAt', 'hasRunFirstTimeSetup', 'pronouns', 'acceptedTiiEula', + 'institutionalIdentityManaged', + 'emailEditable', ); this._currentUser = this.anonymousUser; - this.mapping.mapAllKeysToJsonExcept('id'); + // Theme sync has a deliberately narrow writer below. Profile updates must + // not replay a stale account timestamp or turn a missing preference into an + // accidental choice. + this.mapping.mapAllKeysToJsonExcept('id', 'themePreference', 'themePreferenceUpdatedAt'); } // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any @@ -75,6 +90,13 @@ export class UserService extends CachedEntityService { return this.query(undefined, {endpointFormat: this.tutorEndpointFormat}); } + public updateThemePreference(user: User, preference: ThemePreference): Observable { + return this.update(user, { + entity: user, + body: {user: {theme_preference: preference}}, + }); + } + public adminRoleFor(unitId: number, user: User): UnitRole { return this.adminOrAuditorRoleFor('Admin', unitId, user); } diff --git a/src/app/app.component.html b/src/app/app.component.html index 53e25cc7c8..f67f6c97b6 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,12 @@ + + - - +
+ + + +
+ + diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts new file mode 100644 index 0000000000..200df02905 --- /dev/null +++ b/src/app/app.routes.spec.ts @@ -0,0 +1,32 @@ +import {describe, expect, it} from 'vitest'; +import {Route, UrlSegment, UrlSegmentGroup} from '@angular/router'; +import {projectDashboardTaskMatcher} from './app.routes'; + +const match = (paths: string[]) => { + const segments = paths.map((path) => new UrlSegment(path, {})); + return { + result: projectDashboardTaskMatcher(segments, {} as UrlSegmentGroup, {} as Route), + segments, + }; +}; + +describe('project dashboard task route matcher', () => { + it('uses one reusable route for task details and feedback destinations', () => { + const taskRoute = match(['dashboard', '1.1P']); + const feedbackRoute = match(['dashboard', '1.1P', 'feedback']); + + expect(taskRoute.result?.consumed).toEqual(taskRoute.segments); + expect(taskRoute.result?.posParams?.['taskAbbreviation'].path).toBe('1.1P'); + expect(taskRoute.result?.posParams?.['mobilePane']).toBeUndefined(); + + expect(feedbackRoute.result?.consumed).toEqual(feedbackRoute.segments); + expect(feedbackRoute.result?.posParams?.['taskAbbreviation'].path).toBe('1.1P'); + expect(feedbackRoute.result?.posParams?.['mobilePane'].path).toBe('feedback'); + }); + + it('does not consume the dashboard root or unrelated suffixes', () => { + expect(match(['dashboard']).result).toBeNull(); + expect(match(['dashboard', '1.1P', 'details']).result).toBeNull(); + expect(match(['dashboard', '1.1P', 'feedback', 'extra']).result).toBeNull(); + }); +}); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c9160fca0e..ab3fe9af72 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,12 +1,18 @@ -import {Routes} from '@angular/router'; +import {Routes, UrlMatcher} from '@angular/router'; import {EditProfileComponent} from './account/edit-profile/edit-profile.component'; +import {VerifyAdditionalEmailComponent} from './account/verify-additional-email/verify-additional-email.component'; import {InstitutionSettingsComponent} from './admin/institution-settings/institution-settings.component'; import {FUnitsComponent} from './admin/states/units/units.component'; import {FUsersComponent} from './admin/states/users/users.component'; import {roleWhitelistGuard} from './common/guards/role-whitelist.guard'; +import {NotificationsPageComponent} from './common/notifications-page/notifications-page.component'; import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; import {SubmissionFilesDownloadComponent} from './common/submission-files-download/submission-files-download.component'; import {SuccessCloseComponent} from './common/success-close/success-close.component'; +import {courseFlowDirtyGuard} from './courseflow/coursemap/course-flow-dirty.guard'; +import {CrossDashboardComponent} from './dashboard/f-cross-dashboard.component'; +import {DemoControlsComponent} from './demo/demo-controls/demo-controls.component'; +import {demoToolsGuard} from './demo/demo-tools.guard'; import {TimeoutComponent} from './errors/states/timeout/timeout.component'; import {UnauthorisedComponent} from './errors/states/unauthorised/unauthorised.component'; import {AcceptEulaComponent} from './eula/accept-eula/accept-eula.component'; @@ -35,6 +41,24 @@ import {UnitRootStateComponent} from './units/unit-root-state.component'; import {resolveUnit} from './units/unit.resolver'; import {WelcomeComponent} from './welcome/welcome.component'; +export const projectDashboardTaskMatcher: UrlMatcher = (segments) => { + if (segments.length < 2 || segments.length > 3 || segments[0].path !== 'dashboard') { + return null; + } + + if (segments.length === 3 && segments[2].path !== 'feedback') { + return null; + } + + return { + consumed: segments, + posParams: { + taskAbbreviation: segments[1], + ...(segments[2] ? {mobilePane: segments[2]} : {}), + }, + }; +}; + export const routes: Routes = [ {path: '', pathMatch: 'full', redirectTo: 'home'}, {path: 'home', component: HomeComponent}, @@ -44,9 +68,64 @@ export const routes: Routes = [ {path: 'timeout', component: TimeoutComponent}, {path: 'success-close', component: SuccessCloseComponent}, {path: 'edit_profile', component: EditProfileComponent}, + {path: 'verify_additional_email', component: VerifyAdditionalEmailComponent}, + {path: 'notifications', component: NotificationsPageComponent}, + ...['coursemap', 'coursemap/:courseMapId'].map((path) => ({ + path, + loadComponent: () => + import('./courseflow/coursemap/coursemap.component').then((m) => m.CoursemapComponent), + canActivate: [roleWhitelistGuard], + canDeactivate: [courseFlowDirtyGuard], + data: { + pageTitle: 'Course Flow', + roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin', 'Auditor'], + }, + })), + { + path: 'unit-hub', + loadComponent: () => import('./unit-hub/unit-hub.component').then((m) => m.UnitHubComponent), + canActivate: [roleWhitelistGuard], + data: { + pageTitle: 'Unit Hub', + roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin', 'Auditor'], + }, + }, + { + path: 'theme-demo', + loadComponent: () => + import('./common/theme/theme-demo.component').then((m) => m.ThemeDemoComponent), + data: {pageTitle: 'Theme foundation'}, + }, + { + path: 'motion-demo', + loadComponent: () => + import('./common/celebrate/motion-demo/motion-demo.component').then( + (m) => m.MotionDemoComponent, + ), + canActivate: [demoToolsGuard], + data: {pageTitle: 'Motion'}, + }, + { + path: 'submit-motion', + loadComponent: () => + import('./common/celebrate/motion-demo/submit-motion.component').then( + (m) => m.SubmitMotionComponent, + ), + canActivate: [demoToolsGuard], + data: {pageTitle: 'Submitting'}, + }, + { + path: 'demo-controls', + component: DemoControlsComponent, + canActivate: [demoToolsGuard], + data: {pageTitle: 'Demo controls'}, + }, {path: 'eula', component: AcceptEulaComponent}, {path: 'lti', component: LtiDashboardComponent}, {path: 'lti/link', component: LtiUnitLinkComponent}, + // Deliberately open: this is an empty iframe onto the bundled JPlag viewer at /JPlag/. It + // makes no api call and shows nothing until staff hand it a blob url that only exists in + // their own tab, so there is nothing here to guard. Listed in DELIBERATELY_OPEN_PATHS. {path: 'jplag-report-viewer', component: JplagReportViewerComponent}, { path: 'projects/:projectId/task_def_id/:taskDefId/scorm-player/normal', @@ -67,8 +146,20 @@ export const routes: Routes = [ path: 'projects/:projectId/task_def_id/:taskDefId/submission_files/download', component: SubmissionFilesDownloadComponent, }, - {path: 'view-all-units', component: FUnitsComponent, data: {mode: 'tutor'}}, + { + path: 'view-all-units', + component: FUnitsComponent, + canActivate: [roleWhitelistGuard], + data: {mode: 'tutor', roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor']}, + }, + // Deliberately open: 'student' mode lists the signed in user's own enrolled units. {path: 'view-all-projects', component: FUnitsComponent, data: {mode: 'student'}}, + { + path: 'dashboard', + component: CrossDashboardComponent, + canActivate: [roleWhitelistGuard], + data: {roleWhitelist: ['Student'], pageTitle: 'Dashboard'}, + }, { path: 'admin/units', component: FUnitsComponent, @@ -99,10 +190,18 @@ export const routes: Routes = [ canActivate: [roleWhitelistGuard], data: {task: 'Discussion', roleWhitelist: ['Admin', 'Auditor', 'Tutor']}, }, + // Top level, so the guard checks the SYSTEM role here, not a unit role. Convenor is in the + // list because this url is open to system convenors today and guarding it must not take that + // away. It is a wider list than 'tutor-discussion' above, which this change does not touch. { path: 'tutor-attendance', component: TutorDiscussionComponent, - data: {attendance: true, task: 'Check-in'}, + canActivate: [roleWhitelistGuard], + data: { + attendance: true, + task: 'Check-in', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, }, { path: 'units', @@ -116,29 +215,66 @@ export const routes: Routes = [ }, children: [ {path: '', pathMatch: 'full', redirectTo: 'tasks/inbox'}, - {path: 'analytics', component: UnitAnalyticsComponent, data: {task: 'Unit Analytics'}}, - {path: 'students/groups', component: UnitGroupsComponent, data: {task: 'Student Groups'}}, + { + path: 'analytics', + component: UnitAnalyticsComponent, + canActivate: [roleWhitelistGuard], + data: { + task: 'Unit Analytics', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, + }, + { + path: 'students/groups', + component: UnitGroupsComponent, + canActivate: [roleWhitelistGuard], + data: { + task: 'Student Groups', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, + }, { path: 'students/portfolios', component: PortfoliosComponent, - data: {task: 'Student Portfolios'}, + canActivate: [roleWhitelistGuard], + data: { + task: 'Student Portfolios', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, }, { path: 'students/portfolios/:projectId', component: PortfoliosComponent, - data: {task: 'Student Portfolios'}, + canActivate: [roleWhitelistGuard], + data: { + task: 'Student Portfolios', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, }, { path: 'students/portfolios/:projectId/:tab', component: PortfoliosComponent, - data: {task: 'Student Portfolios'}, + canActivate: [roleWhitelistGuard], + data: { + task: 'Student Portfolios', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, }, { path: 'students/portfolios/:projectId/:tab/:taskAbbreviation', component: PortfoliosComponent, - data: {task: 'Student Portfolios'}, + canActivate: [roleWhitelistGuard], + data: { + task: 'Student Portfolios', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, + }, + { + path: 'students', + component: StudentsListComponent, + canActivate: [roleWhitelistGuard], + data: {task: 'Student List', roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor']}, }, - {path: 'students', component: StudentsListComponent, data: {task: 'Student List'}}, { path: 'admin', component: UnitAdminStateComponent, @@ -151,12 +287,31 @@ export const routes: Routes = [ canActivate: [roleWhitelistGuard], data: {task: 'Unit Administration', roleWhitelist: ['Convenor', 'Admin', 'Auditor']}, }, - {path: 'rollover', component: RolloverComponent, data: {task: 'Unit Rollover'}}, - {path: 'discussion', component: TutorDiscussionComponent, data: {task: 'Discussion'}}, + { + path: 'rollover', + component: RolloverComponent, + canActivate: [roleWhitelistGuard], + data: {task: 'Unit Rollover', roleWhitelist: ['Convenor', 'Admin', 'Auditor']}, + }, + // Convenor is in both lists because the staff menu offers Discussion and Check-in to + // every unit role, and the header QR button routes non students straight to + // 'discussion'. Do not narrow these to the 'tutor-discussion' list at the top level: + // that one is matched against the system role, this one against the unit role. + { + path: 'discussion', + component: TutorDiscussionComponent, + canActivate: [roleWhitelistGuard], + data: {task: 'Discussion', roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor']}, + }, { path: 'check-in', component: TutorDiscussionComponent, - data: {attendance: true, task: 'Check-in'}, + canActivate: [roleWhitelistGuard], + data: { + attendance: true, + task: 'Check-in', + roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor'], + }, }, { path: 'tasks', @@ -166,6 +321,7 @@ export const routes: Routes = [ canActivate: [roleWhitelistGuard], }, { + // The guard on this parent runs for every inbox child below it. path: 'tasks', canActivate: [roleWhitelistGuard], data: {roleWhitelist: ['Convenor', 'Admin', 'Auditor', 'Tutor']}, @@ -214,10 +370,15 @@ export const routes: Routes = [ }, ], }, + // Deliberately the same list as the 'tasks' viewer above, which is narrower than the + // inbox children. This is the whole unit task list, not one tutor's queue. A deep link + // meant for a tutor belongs on 'tasks/inbox/:studentId/:taskDefAbbr', which does + // whitelist Tutor. { path: 'tasks/:taskAbbreviation', component: TaskViewerStateComponent, - data: {task: 'Task Lists'}, + canActivate: [roleWhitelistGuard], + data: {task: 'Task Lists', roleWhitelist: ['Convenor', 'Admin', 'Auditor']}, }, ], }, @@ -241,7 +402,7 @@ export const routes: Routes = [ data: {task: 'Dashboard'}, }, { - path: 'dashboard/:taskAbbreviation', + matcher: projectDashboardTaskMatcher, component: ProjectDashboardComponent, data: {task: 'Dashboard'}, }, diff --git a/src/app/common/audio-player/audio-player.component.html b/src/app/common/audio-player/audio-player.component.html index 4b4bf2f632..8bc199a01a 100644 --- a/src/app/common/audio-player/audio-player.component.html +++ b/src/app/common/audio-player/audio-player.component.html @@ -1,14 +1,40 @@ -
- - + +
+
+ @for (peak of waveformPeaks; track $index) { + + } +
+ +
+@if (audioError) { + +} diff --git a/src/app/common/audio-player/audio-player.component.spec.ts b/src/app/common/audio-player/audio-player.component.spec.ts new file mode 100644 index 0000000000..036b80d5d1 --- /dev/null +++ b/src/app/common/audio-player/audio-player.component.spec.ts @@ -0,0 +1,198 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {HttpResponse} from '@angular/common/http'; +import {Subject} from 'rxjs'; +import {FileDownloaderService} from '../file-downloader/file-downloader.service'; +import {AlertService} from '../services/alert.service'; +import {AppLifecycleService, MediaPauseEvent} from '../services/app-lifecycle.service'; +import {AudioPlaybackCoordinatorService} from '../services/audio-playback-coordinator.service'; +import {AudioWaveformService} from '../services/audio-waveform.service'; +import {AudioPlayerComponent} from './audio-player.component'; + +describe('AudioPlayerComponent', () => { + let coordinator: AudioPlaybackCoordinatorService; + + beforeEach(() => { + coordinator = new AudioPlaybackCoordinatorService(); + }); + + it('supports finite seeking, keyboard seeking, end reset, and replay', async () => { + const {component, play} = createPlayer(coordinator); + component.setSrc('blob:short-valid-audio'); + setProgressWidth(component, 100); + + component.seek({offsetX: 50} as MouseEvent); + expect(component.audio.currentTime).toBe(5); + + const preventDefault = vi.fn(); + component.seekWithKeyboard({key: 'ArrowRight', preventDefault} as unknown as KeyboardEvent); + expect(component.audio.currentTime).toBe(5.5); + expect(preventDefault).toHaveBeenCalledOnce(); + + component.play(); + await flushPromises(); + expect(component.isPlaying).toBe(true); + component.audio.onended(new Event('ended')); + expect(component.audio.currentTime).toBe(0); + expect(component.audioProgress).toBe(0); + + component.play(); + await flushPromises(); + expect(play).toHaveBeenCalledTimes(2); + expect(component.isPlaying).toBe(true); + }); + + it('pauses without rewinding the current playback position', async () => { + const {component} = createPlayer(coordinator); + component.setSrc('blob:pause-position'); + component.audio.currentTime = 6; + + component.play(); + await flushPromises(); + component.pause(); + + expect(component.audio.currentTime).toBe(6); + expect(component.isPlaying).toBe(false); + }); + + it('rewinds only when playback is explicitly stopped', () => { + const {component} = createPlayer(coordinator); + component.setSrc('blob:stop-position'); + component.audio.currentTime = 6; + + component.stop(); + + expect(component.audio.currentTime).toBe(0); + expect(component.audioProgress).toBe(0); + }); + + it('allows only one audio message to remain playing', async () => { + const first = createPlayer(coordinator); + const second = createPlayer(coordinator); + first.component.setSrc('blob:first'); + second.component.setSrc('blob:second'); + first.pause.mockClear(); + + first.component.play(); + await flushPromises(); + second.component.play(); + await flushPromises(); + + expect(first.pause).toHaveBeenCalledOnce(); + expect(first.component.isPlaying).toBe(false); + expect(second.component.isPlaying).toBe(true); + }); + + it('enters a recoverable error state for corrupt playback or waveform data', async () => { + const {component, alerts, waveform} = createPlayer(coordinator); + waveform.decodePeaks.mockRejectedValueOnce(new DOMException('bad audio', 'EncodingError')); + component.setSrc('blob:corrupt'); + await flushPromises(); + + expect(component.waveformUnavailable).toBe(true); + component.audio.onerror(new Event('error')); + expect(component.audioError).toContain('could not be played'); + expect(component.isPlaying).toBe(false); + expect(alerts.error).toHaveBeenCalled(); + }); + + it('pauses on a lifecycle event and does not auto-resume', async () => { + const {component, pauseEvents, play, unregister} = createPlayer(coordinator); + component.setSrc('blob:lifecycle'); + component.play(); + await flushPromises(); + + pauseEvents.next({reason: 'app-hidden', occurredAt: Date.now()}); + + expect(component.isPlaying).toBe(false); + expect(play).toHaveBeenCalledOnce(); + component.ngOnDestroy(); + expect(unregister).toHaveBeenCalledOnce(); + }); + + it('drops a play queued behind a download when a lifecycle pause arrives', async () => { + const {component, pauseEvents, play, fileDownloader} = createPlayer(coordinator); + component.audioSrc = {src: '/audio/slow-download'}; + + component.play(); + expect(fileDownloader.downloadBlob).toHaveBeenCalledOnce(); + const onDownloaded = fileDownloader.downloadBlob.mock.calls[0][1] as ( + blobUrl: string, + response: HttpResponse, + ) => void; + + pauseEvents.next({reason: 'app-hidden', occurredAt: Date.now()}); + onDownloaded('blob:late-audio', {} as HttpResponse); + await flushPromises(); + + expect(play).not.toHaveBeenCalled(); + expect(component.isPlaying).toBe(false); + + component.play(); + await flushPromises(); + expect(play).toHaveBeenCalledOnce(); + }); + + it('releases object URLs on replacement and destruction', () => { + const {component, fileDownloader} = createPlayer(coordinator); + component.setSrc('blob:first'); + component.setSrc('blob:second'); + component.ngOnDestroy(); + + expect(fileDownloader.releaseBlob).toHaveBeenCalledWith('blob:first'); + expect(fileDownloader.releaseBlob).toHaveBeenCalledWith('blob:second'); + }); +}); + +function createPlayer(coordinator: AudioPlaybackCoordinatorService) { + const pauseEvents: Subject = new Subject(); + const unregister = vi.fn(); + const fileDownloader = { + releaseBlob: vi.fn(), + downloadBlob: vi.fn(), + }; + const alerts = {error: vi.fn()}; + const waveform = {decodePeaks: vi.fn(() => Promise.resolve([0.2, 0.5, 1]))}; + const component = new AudioPlayerComponent( + fileDownloader as unknown as FileDownloaderService, + alerts as unknown as AlertService, + { + registerMedia: vi.fn(() => unregister), + mediaPauseSubject: pauseEvents, + } as unknown as AppLifecycleService, + coordinator, + waveform as unknown as AudioWaveformService, + ); + + let paused = true; + let currentTime = 0; + Object.defineProperty(component.audio, 'paused', {configurable: true, get: () => paused}); + Object.defineProperty(component.audio, 'duration', {configurable: true, get: () => 10}); + Object.defineProperty(component.audio, 'currentTime', { + configurable: true, + get: () => currentTime, + set: (value: number) => (currentTime = value), + }); + const play = vi.fn(() => { + paused = false; + return Promise.resolve(); + }); + const pause = vi.fn(() => (paused = true)); + component.audio.play = play; + component.audio.pause = pause; + component.audio.load = vi.fn(); + + return {component, play, pause, pauseEvents, unregister, fileDownloader, alerts, waveform}; +} + +function setProgressWidth(component: AudioPlayerComponent, width: number): void { + (component as unknown as {progressBar: {nativeElement: HTMLElement}}).progressBar = { + nativeElement: { + getBoundingClientRect: () => ({width}), + } as unknown as HTMLElement, + }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/src/app/common/audio-player/audio-player.component.ts b/src/app/common/audio-player/audio-player.component.ts index 33e8317ad9..4b57040803 100644 --- a/src/app/common/audio-player/audio-player.component.ts +++ b/src/app/common/audio-player/audio-player.component.ts @@ -10,9 +10,15 @@ import { Output, ViewChild, } from '@angular/core'; +import {Subscription} from 'rxjs'; import {Project, Task, TaskComment} from 'src/app/api/models/doubtfire-model'; import {FileDownloaderService} from '../file-downloader/file-downloader.service'; import {AlertService} from '../services/alert.service'; +import {AppLifecycleService} from '../services/app-lifecycle.service'; +import {AudioPlaybackCoordinatorService} from '../services/audio-playback-coordinator.service'; +import {AudioWaveformService} from '../services/audio-waveform.service'; + +const WAVEFORM_BAR_COUNT = 40; @Component({ selector: 'audio-player', @@ -28,126 +34,258 @@ export class AudioPlayerComponent implements OnDestroy { @Input() audioSrc: {src: string}; @Output() playingChange: EventEmitter = new EventEmitter(); - @ViewChild('progressBar', {read: ElementRef}) private progressBar: ElementRef; + @ViewChild('progressBar', {read: ElementRef}) private progressBar: ElementRef; - private isLoaded = false; public isPlaying = false; public audioProgress = 0; - public audio: HTMLAudioElement = document.createElement('AUDIO') as HTMLAudioElement; + public duration = 0; + public audioError: string | null = null; + public waveformUnavailable = false; + public waveformPeaks: number[] = Array.from({length: WAVEFORM_BAR_COUNT}, () => 0.12); + public audio: HTMLAudioElement = document.createElement('audio'); + + private isLoaded = false; + private isLoading = false; + private destroyed = false; + private sourceGeneration = 0; + private pendingActions: Array<() => void> = []; + private readonly unregisterMedia: () => void; + private readonly lifecycleSubscription: Subscription; constructor( @Inject(FileDownloaderService) private fileDownloader: FileDownloaderService, private alerts: AlertService, + private appLifecycle: AppLifecycleService, + private playbackCoordinator: AudioPlaybackCoordinatorService, + private waveformService: AudioWaveformService, ) { + this.unregisterMedia = this.appLifecycle.registerMedia(this.audio); + this.lifecycleSubscription = this.appLifecycle.mediaPauseSubject.subscribe(() => { + // A play queued behind a slow download must not start after the pause. + this.pendingActions = []; + this.updatePlaying(false); + this.playbackCoordinator.release(this.audio); + }); + this.audio.ontimeupdate = () => { - const percentagePlayed = this.audio.currentTime / this.audio.duration; - this.audioProgress = (isNaN(percentagePlayed) ? 0 : percentagePlayed) * 100; + this.duration = Number.isFinite(this.audio.duration) ? this.audio.duration : 0; + const percentagePlayed = this.duration > 0 ? this.audio.currentTime / this.duration : 0; + this.audioProgress = Math.min(100, Math.max(0, percentagePlayed * 100)); + }; + this.audio.onloadedmetadata = () => { + this.duration = Number.isFinite(this.audio.duration) ? this.audio.duration : 0; + if (this.duration <= 0) { + this.setPlaybackError('This audio message has no playable duration.'); + } }; - this.audio.onended = () => { - this.isPlaying = false; - this.playingChange.emit(false); + this.playbackCoordinator.release(this.audio); + this.updatePlaying(false); + this.audio.currentTime = 0; + this.audioProgress = 0; + }; + this.audio.onerror = () => { + this.playbackCoordinator.release(this.audio); + this.setPlaybackError('This audio message could not be played.'); }; } ngOnDestroy(): void { - // Clean up the blob - if (this.audio.src) { - this.fileDownloader.releaseBlob(this.audio.src); - } - } - - private setTime(percent: number) { - const newTime = percent * this.audio.duration; - this.audio.currentTime = newTime; + this.destroyed = true; + this.sourceGeneration++; + this.audio.pause(); + this.playbackCoordinator.release(this.audio); + this.unregisterMedia(); + this.lifecycleSubscription.unsubscribe(); + this.audio.ontimeupdate = null; + this.audio.onloadedmetadata = null; + this.audio.onended = null; + this.audio.onerror = null; + this.releaseCurrentSource(); + this.pendingActions = []; } - public seek(evt: MouseEvent) { - const offset = evt.offsetX; - const percent = offset / this.progressBar.nativeElement.offsetWidth; - - this.execWithAudio(true, () => { - this.setTime(percent); + public seek(event: MouseEvent): void { + this.withAudio(() => { + const width = this.progressBar?.nativeElement.getBoundingClientRect().width; + if (!width || !Number.isFinite(this.audio.duration) || this.audio.duration <= 0) { + return; + } + this.setProgress(event.offsetX / width); }); } - public setSrc(src: string) { - // If there was an old blob, then free the memory it uses - if (this.audio.src) { - this.fileDownloader.releaseBlob(this.audio.src); + public seekWithKeyboard(event: KeyboardEvent): void { + if (!this.isLoaded || !Number.isFinite(this.audio.duration) || this.audio.duration <= 0) { + return; } + const currentProgress = this.audio.currentTime / this.audio.duration; + let nextProgress = currentProgress; + if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') { + nextProgress -= 0.05; + } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') { + nextProgress += 0.05; + } else if (event.key === 'Home') { + nextProgress = 0; + } else if (event.key === 'End') { + nextProgress = 1; + } else { + return; + } + event.preventDefault(); + this.setProgress(nextProgress); + } + + public setSrc(src: string): void { + this.sourceGeneration++; + const generation = this.sourceGeneration; + this.audio.pause(); + this.playbackCoordinator.release(this.audio); + this.releaseCurrentSource(); this.isLoaded = true; - this.isPlaying = false; - this.playingChange.emit(false); + this.isLoading = false; + this.audioError = null; + this.waveformUnavailable = false; + this.duration = 0; + this.audioProgress = 0; + this.updatePlaying(false); this.audio.src = src; this.audio.load(); - this.audio.onloadeddata = () => { - this.setTime(0); - }; + void this.loadWaveform(src, generation); + } + + public play(): void { + this.withAudio(() => void this.startPlayback()); + } + + public pause(): void { + this.audio.pause(); + this.playbackCoordinator.release(this.audio); + this.updatePlaying(false); + } + + public stop(): void { + this.pause(); + this.audio.currentTime = 0; this.audioProgress = 0; } - private execWithAudio(onLoad: boolean, fn: () => void) { - if (this.isLoaded) { - fn(); + public pausePlay(): void { + if (this.isLoaded && !this.audio.paused) { + this.pause(); } else { - let url: string; - if (this.project && this.task && this.comment) { - url = this.comment.attachmentUrl; - } else if (this.audioSrc) { - url = this.audioSrc.src; - } + this.play(); + } + } - this.fileDownloader.downloadBlob( - url, - ((blobUrl: string, _response: HttpResponse) => { - this.isLoaded = true; - this.setSrc(blobUrl); - this.audio.src = blobUrl; - this.audio.load(); - if (onLoad) { - this.audio.onloadeddata = () => { - fn(); - }; - } else { - fn(); - } - }).bind(this), - ((error: Error) => { - this.alerts.error(`Error loading audio. ${error}`, 6000); - }).bind(this), - ); + public isBarPlayed(index: number): boolean { + return ((index + 1) / this.waveformPeaks.length) * 100 <= this.audioProgress; + } + + public formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '0:00'; } + const wholeSeconds = Math.floor(seconds); + return `${Math.floor(wholeSeconds / 60)}:${String(wholeSeconds % 60).padStart(2, '0')}`; } - public play() { - this.execWithAudio( - true, - (() => { - this.audio.play(); - this.isPlaying = true; - this.playingChange.emit(true); - }).bind(this), - ); + private setProgress(progress: number): void { + const clamped = Math.min(1, Math.max(0, progress)); + this.audio.currentTime = clamped * this.audio.duration; + this.audioProgress = clamped * 100; } - public stop() { - this.audio.pause(); - this.audio.currentTime = 0; - this.isPlaying = false; - this.playingChange.emit(false); - } - - public pausePlay() { - this.execWithAudio( - true, - (() => { - if (this.audio.paused) { - this.play(); - } else { - this.stop(); + private withAudio(action: () => void): void { + if (this.isLoaded) { + action(); + return; + } + this.pendingActions.push(action); + if (this.isLoading) { + return; + } + + let url: string; + if (this.project && this.task && this.comment) { + url = this.comment.attachmentUrl; + } else if (this.audioSrc) { + url = this.audioSrc.src; + } + if (!url) { + this.pendingActions = []; + this.setPlaybackError('This audio message has no source.'); + return; + } + + this.isLoading = true; + this.fileDownloader.downloadBlob( + url, + (blobUrl: string, _response: HttpResponse) => { + if (this.destroyed) { + this.fileDownloader.releaseBlob(blobUrl); + return; } - }).bind(this), + this.setSrc(blobUrl); + const actions = this.pendingActions; + this.pendingActions = []; + actions.forEach((pendingAction) => pendingAction()); + }, + (error: Error) => { + this.isLoading = false; + this.pendingActions = []; + this.setPlaybackError(`Error loading audio. ${error}`); + }, ); } + + private async startPlayback(): Promise { + this.audioError = null; + this.playbackCoordinator.activate(this.audio, () => this.updatePlaying(false)); + try { + await Promise.resolve(this.audio.play()); + if (this.playbackCoordinator.isActive(this.audio)) { + this.updatePlaying(true); + } + } catch (error) { + this.playbackCoordinator.release(this.audio); + this.setPlaybackError(`This audio message could not be played. ${String(error)}`); + } + } + + private async loadWaveform(src: string, generation: number): Promise { + try { + const peaks = await this.waveformService.decodePeaks(src, WAVEFORM_BAR_COUNT); + if (!this.destroyed && generation === this.sourceGeneration) { + this.waveformPeaks = peaks; + } + } catch { + if (!this.destroyed && generation === this.sourceGeneration) { + // Playback can still work when Web Audio decoding is unavailable. + this.waveformUnavailable = true; + this.waveformPeaks = Array.from({length: WAVEFORM_BAR_COUNT}, () => 0.12); + } + } + } + + private setPlaybackError(message: string): void { + this.audioError = message; + this.updatePlaying(false); + this.alerts.error(message, 6000); + } + + private updatePlaying(isPlaying: boolean): void { + if (this.isPlaying === isPlaying) { + return; + } + this.isPlaying = isPlaying; + this.playingChange.emit(isPlaying); + } + + private releaseCurrentSource(): void { + if (this.audio.src) { + this.fileDownloader.releaseBlob(this.audio.src); + this.audio.removeAttribute('src'); + } + } } diff --git a/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.html b/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.html index 736059be22..df862c2828 100644 --- a/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.html +++ b/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.html @@ -1,27 +1,20 @@ -
+

Audio recording
only supported in modern versions of Chrome, Firefox and Safari.

- -
+
- - - -
- + +
+
+ + @if (recordingAvailable) { + Audio staged. Use the composer Send button to post it. + }
+@if (recordingError) { + +} diff --git a/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.ts b/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.ts index ca4798e5d3..155335b292 100644 --- a/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.ts +++ b/src/app/common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder.ts @@ -1,8 +1,18 @@ -import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; -import {Task, TaskComment, TaskCommentService} from 'src/app/api/models/doubtfire-model'; -import {AlertService} from 'src/app/common/services/alert.service'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core'; +import {Task} from 'src/app/api/models/doubtfire-model'; +import {AppLifecycleService} from 'src/app/common/services/app-lifecycle.service'; +import {AudioPlaybackCoordinatorService} from 'src/app/common/services/audio-playback-coordinator.service'; import {MediaRecorderService} from 'src/app/common/services/recorder-service'; -import {BaseAudioRecorderComponent} from '../base-audio-recorder'; +import {BaseAudioRecorderComponent, RecordingEvent} from '../base-audio-recorder'; @Component({ selector: 'audio-comment-recorder', @@ -11,20 +21,27 @@ import {BaseAudioRecorderComponent} from '../base-audio-recorder'; changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class AudioCommentRecorderComponent extends BaseAudioRecorderComponent implements OnInit { +export class AudioCommentRecorderComponent + extends BaseAudioRecorderComponent + implements AfterViewInit +{ @Input() task: Task; + @Output() recordingReady: EventEmitter = new EventEmitter(); + @ViewChild('audioRecorderVisualiser') canvasRef: ElementRef; + @ViewChild('audioRecorderPlayer') audioRef: ElementRef; canvas: HTMLCanvasElement; canvasCtx: CanvasRenderingContext2D; isSending: boolean = false; constructor( private mediaRecorderService: MediaRecorderService, - private alerts: AlertService, - private ts: TaskCommentService, + playbackCoordinator: AudioPlaybackCoordinatorService, + appLifecycle: AppLifecycleService, ) { - super(mediaRecorderService); + super(mediaRecorderService, playbackCoordinator, appLifecycle); } - ngOnInit() { + + ngAfterViewInit(): void { if (this.canRecord) { this.init(); } @@ -32,37 +49,21 @@ export class AudioCommentRecorderComponent extends BaseAudioRecorderComponent im init(): void { super.init(); - this.canvas = document.getElementById('audio-recorder-visualiser') as HTMLCanvasElement; - this.audio = document.getElementById('audioPlayer') as HTMLAudioElement; + this.canvas = this.canvasRef.nativeElement; + this.attachAudio(this.audioRef.nativeElement); this.canvasCtx = this.canvas.getContext('2d'); } - sendRecording(): void { - this.isSending = true; - if (this.blob && this.blob.size > 0) { - this.ts.addComment(this.task, this.blob, 'audio').subscribe({ - next: (_comment: TaskComment) => { - this.isSending = false; - this.scrollCommentsDown(); - }, - error: (failure: {data: {error: string}}) => { - this.alerts.error( - `Failed to post audio. ${failure.data != null ? failure.data.error : undefined}`, - ); - this.isSending = false; - }, - }); - - this.blob = {} as Blob; - this.recordingAvailable = false; + override onNewRecording(event: RecordingEvent): void { + super.onNewRecording(event); + if (this.recordingAvailable && this.blob.size > 0) { + this.recordingReady.emit(this.blob); } } - private scrollCommentsDown(): void { - setTimeout(() => { - const objDiv = document.querySelector('div.comments-body'); - // let wrappedResult = angular.element(objDiv); - objDiv.scrollTop = objDiv.scrollHeight; - }, 50); + sendRecording(): void { + if (this.recordingAvailable && this.blob.size > 0) { + this.recordingReady.emit(this.blob); + } } } diff --git a/src/app/common/audio-recorder/audio/base-audio-recorder.ts b/src/app/common/audio-recorder/audio/base-audio-recorder.ts index 43942d7d9b..d705cc62a2 100644 --- a/src/app/common/audio-recorder/audio/base-audio-recorder.ts +++ b/src/app/common/audio-recorder/audio/base-audio-recorder.ts @@ -1,88 +1,164 @@ -import {Directive} from '@angular/core'; -import {MediaRecorderService} from 'src/app/common/services/recorder-service'; +import {Directive, OnDestroy} from '@angular/core'; +import {Subscription} from 'rxjs'; +import {AppLifecycleService} from 'src/app/common/services/app-lifecycle.service'; +import {AudioPlaybackCoordinatorService} from 'src/app/common/services/audio-playback-coordinator.service'; +import {MediaRecorderService, RecorderFailureEvent} from 'src/app/common/services/recorder-service'; export interface RecordingEvent extends Event { detail: { recording: { blob: Blob; - blobUrl: string; }; }; } @Directive() -export abstract class BaseAudioRecorderComponent { +export abstract class BaseAudioRecorderComponent implements OnDestroy { protected mediaRecorder: MediaRecorderService = null; - public recordingAvailable: boolean = false; - public isRecording: boolean = false; - protected isPlaying: boolean = false; + public recordingAvailable = false; + public isRecording = false; + public isRequestingPermission = false; + public recordingError: string | null = null; + public playbackProgress = 0; + protected isPlaying = false; protected audio: HTMLAudioElement; abstract isSending; abstract canvas: HTMLCanvasElement; abstract canvasCtx: CanvasRenderingContext2D; - protected blob: Blob; + protected blob: Blob = new Blob(); + protected capturedWaveform: number[] = []; + protected animationFrameId?: number; + private previewUrl?: string; + private unregisterMedia?: () => void; + private lifecycleSubscription?: Subscription; + private destroyed = false; + + private readonly recordingListener = (event: Event) => + this.onNewRecording(event as RecordingEvent); + private readonly recorderErrorListener = (event: Event) => + this.onRecorderError(event as RecorderFailureEvent); + private readonly audioTimeUpdateListener = () => { + const duration = this.audio?.duration; + this.playbackProgress = + Number.isFinite(duration) && duration > 0 + ? Math.min(100, Math.max(0, (this.audio.currentTime / duration) * 100)) + : 0; + this.drawCapturedWaveform(); + }; + private readonly audioEndedListener = () => { + this.isPlaying = false; + this.playbackCoordinator?.release(this.audio); + if (this.audio) { + this.audio.currentTime = 0; + } + this.playbackProgress = 0; + this.drawCapturedWaveform(); + }; + private readonly audioErrorListener = () => { + this.isPlaying = false; + this.playbackCoordinator?.release(this.audio); + this.recordingError = 'This recording could not be played. Record it again and retry.'; + }; get canRecord(): boolean { - return Boolean(navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + return Boolean(navigator?.mediaDevices?.getUserMedia); } - constructor(private recorderService: MediaRecorderService) {} + constructor( + private recorderService: MediaRecorderService, + private playbackCoordinator?: AudioPlaybackCoordinatorService, + private appLifecycle?: AppLifecycleService, + ) { + this.lifecycleSubscription = this.appLifecycle?.mediaPauseSubject.subscribe(() => + this.onLifecyclePause(), + ); + } + + /** + * Backgrounding the app or leaving the route ends the take, so the microphone + * is not left open. Subclasses whose stop has side effects can override this. + */ + protected onLifecyclePause(): void { + this.isPlaying = false; + if (this.isRecording || this.isRequestingPermission) { + this.stopRecording(); + } + } protected init(): void { + this.destroyed = false; this.isSending = false; this.blob = new Blob(); this.mediaRecorder = this.recorderService; - // Required for recording multiple times this.mediaRecorder.config.stopTracksAndCloseCtxWhenFinished = true; - // Required for visualising the stream this.mediaRecorder.config.createAnalyserNode = true; - this.mediaRecorder.em.addEventListener('recording', (evt: Event) => - this.onNewRecording(evt as RecordingEvent), - ); + this.mediaRecorder.em.addEventListener('recording', this.recordingListener); + this.mediaRecorder.em.addEventListener('error', this.recorderErrorListener); } - playStop(): void { - if (this.audio.paused) { - this.audio.play(); - this.isPlaying = true; - } else { - this.audio.pause(); - this.audio.currentTime = 0; - this.isPlaying = false; - } + protected attachAudio(audio: HTMLAudioElement): void { + this.detachAudio(); + this.audio = audio; + this.audio.addEventListener('timeupdate', this.audioTimeUpdateListener); + this.audio.addEventListener('ended', this.audioEndedListener); + this.audio.addEventListener('error', this.audioErrorListener); + this.unregisterMedia = this.appLifecycle?.registerMedia(this.audio); } - recordingToggle(): void { - if (!this.isRecording) { - this.mediaRecorder.startRecording(); - this.visualise(); - this.isRecording = true; - } else if (this.isRecording) { - this.mediaRecorder.stopRecording(); - this.isRecording = false; + async playStop(): Promise { + if (!this.audio || !this.recordingAvailable) { + return; } - } - - stopRecording() { if (!this.audio.paused) { - this.audio.pause(); - this.audio.currentTime = 0; + this.pausePreview(); + return; + } + + this.recordingError = null; + this.playbackCoordinator?.activate(this.audio, () => { + this.isPlaying = false; + this.drawCapturedWaveform(); + }); + try { + await Promise.resolve(this.audio.play()); + if (!this.playbackCoordinator || this.playbackCoordinator.isActive(this.audio)) { + this.isPlaying = true; + } + } catch { + this.playbackCoordinator?.release(this.audio); this.isPlaying = false; + this.recordingError = 'This recording could not be played. Record it again and retry.'; } + } + recordingToggle(): void { + if (this.isRequestingPermission) { + this.mediaRecorder.cancelRecording(); + this.isRequestingPermission = false; + return; + } if (this.isRecording) { - this.mediaRecorder.stopRecording(); - this.isRecording = false; + this.finishRecording(); + } else { + void this.beginRecording(); + } + } + + stopRecording(): void { + this.pausePreview(); + if (this.isRequestingPermission) { + this.mediaRecorder.cancelRecording(); + this.isRequestingPermission = false; + } else if (this.isRecording) { + this.finishRecording(); } } startRecording(): void { - if (!this.isRecording) { - this.mediaRecorder.startRecording(); - this.visualise(); - this.isRecording = true; + if (!this.isRecording && !this.isRequestingPermission) { + void this.beginRecording(); } } @@ -90,50 +166,240 @@ export abstract class BaseAudioRecorderComponent { this.mediaRecorder.processChunks(); } - onNewRecording(evt: RecordingEvent): void { - this.blob = evt.detail.recording.blob; - this.audio.src = evt.detail.recording.blobUrl; - this.audio.load(); + onNewRecording(event: RecordingEvent): void { + const recording = event.detail.recording.blob; + this.isRecording = false; + this.isRequestingPermission = false; + this.stopVisualisation(); + + if (!recording || recording.size === 0) { + this.recordingAvailable = false; + this.recordingError = 'The recording was empty. Record it again and retry.'; + this.drawCapturedWaveform(); + return; + } + + this.blob = recording; + this.replacePreviewUrl(URL.createObjectURL(recording)); this.recordingAvailable = true; + this.recordingError = null; + this.playbackProgress = 0; + this.drawCapturedWaveform(); + } + + public seekPreview(event: MouseEvent): void { + if ( + !this.audio || + !this.canvas || + !Number.isFinite(this.audio.duration) || + this.audio.duration <= 0 + ) { + return; + } + const width = this.canvas.getBoundingClientRect().width || this.canvas.clientWidth; + if (width <= 0) { + return; + } + this.setPreviewProgress(event.offsetX / width); + } + + public seekPreviewWithKeyboard(event: KeyboardEvent): void { + if (!this.audio || !Number.isFinite(this.audio.duration) || this.audio.duration <= 0) { + return; + } + const current = this.audio.currentTime / this.audio.duration; + let next = current; + if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') { + next -= 0.05; + } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') { + next += 0.05; + } else if (event.key === 'Home') { + next = 0; + } else if (event.key === 'End') { + next = 1; + } else { + return; + } + event.preventDefault(); + this.setPreviewProgress(next); + } + + ngOnDestroy(): void { + this.destroyed = true; + this.stopVisualisation(); + this.mediaRecorder?.em.removeEventListener('recording', this.recordingListener); + this.mediaRecorder?.em.removeEventListener('error', this.recorderErrorListener); + this.mediaRecorder?.dispose(); + this.lifecycleSubscription?.unsubscribe(); + this.lifecycleSubscription = undefined; + this.pausePreview(); + this.detachAudio(); + this.releasePreviewUrl(); + } + + protected discardRecording(): void { + this.pausePreview(); + this.blob = new Blob(); + this.recordingAvailable = false; + this.playbackProgress = 0; + this.capturedWaveform = []; + this.releasePreviewUrl(); + this.drawCapturedWaveform(); } - // virtual implementation of visualise - // Which can be overridden protected visualise(): void { + const analyser = this.mediaRecorder.analyserNode; + if (!analyser) { + return; + } + analyser.fftSize = 2048; + const samples = new Uint8Array(analyser.fftSize); + this.capturedWaveform = []; + const draw = () => { - let WIDTH: number; - let HEIGHT: number; - - this.canvas.width = 1; - this.canvas.height = 1; - - this.canvas.width = WIDTH = this.canvas.clientWidth; - this.canvas.height = HEIGHT = this.canvas.clientHeight; - requestAnimationFrame(draw); - analyser.getByteTimeDomainData(dataArray); - analyser.getByteFrequencyData(dataArray); - - this.canvasCtx.clearRect(0, 0, 300, HEIGHT); - - const bar_width = 2; - const bar_gap = 2; - - for (let i = 0; i < WIDTH; i++) { - const bar_x = i * (bar_width + bar_gap); - const bar_y = HEIGHT / 2; - const bar_height = -(dataArray[i] / 8) + 1; - this.canvasCtx.fillStyle = 'white'; - this.canvasCtx.fillRect(bar_x, bar_y, bar_width, bar_height); - this.canvasCtx.fillRect(bar_x, bar_y - bar_height, bar_width, bar_height); + if (!this.isRecording || this.destroyed) { + this.animationFrameId = undefined; + return; + } + analyser.getByteTimeDomainData(samples); + let peak = 0; + for (const sample of samples) { + peak = Math.max(peak, Math.abs(sample - 128) / 128); } + this.capturedWaveform.push(peak); + if (this.capturedWaveform.length > 48) { + this.capturedWaveform.shift(); + } + this.drawCapturedWaveform(); + this.scheduleVisualisationFrame(draw); }; - - const analyser = this.mediaRecorder.analyserNode; - analyser.fftSize = 2048; - const bufferLength = analyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); draw(); } + protected stopVisualisation(): void { + if (this.animationFrameId !== undefined) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + } + + protected scheduleVisualisationFrame(callback: FrameRequestCallback): void { + if (this.isRecording && !this.destroyed) { + this.animationFrameId = requestAnimationFrame(callback); + } + } + protected abstract sendRecording(): void; + + private async beginRecording(): Promise { + this.discardRecording(); + this.recordingError = null; + this.isRequestingPermission = true; + try { + await this.mediaRecorder.startRecording(); + this.isRequestingPermission = false; + if (this.mediaRecorder.state === 'recording' && !this.destroyed) { + this.isRecording = true; + this.visualise(); + } + } catch (error) { + this.isRequestingPermission = false; + this.isRecording = false; + this.recordingError = this.describeRecorderError(error); + } + } + + private finishRecording(): void { + this.isRecording = false; + this.stopVisualisation(); + this.mediaRecorder.stopRecording(); + this.drawCapturedWaveform(); + } + + private onRecorderError(event: RecorderFailureEvent): void { + this.isRequestingPermission = false; + this.isRecording = false; + this.stopVisualisation(); + this.recordingError = this.describeRecorderError(event.detail.error); + } + + private describeRecorderError(error: unknown): string { + const errorName = (error as {name?: string})?.name; + if (errorName === 'NotAllowedError' || errorName === 'SecurityError') { + return 'Microphone permission was denied. Allow microphone access, then try again.'; + } + if (errorName === 'NotFoundError') { + return 'No microphone was found. Connect a microphone, then try again.'; + } + return 'Audio recording could not start. Check the microphone and try again.'; + } + + private pausePreview(): void { + if (!this.audio) { + return; + } + this.audio.pause(); + this.playbackCoordinator?.release(this.audio); + this.isPlaying = false; + } + + private setPreviewProgress(progress: number): void { + const clamped = Math.min(1, Math.max(0, progress)); + this.audio.currentTime = clamped * this.audio.duration; + this.playbackProgress = clamped * 100; + this.drawCapturedWaveform(); + } + + private replacePreviewUrl(url: string): void { + this.releasePreviewUrl(); + this.previewUrl = url; + if (this.audio) { + this.audio.src = url; + this.audio.load(); + } + } + + private releasePreviewUrl(): void { + if (this.previewUrl) { + URL.revokeObjectURL(this.previewUrl); + if (this.audio?.src === this.previewUrl) { + this.audio.removeAttribute('src'); + this.audio.load(); + } + this.previewUrl = undefined; + } + } + + private detachAudio(): void { + if (!this.audio) { + return; + } + this.playbackCoordinator?.release(this.audio); + this.unregisterMedia?.(); + this.unregisterMedia = undefined; + this.audio.removeEventListener('timeupdate', this.audioTimeUpdateListener); + this.audio.removeEventListener('ended', this.audioEndedListener); + this.audio.removeEventListener('error', this.audioErrorListener); + } + + private drawCapturedWaveform(): void { + if (!this.canvas || !this.canvasCtx) { + return; + } + this.canvas.width = this.canvas.clientWidth || this.canvas.width; + this.canvas.height = this.canvas.clientHeight || this.canvas.height; + const width = this.canvas.width; + const height = this.canvas.height; + const values = this.capturedWaveform.length > 0 ? this.capturedWaveform : [0]; + const barWidth = Math.max(1, width / values.length - 2); + + this.canvasCtx.clearRect(0, 0, width, height); + values.forEach((value, index) => { + const x = (index * width) / values.length; + const amplitude = Math.max(2, Math.min(height, value * height)); + const played = this.isRecording || x / Math.max(1, width) <= this.playbackProgress / 100; + this.canvasCtx.fillStyle = played ? '#ffffff' : 'rgba(255, 255, 255, 0.45)'; + this.canvasCtx.fillRect(x, (height - amplitude) / 2, barWidth, amplitude); + }); + } } diff --git a/src/app/common/empty-state/collection-empty-states.component.spec.ts b/src/app/common/empty-state/collection-empty-states.component.spec.ts new file mode 100644 index 0000000000..406f1b3cc3 --- /dev/null +++ b/src/app/common/empty-state/collection-empty-states.component.spec.ts @@ -0,0 +1,204 @@ +import {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 {FormsModule} from '@angular/forms'; +import {MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {MatMenuModule} from '@angular/material/menu'; +import {Subject, of} from 'rxjs'; +import {DiscussionPrompt} from 'src/app/api/models/discussion-prompt'; +import { + ActivityTypeService, + Engagement, + EngagementCommentService, + EngagementService, + Unit, + UserService, +} from 'src/app/api/models/doubtfire-model'; +import {DiscussionPromptService} from 'src/app/api/services/discussion-prompt.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {HumanizedDatePipe} from 'src/app/common/pipes/humanized-date.pipe'; +import {LocalizedDatePipe} from 'src/app/common/pipes/localized-date.pipe'; +import {MarkedPipe} from 'src/app/common/pipes/marked.pipe'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {SkeletonLoaderComponent} from 'src/app/common/skeleton-loader/skeleton-loader.component'; +import {EngagementDetailDialogComponent} from 'src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/engagement-detail-dialog/engagement-detail-dialog.component'; +import {DiscussionPromptsComponent} from 'src/app/projects/states/discussion-prompts/discussion-prompts.component'; +import {CommunicationSchedulesComponent} from 'src/app/units/states/edit/directives/unit-communications-editor/communication-schedule-modal/communication-schedules.component'; +import {UnitTutorialsManagerComponent} from 'src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component'; +import {EmptyStateComponent} from './empty-state.component'; + +describe('Tutorial stream empty state', () => { + it('keeps tutorial creation available and replaces the message after a stream is added', async () => { + await TestBed.configureTestingModule({ + declarations: [UnitTutorialsManagerComponent], + imports: [EmptyStateComponent, MatMenuModule], + providers: [ + {provide: ActivityTypeService, useValue: {query: () => of([])}}, + {provide: AlertService, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + const fixture = TestBed.createComponent(UnitTutorialsManagerComponent); + fixture.componentRef.setInput('unit', {tutorialStreams: [], tutorials: []} as unknown as Unit); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state').textContent).toContain( + 'No tutorial streams yet', + ); + expect(fixture.nativeElement.querySelector('button').textContent).toContain('Add a stream'); + fixture.componentRef.setInput('unit', { + tutorialStreams: [{id: 1}], + tutorials: [], + } as unknown as Unit); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + expect(fixture.nativeElement.querySelector('df-unit-tutorials-list')).not.toBeNull(); + }); +}); + +describe('Discussion prompt states', () => { + let result: Subject; + beforeEach(async () => { + result = new Subject(); + await TestBed.configureTestingModule({ + declarations: [DiscussionPromptsComponent], + imports: [EmptyStateComponent, SkeletonLoaderComponent], + providers: [ + { + provide: DiscussionPromptService, + useValue: {loadDiscussionPromptsForPoject: () => result}, + }, + {provide: UserService, useValue: {}}, + {provide: AlertService, useValue: {}}, + {provide: ConfirmationModalService, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + it('waits for an empty response before showing an empty state', () => { + const fixture = TestBed.createComponent(DiscussionPromptsComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-skeleton-loader')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain( + 'Loading discussion prompts', + ); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + result.next([]); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-skeleton-loader')).toBeNull(); + expect(fixture.nativeElement.querySelector('f-empty-state').textContent).toContain( + 'No discussion prompts', + ); + }); + + it('shows a retrieval error instead of an empty collection', () => { + const fixture = TestBed.createComponent(DiscussionPromptsComponent); + fixture.detectChanges(); + result.error(new Error('Unavailable')); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'Unable to load', + ); + }); + + it('retains prompt content after a populated response', () => { + const fixture = TestBed.createComponent(DiscussionPromptsComponent); + fixture.detectChanges(); + result.next([ + { + taskDefinition: {name: 'Task', abbreviation: '1.1P'}, + content: 'Explain your approach', + priorityLabel: 'Normal', + }, + ] as unknown as DiscussionPrompt[]); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Explain your approach'); + }); +}); + +describe('Engagement comment states', () => { + let result: Subject; + const engagement = {comments: [], hasAttachment: false} as unknown as Engagement; + beforeEach(async () => { + result = new Subject(); + await TestBed.configureTestingModule({ + declarations: [ + EngagementDetailDialogComponent, + HumanizedDatePipe, + LocalizedDatePipe, + MarkedPipe, + ], + imports: [CommonModule, FormsModule, EmptyStateComponent], + providers: [ + {provide: MAT_DIALOG_DATA, useValue: {engagement}}, + {provide: EngagementService, useValue: {loadEngagement: () => result}}, + {provide: EngagementCommentService, useValue: {}}, + {provide: FileDownloaderService, useValue: {}}, + {provide: AlertService, useValue: {}}, + {provide: ConfirmationModalService, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + it('shows no-comments copy only after successful retrieval and keeps the composer', () => { + const fixture = TestBed.createComponent(EngagementDetailDialogComponent); + vi.spyOn(fixture.componentInstance, 'scrollToBottom').mockImplementation(() => {}); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + result.next(engagement); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state').textContent).toContain( + 'No comments yet', + ); + expect(fixture.nativeElement.querySelector('textarea')).not.toBeNull(); + }); + + it('preserves the failed-retrieval message without claiming there are no comments', () => { + const fixture = TestBed.createComponent(EngagementDetailDialogComponent); + fixture.detectChanges(); + result.error(new Error('Unavailable')); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Unable to load this engagement.'); + }); +}); + +describe('Communication schedule empty state', () => { + it('replaces the empty message with the existing schedule actions when populated', async () => { + await TestBed.configureTestingModule({ + declarations: [CommunicationSchedulesComponent], + imports: [CommonModule, EmptyStateComponent], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + const editSchedule = vi.fn(); + const fixture = TestBed.createComponent(CommunicationSchedulesComponent); + fixture.componentRef.setInput('editor', { + setPreviewLoading: false, + scheduleTrackId: () => 1, + scheduleSummary: () => 'Weekly', + scheduleAnchorSummary: () => '', + scheduleTimeSummary: () => '', + scheduleNextRunSummary: () => '', + scheduleLastRunSummary: () => '', + editSchedule, + }); + fixture.componentRef.setInput('set', {schedules: []}); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state').textContent).toContain( + 'No schedules yet', + ); + const schedule = {name: 'Weekly reminder', active: true}; + const set = {schedules: [schedule]}; + fixture.componentRef.setInput('set', set); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Weekly reminder'); + fixture.nativeElement.querySelector('button').click(); + expect(editSchedule).toHaveBeenCalledWith(set, schedule); + }); +}); diff --git a/src/app/common/empty-state/empty-state.component.html b/src/app/common/empty-state/empty-state.component.html new file mode 100644 index 0000000000..3fafcae0f7 --- /dev/null +++ b/src/app/common/empty-state/empty-state.component.html @@ -0,0 +1,19 @@ +
+ @if (icon) { + + } + @if (heading) { +

{{ heading }}

+

{{ message }}

+ } @else { +

{{ message }}

+ } + @if (hint) { +

{{ hint }}

+ } + @if (actionLabel) { + + } +
diff --git a/src/app/common/empty-state/empty-state.component.scss b/src/app/common/empty-state/empty-state.component.scss new file mode 100644 index 0000000000..74cd92008a --- /dev/null +++ b/src/app/common/empty-state/empty-state.component.scss @@ -0,0 +1,10 @@ +:host { + display: block; +} + +.empty-state-icon { + height: 48px; + width: 48px; + font-size: 48px; + color: var(--ot-color-text-muted); +} diff --git a/src/app/common/empty-state/empty-state.component.spec.ts b/src/app/common/empty-state/empty-state.component.spec.ts new file mode 100644 index 0000000000..586d2eb055 --- /dev/null +++ b/src/app/common/empty-state/empty-state.component.spec.ts @@ -0,0 +1,84 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {EmptyStateComponent} from './empty-state.component'; + +describe('EmptyStateComponent', () => { + let component: EmptyStateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmptyStateComponent, NoopAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(EmptyStateComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the icon, heading and message it is given', () => { + component.icon = 'inbox'; + component.heading = 'Nothing here yet'; + component.message = 'New items will show up in this list.'; + fixture.detectChanges(); + + const root: HTMLElement = fixture.nativeElement; + expect(root.querySelector('mat-icon').textContent.trim()).toBe('inbox'); + expect(root.textContent).toContain('Nothing here yet'); + expect(root.textContent).toContain('New items will show up in this list.'); + }); + + it('renders the message and hint when there is no heading', () => { + component.icon = 'inbox'; + component.message = 'Nothing here yet'; + component.hint = 'New items will show up in this list.'; + fixture.detectChanges(); + + const root: HTMLElement = fixture.nativeElement; + expect(root.querySelector('mat-icon').textContent.trim()).toBe('inbox'); + expect(root.textContent).toContain('Nothing here yet'); + expect(root.textContent).toContain('New items will show up in this list.'); + }); + + it('renders no action when no action label is given', () => { + component.icon = 'inbox'; + component.heading = 'Nothing here yet'; + component.message = 'New items will show up in this list.'; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('button')).toBeNull(); + }); + + it('renders the action and emits it on click, only when a label is given', () => { + component.icon = 'inbox'; + component.heading = 'Nothing here yet'; + component.message = 'New items will show up in this list.'; + component.actionLabel = 'Refresh'; + fixture.detectChanges(); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button).not.toBeNull(); + expect(button.textContent).toContain('Refresh'); + + const emitted = vi.fn(); + component.action.subscribe(emitted); + button.click(); + + expect(emitted).toHaveBeenCalledTimes(1); + }); + + it('marks the icon decorative so it is not announced twice alongside the heading', () => { + component.icon = 'inbox'; + component.heading = 'Nothing here yet'; + component.message = 'New items will show up in this list.'; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('mat-icon').getAttribute('aria-hidden')).toBe( + 'true', + ); + }); +}); diff --git a/src/app/common/empty-state/empty-state.component.ts b/src/app/common/empty-state/empty-state.component.ts new file mode 100644 index 0000000000..624bccb0e7 --- /dev/null +++ b/src/app/common/empty-state/empty-state.component.ts @@ -0,0 +1,36 @@ +import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; + +/** + * Small, centered empty-state block for tables and lists that would otherwise + * show a header and blank space: an icon, a main line, an optional supporting + * line and an optional action. Colours come from the theme tokens so it flips + * light/dark for free. + * + * Two ways to fill it. With a heading, the heading is the main line and the + * message supports it. Without one, the message is the main line and the hint + * supports it. + * + * Render it only when the collection is confirmed empty, not while it is still + * loading and not when the load failed - "you have none" and "we could not find + * out" are different states and only one of them has a way out. + */ +@Component({ + selector: 'f-empty-state', + standalone: true, + imports: [MatIconModule, MatButtonModule], + templateUrl: './empty-state.component.html', + styleUrl: './empty-state.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, +}) +export class EmptyStateComponent { + @Input() icon?: string; + @Input() heading?: string; + @Input() message = 'Nothing to show'; + @Input() hint?: string; + + /** Omit to render no action. */ + @Input() actionLabel?: string; + @Output() action: EventEmitter = new EventEmitter(); +} diff --git a/src/app/common/entity-form/entity-form.component.spec.ts b/src/app/common/entity-form/entity-form.component.spec.ts new file mode 100644 index 0000000000..8faff9504f --- /dev/null +++ b/src/app/common/entity-form/entity-form.component.spec.ts @@ -0,0 +1,44 @@ +import {describe, expect, it, vi} from 'vitest'; +import {EntityFormComponent} from './entity-form.component'; + +// EntityFormComponent is an abstract base. We exercise submit() on a bare +// instance and stub the collaborators the two feedback branches touch, rather +// than standing a concrete subclass up in a TestBed. +interface BareEntityForm { + formData: unknown; + selected: unknown; + hasChanges: () => boolean; + entityName: string; + submit(service: unknown, alertService: unknown, success: unknown): void; +} + +describe('EntityFormComponent submit feedback', () => { + const bare = () => Object.create(EntityFormComponent.prototype) as BareEntityForm; + + it('uses a neutral message, not a red error, when nothing changed', () => { + const component = bare(); + component.formData = {valid: true}; + component.selected = {id: 1}; + component.hasChanges = () => false; + component.entityName = 'Tutorial'; + const alertService = {message: vi.fn(), error: vi.fn(), success: vi.fn()}; + + component.submit({}, alertService, vi.fn()); + + expect(alertService.message).toHaveBeenCalledWith('Tutorial was not changed', 6000); + expect(alertService.error).not.toHaveBeenCalled(); + }); + + it('tells the user the save was blocked when the form is invalid', () => { + const component = bare(); + const markAllAsTouched = vi.fn(); + component.formData = {valid: false, markAllAsTouched}; + component.entityName = 'Tutorial'; + const alertService = {message: vi.fn(), error: vi.fn(), success: vi.fn()}; + + component.submit({}, alertService, vi.fn()); + + expect(markAllAsTouched).toHaveBeenCalled(); + expect(alertService.error).toHaveBeenCalledWith('Check the highlighted fields', 6000); + }); +}); diff --git a/src/app/common/entity-form/entity-form.component.ts b/src/app/common/entity-form/entity-form.component.ts index 8b9eddb11d..c99ffc2787 100644 --- a/src/app/common/entity-form/entity-form.component.ts +++ b/src/app/common/entity-form/entity-form.component.ts @@ -144,8 +144,9 @@ export abstract class EntityFormComponent implements AfterView const data = this.formDataToNewObject(this.serverKey); // sent as path id and body response = service.create(data, this.optionsOnRequest('create')); } else { - // Nothing has changed if the selected value, so we want to inform the user - alertService.error(`${this.entityName} was not changed`, 6000); + // Nothing has changed on the selected value. This is not a failure, so + // use a neutral message rather than a red error snackbar. + alertService.message(`${this.entityName} was not changed`, 6000); return; } @@ -175,8 +176,11 @@ export abstract class EntityFormComponent implements AfterView }); } else { // Once we mark forms as touched, erroneous state will be rendered - // In the form's template accordingly + // In the form's template accordingly. Tell the user why the save did + // nothing, otherwise the button reads as dead when the invalid control + // is scrolled off screen. this.formData.markAllAsTouched(); + alertService.error('Check the highlighted fields', 6000); } } diff --git a/src/app/common/filters/filters.coffee b/src/app/common/filters/filters.coffee deleted file mode 100644 index 3a9711cd0b..0000000000 --- a/src/app/common/filters/filters.coffee +++ /dev/null @@ -1,320 +0,0 @@ -# Component is no longer used and is unlinked from the app -# This file is left for reference, as these are migrated into Pipes - -angular.module("doubtfire.common.filters", []) - -# -# Paging filter - start from the indicated index -# -.filter('startFrom', -> - (input, start) -> - input?.slice(+start) -) - -.filter('showStudents', -> - (input, kind, tutor) -> - if input - if kind == "mine" || kind == "myStudents" - _.filter input, (project) -> project.hasTutor(tutor) - else - input - else - input -) - -.filter('orderObjectBy', -> - (items, field, reverse) -> - filtered = [] - - angular.forEach items, (item) -> - filtered.push(item) - - filtered.sort (a, b) -> - a[field] > b[field] ? 1 : -1 - - if reverse - filtered.reverse() - - filtered -) - -.filter('byGrade', -> - (input, grade) -> - if input - _.filter input, (task) -> (task?) && task.definition.targetGrade <= grade - else - input -) - -.filter('studentsWithPlagiarism', -> - (input) -> - if input - _.filter input, (student) -> (student?) && student.similarityFlag - else - input -) - -.filter('studentsWithPortfolio', -> - (input, option) -> - if input - _.filter input, (student) -> option == 'allStudents' || ((student?) && student.hasPortfolio > 0) - else - input -) - -.filter('studentsWithSubmittedGrade', -> - (input, grade) -> - if input && grade > -1 - _.filter input, (student) -> (student?) && student.submittedGrade == grade - else - input -) - -.filter('taskWithPlagiarism', -> - (input) -> - if input - _.filter input, (task) -> (task?) && task.similarityFlag - else - input -) - -.filter('studentsForGroup', -> - (input, gs, group, members) -> - if input - if gs.keepGroupsInSameClass - _.filter input, (student) -> (student?) && (student.isEnrolledIn(group.tutorial)) && (not _.find(members, (mbr) -> student.id == mbr.id )) - else - _.filter input, (student) -> (student?) && not _.find(members, (mbr) -> student.id == mbr.id ) - else - input -) - -.filter('outcomeFilter', -> - (input, outcomeId) -> - if input && outcomeId - _.filter input, (item) -> item.learningOutcome?.id == outcomeId - else - input -) - -.filter('taskDefinitionFilter', -> - (input, taskDefId) -> - if input && taskDefId - _.filter input, (item) -> item.definition.id == taskDefId - else - input -) - -.filter('truncatedMarkdown', ($filter) -> - (input, truncateTo) -> - truncateTo = truncateTo or 128 - input = $filter('markdown')(input) - input = $filter('stripTags')(input) - $filter('truncate')(input, truncateTo, '...') -) - -.filter('statusFilter', -> - (input, statusKind) -> - if input && statusKind - _.filter input, (task) -> - if statusKind == 'discuss' - task.status == statusKind || task.status == 'demonstrate' - else - task.status == statusKind - else - input -) - -.filter('projectFilter', -> - (input, text) -> - if _.isString(text) && text.length > 0 && input - matchText = text.toLowerCase() - _.filter input, (project) -> (project?) && project.matches(matchText) - else - input -) - -.filter('projectUnitFilter', -> - (input, text) -> - if _.isString(text) && text.length > 0 && input - matchText = text.toLowerCase() - _.filter input, (project) -> (project?) && project.unit.matches(matchText) - else - input -) - -.filter('unitFilter', -> - (input, text) -> - if _.isString(text) && text.length > 0 && input - matchText = text.toLowerCase() - _.filter input, (unit) -> (unit?) && unit.matches(matchText) - else - input -) - -.filter('teachingPeriodFilter', -> - (input, text) -> - if _.isString(text) && text.length > 0 && input - matchText = text.toLowerCase() - _.filter input, (tp) -> (tp?) && tp.period.toLowerCase().indexOf(matchText) >= 0 - else - input -) - - -.filter('taskForPortfolio', (newTaskService) -> - (input, apply) -> - if (! apply) || (! input) - input - else - _.filter input, (task) -> - if task? - !_.includes(newTaskService.toBeWorkedOn, task.status) - else - false -) - -# NEW FILTERS - -.filter('tasksOfTaskDefinition', -> - (tasks, taskDefinition) -> - return tasks unless (taskDefinition? && tasks?) - tasks = _.filter tasks, (task) -> task.definition.id == taskDefinition.id -) - -.filter('tasksWithStatuses', -> - (tasks, statusKeys) -> - return tasks unless tasks? - return [] if _.isEmpty statusKeys - _.filter tasks, (task) -> _.includes(statusKeys, task.status) -) - -.filter('tasksInTutorials', -> - (tasks, tutorialIds) -> - return tasks unless tasks? - return [] if _.isEmpty tutorialIds - # If task is group task, then use group tutorial, else use project tutorial - _.filter tasks, (task) -> - if task.isGroupTask() - if task.group()? - _.includes(tutorialIds, task.group.tutorial.id) - else - _.filter(task.project.tutorials, (tutorial) -> - _.includes(tutorialIds, tutorial.id) - ).length > 0 - -) - -.filter('tasksWithSearchText', -> - (tasks, searchText) -> - return tasks unless (searchText? && tasks?) - searchText = searchText.toLowerCase() - _.filter tasks, (task) -> - p = task.project - p.matches(searchText) -) - -.filter('tutorialCampusFilter', -> - (tutorials, project) -> - return tutorials unless project? - _.filter tutorials, (tute) -> - !project.campus?.id? || !tute.campus? || tute.campus.id == project.campus.id -) - -.filter('groupsInTutorials', -> - (input, unitRole, kind) -> - return unless input? && unitRole? && kind? - if kind == 'mine' - return _.filter(input, (group) -> group.tutorial.tutor.id == unitRole.user.id) - return input -) - -.filter('groupsForStudent', -> - (input, project, groupSet) -> - # Filter by tutorial if keep groups in same class - return input unless input? && groupSet? && project? - grp = project.groupForGroupSet(groupSet) - return [grp] if grp - return input unless groupSet.keepGroupsInSameClass - _.filter(input, (group) -> project.isEnrolledIn(group.tutorial)) -) - -.filter('groupsWithName', -> - (input, searchName) -> - return input unless (searchName? && input?) - searchName = searchName.toLowerCase() - _.filter(input, (group) -> - # Search using name or abbreviation - group.name.toLowerCase().indexOf(searchName) >= 0 - ) -) - -.filter('tasksForGroupset', -> - (input, groupSet) -> - return input unless input? - return input.filter (task) -> (task.definition.groupSet == groupSet) || (!task.definition.groupSet? && !groupSet?) -) - -.filter('paginateAndSort', ($filter) -> - (input, pagination, tableSort) -> - return unless input? && tableSort? && pagination? - return input if input.length == 0 - pagination.show = input.length > pagination.pageSize - pagination.totalSize = input.length - input = $filter('orderBy')(input, tableSort.order, tableSort.reverse) - input = $filter('startFrom')(input, (pagination.currentPage - 1) * pagination.pageSize) - input = $filter('limitTo')(input, pagination.pageSize) - input -) - -.filter('tasksWithName', -> - (tasks, searchName) -> - return tasks unless (searchName? && tasks?) - searchName = searchName.toLowerCase() - _.filter(tasks, (task) -> - # Search using name or abbreviation - task.definition.name.toLowerCase().indexOf(searchName) >= 0 || - task.definition.abbreviation.toLowerCase().indexOf(searchName) >= 0 - ) -) - -.filter('taskDefinitionName', -> - (taskDefinitions, searchName) -> - return taskDefinitions unless (searchName? && taskDefinitions?) - searchName = searchName.toLowerCase() - _.filter(taskDefinitions, (td) -> - # Search using name or abbreviation - td.name.toLowerCase().indexOf(searchName) >= 0 || - td.abbreviation.toLowerCase().indexOf(searchName) >= 0 || - td.targetGradeText.toLowerCase().indexOf(searchName) >= 0 - ) -) - -.filter('humanizedDate', ($filter) -> - (input) -> - return unless input? - moment(input).calendar(null, { - sameDay: '', - nextDay: '[tomorrow]', - nextWeek: '[this] dddd', - lastDay: '[yesterday]', - lastWeek: '[last] dddd', - sameElse: 'DD/MM/YYYY' - }) -) - -.filter('lcfirst', -> - (input) -> - return if !input? || input.length == 0 - input[0].toLowerCase() + input.substring(1) -) - -.filter('isActiveUnitRole', -> - (unitRoles) -> - _.filter(unitRoles, (ur) -> ur.active ) -) - -.filter('to_trusted', ($sce) -> - (text) -> - $sce.getTrustedHtml(text) -) diff --git a/src/app/common/guards/role-whitelist.guard.ts b/src/app/common/guards/role-whitelist.guard.ts index 124c7ad937..6e11b8f7cf 100644 --- a/src/app/common/guards/role-whitelist.guard.ts +++ b/src/app/common/guards/role-whitelist.guard.ts @@ -1,4 +1,4 @@ -import {inject} from '@angular/core'; +import {inject, isDevMode} from '@angular/core'; import {ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree} from '@angular/router'; import {Observable, filter, map, of, take} from 'rxjs'; import {AuthenticationService, UserService} from 'src/app/api/models/doubtfire-model'; @@ -14,6 +14,14 @@ export const roleWhitelistGuard: CanActivateFn = ( const roleWhitelist = route.data['roleWhitelist'] as string[] | undefined; if (!roleWhitelist?.length) { + // A route that asks for the guard and gives it no whitelist lets everybody through. Keep + // letting them through so production behaviour does not change, but say so while developing. + if (isDevMode()) { + console.error( + `roleWhitelistGuard is on route '${routePath(route)}' with no roleWhitelist in its data, ` + + 'so it allows every signed in user. Add a roleWhitelist or drop the guard.', + ); + } return of(true); } @@ -29,6 +37,15 @@ export const roleWhitelistGuard: CanActivateFn = ( ); }; +function routePath(route: ActivatedRouteSnapshot): string { + return ( + route.pathFromRoot + .map((snapshot) => snapshot.routeConfig?.path) + .filter((path) => !!path) + .join('/') || '/' + ); +} + function roleForRoute( route: ActivatedRouteSnapshot, userService: UserService, diff --git a/src/app/common/guards/spec/role-whitelist.guard.spec.ts b/src/app/common/guards/spec/role-whitelist.guard.spec.ts new file mode 100644 index 0000000000..63869a7559 --- /dev/null +++ b/src/app/common/guards/spec/role-whitelist.guard.spec.ts @@ -0,0 +1,456 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {HttpClient} from '@angular/common/http'; +import {Type} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + Data, + Route, + Router, + RouterStateSnapshot, + Routes, + UrlTree, + convertToParamMap, +} from '@angular/router'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {InstitutionSettingsComponent} from 'src/app/admin/institution-settings/institution-settings.component'; +import {FUnitsComponent} from 'src/app/admin/states/units/units.component'; +import {FUsersComponent} from 'src/app/admin/states/users/users.component'; +import {AuthenticationService, UserService} from 'src/app/api/models/doubtfire-model'; +import {routes} from 'src/app/app.routes'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {JplagReportViewerComponent} from 'src/app/projects/states/jplag/jplag-report-viewer.component'; +import {TutorDiscussionComponent} from 'src/app/projects/states/tutor-discussion/tutor-discussion.component'; +import {UnitAnalyticsComponent} from 'src/app/units/states/analytics/unit-analytics-route.component'; +import {UnitAdminStateComponent} from 'src/app/units/states/edit/unit-admin-state.component'; +import {UnitGroupsComponent} from 'src/app/units/states/groups/unit-groups/unit-groups.component'; +import {PortfoliosComponent} from 'src/app/units/states/portfolios/portfolios.component'; +import {RolloverComponent} from 'src/app/units/states/rollover/rollover.component'; +import {StudentsListComponent} from 'src/app/units/states/students-list/students-list.component'; +import {UnitTaskInboxStateComponent} from 'src/app/units/states/tasks/inbox/unit-task-inbox-state.component'; +import {TaskViewerStateComponent} from 'src/app/units/task-viewer/task-viewer-state.component'; +import {roleWhitelistGuard} from '../role-whitelist.guard'; + +/** + * Components that only teaching staff should ever reach. Every route that renders one of + * these has to carry roleWhitelistGuard, on itself or on a parent, unless its full path is + * listed in DELIBERATELY_OPEN_PATHS below. + * + * Add to this list when you add a staff screen. That is the whole point of it. + */ +const STAFF_ONLY_COMPONENTS: Type[] = [ + FUnitsComponent, + FUsersComponent, + InstitutionSettingsComponent, + JplagReportViewerComponent, + PortfoliosComponent, + RolloverComponent, + StudentsListComponent, + TaskViewerStateComponent, + TutorDiscussionComponent, + UnitAdminStateComponent, + UnitAnalyticsComponent, + UnitGroupsComponent, + UnitTaskInboxStateComponent, +]; + +/** + * Routes onto a staff component that are open on purpose. Anything added here needs a + * comment on the route itself saying why it is open. + */ +const DELIBERATELY_OPEN_PATHS: string[] = [ + // FUnitsComponent in 'student' mode lists the signed in user's own enrolled units. + 'view-all-projects', + // An empty iframe onto the bundled JPlag viewer. It makes no api call and holds no data of + // its own until staff hand it a blob url from their own tab. + 'jplag-report-viewer', +]; + +interface FlatRoute { + path: string; + component?: Type; + guarded: boolean; + roleWhitelist?: string[]; + ownGuard: boolean; + ownWhitelist?: string[]; +} + +function hasRoleGuard(route: Route): boolean { + return (route.canActivate ?? []).includes(roleWhitelistGuard); +} + +function flatten( + children: Routes, + parentPath: string, + inheritedGuard: boolean, + inheritedWhitelist: string[] | undefined, +): FlatRoute[] { + return children.flatMap((route) => { + const path = [parentPath, route.path].filter((part) => !!part).join('/'); + const ownGuard = hasRoleGuard(route); + const ownWhitelist = route.data?.['roleWhitelist'] as string[] | undefined; + const guarded = inheritedGuard || ownGuard; + const roleWhitelist = ownGuard ? ownWhitelist : inheritedWhitelist; + + const self: FlatRoute = { + path, + component: route.component, + guarded, + roleWhitelist, + ownGuard, + ownWhitelist, + }; + const descendants = route.children ? flatten(route.children, path, guarded, roleWhitelist) : []; + + return [self, ...descendants]; + }); +} + +const flatRoutes = flatten(routes, '', false, undefined); +const staffRoutes = flatRoutes.filter( + (route) => route.component && STAFF_ONLY_COMPONENTS.includes(route.component), +); + +describe('app route role guard coverage', () => { + it('finds the staff routes it is meant to be checking', () => { + // Guards the walk itself. If flatten() ever stops descending, this fails rather than + // letting the assertions below pass over an empty list. + expect(staffRoutes.length).toBeGreaterThanOrEqual(25); + expect(staffRoutes.map((route) => route.path)).toContain( + 'units/:unitId/tasks/:taskAbbreviation', + ); + }); + + it('puts roleWhitelistGuard on every route that renders a staff only component', () => { + const unguarded = staffRoutes + .filter((route) => !route.guarded) + .map((route) => route.path) + .filter((path) => !DELIBERATELY_OPEN_PATHS.includes(path)); + + expect( + unguarded, + `these staff routes have no roleWhitelistGuard: ${unguarded.join(', ')}. Add ` + + 'canActivate: [roleWhitelistGuard] and a roleWhitelist, or list the path in ' + + 'DELIBERATELY_OPEN_PATHS with a comment on the route saying why it is open.', + ).toEqual([]); + }); + + it('gives every guarded staff route a non empty whitelist', () => { + const missing = staffRoutes + .filter((route) => route.guarded && !route.roleWhitelist?.length) + .map((route) => route.path); + + expect(missing, `these guarded routes have no roleWhitelist: ${missing.join(', ')}`).toEqual( + [], + ); + }); + + it('never puts the guard on a route without a whitelist of its own', () => { + // roleWhitelistGuard falls through to true when data.roleWhitelist is missing, so this + // pairing looks protected and is not. + const emptyGuards = flatRoutes + .filter((route) => route.ownGuard && !route.ownWhitelist?.length) + .map((route) => route.path); + + expect( + emptyGuards, + `these routes ask for the guard and give it nothing to check: ${emptyGuards.join(', ')}`, + ).toEqual([]); + }); + + it('leaves the student routes alone', () => { + // The negative case. If a change starts guarding everything, this goes red. + const studentPaths = [ + 'home', + 'sign_in', + 'projects/:projectId/dashboard', + 'projects/:projectId/portfolio', + 'view-all-projects', + ]; + + const wronglyGuarded = flatRoutes + .filter((route) => studentPaths.includes(route.path) && route.guarded) + .map((route) => route.path); + + expect(wronglyGuarded).toEqual([]); + }); + + it('only ever whitelists role names the api actually issues', () => { + const validRoles = ['Student', 'Tutor', 'Convenor', 'Admin', 'Auditor']; + const invalid = flatRoutes + .flatMap((route) => route.ownWhitelist ?? []) + .filter((role) => !validRoles.includes(role)); + + expect(invalid).toEqual([]); + }); +}); + +const UNIT_ID = 12; + +/** + * Which roles the guard has to admit and refuse, per route, run through the guard itself. + * + * The block above only proves a guard is attached. It says nothing about who that guard lets + * in, so a whitelist that locks out a whole role passes it. These do execute the guard, against + * the whitelist that is really in app.routes.ts, so a wrong whitelist goes red here. + * + * Paths under 'units/:unitId' resolve the caller's UNIT role. Top level paths resolve the + * SYSTEM role. Same guard, different question, which is why two identical looking whitelists + * on the two kinds of path do not admit the same people. + */ +const UNIT_ROLE_EXPECTATIONS: {path: string; allow: string[]; refuse: string[]}[] = [ + // The staff menu offers Discussion and Check-in to every unit role, and the header QR button + // routes every non student to 'discussion'. Convenors have to be able to get there. + {path: 'units/:unitId/discussion', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + {path: 'units/:unitId/check-in', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + {path: 'units/:unitId/analytics', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + {path: 'units/:unitId/students', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + {path: 'units/:unitId/students/groups', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + {path: 'units/:unitId/students/portfolios', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + {path: 'units/:unitId/tasks/inbox', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + {path: 'units/:unitId/tasks/moderation', allow: ['Convenor', 'Tutor'], refuse: ['Student']}, + // Convenor and above. Unit administration, rollover and the whole unit task list are not a + // tutor's queue. A tutor deep link belongs on 'tasks/inbox/:studentId/:taskDefAbbr' above. + {path: 'units/:unitId/admin', allow: ['Convenor'], refuse: ['Tutor', 'Student']}, + {path: 'units/:unitId/rollover', allow: ['Convenor'], refuse: ['Tutor', 'Student']}, + {path: 'units/:unitId/tasks', allow: ['Convenor'], refuse: ['Tutor', 'Student']}, + { + path: 'units/:unitId/tasks/:taskAbbreviation', + allow: ['Convenor'], + refuse: ['Tutor', 'Student'], + }, +]; + +const SYSTEM_ROLE_EXPECTATIONS: {path: string; allow: string[]; refuse: string[]}[] = [ + {path: 'view-all-units', allow: ['Tutor', 'Convenor', 'Admin', 'Auditor'], refuse: ['Student']}, + { + path: 'tutor-attendance', + allow: ['Tutor', 'Convenor', 'Admin', 'Auditor'], + refuse: ['Student'], + }, + // 'tutor-discussion' refuses Convenor on 11.0.x already. This change does not widen it, and + // pinning it here is what makes that a decision rather than an oversight. + {path: 'tutor-discussion', allow: ['Tutor', 'Admin', 'Auditor'], refuse: ['Convenor', 'Student']}, + {path: 'admin/units', allow: ['Convenor', 'Admin', 'Auditor'], refuse: ['Tutor', 'Student']}, + {path: 'admin/users', allow: ['Admin', 'Auditor'], refuse: ['Convenor', 'Tutor', 'Student']}, + { + path: 'admin/institution-settings', + allow: ['Admin', 'Auditor'], + refuse: ['Convenor', 'Tutor', 'Student'], + }, +]; + +/** + * The whitelist the guard will really see on this path, taken from app.routes.ts rather than + * repeated here. Where two routes share a path the first one wins, which is what the router + * does too. + */ +function whitelistFor(path: string): string[] { + const match = flatRoutes.find((route) => route.path === path); + expect(match, `no route in app.routes.ts has the path '${path}'`).toBeDefined(); + expect( + match?.roleWhitelist, + `route '${path}' reaches the guard with no roleWhitelist`, + ).toBeDefined(); + + return match.roleWhitelist; +} + +describe('roleWhitelistGuard role decisions', () => { + const globalState = { + isLoadingSubject: new BehaviorSubject(false), + loadedUnitRoles: {currentValues: [] as {unit: {id: number}; role: string}[]}, + }; + const userService = {currentUser: {role: 'Student'}}; + const router = { + createUrlTree: (commands: unknown[]) => ({commands}) as unknown as UrlTree, + }; + + beforeEach(() => { + globalState.isLoadingSubject.next(false); + globalState.loadedUnitRoles.currentValues = []; + userService.currentUser.role = 'Student'; + + TestBed.configureTestingModule({ + providers: [ + AuthenticationService, + {provide: AlertService, useValue: {}}, + {provide: DoubtfireConstants, useValue: {API_URL: ''}}, + {provide: GlobalStateService, useValue: globalState}, + {provide: HttpClient, useValue: {}}, + {provide: Router, useValue: router}, + {provide: UserService, useValue: userService}, + ], + }); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + function snapshotFor(data: Data, unitId?: number): ActivatedRouteSnapshot { + return { + data, + paramMap: convertToParamMap(unitId === undefined ? {} : {unitId: String(unitId)}), + parent: null, + pathFromRoot: [], + } as unknown as ActivatedRouteSnapshot; + } + + function activate(snapshot: ActivatedRouteSnapshot): boolean | UrlTree { + const result = TestBed.runInInjectionContext( + () => + roleWhitelistGuard(snapshot, {} as RouterStateSnapshot) as Observable, + ); + + let outcome: boolean | UrlTree | undefined; + result.subscribe((value) => (outcome = value)); + expect(outcome, 'the guard did not settle synchronously').toBeDefined(); + + return outcome; + } + + function admitsUnitRole(path: string, unitRole: string): boolean { + // The system role is left as Student on purpose. If role resolution ever stopped reading the + // unit role it would fall back to this and every allow case below would fail. + userService.currentUser.role = 'Student'; + globalState.loadedUnitRoles.currentValues = + unitRole === 'Student' ? [] : [{unit: {id: UNIT_ID}, role: unitRole}]; + + return activate(snapshotFor({roleWhitelist: whitelistFor(path)}, UNIT_ID)) === true; + } + + function admitsSystemRole(path: string, systemRole: string): boolean { + globalState.loadedUnitRoles.currentValues = []; + userService.currentUser.role = systemRole; + + return activate(snapshotFor({roleWhitelist: whitelistFor(path)})) === true; + } + + for (const expectation of UNIT_ROLE_EXPECTATIONS) { + it(`admits ${expectation.allow.join(' and ')} to ${expectation.path}`, () => { + for (const role of expectation.allow) { + expect(admitsUnitRole(expectation.path, role), `${role} was refused`).toBe(true); + } + }); + + it(`refuses ${expectation.refuse.join(' and ')} on ${expectation.path}`, () => { + for (const role of expectation.refuse) { + expect(admitsUnitRole(expectation.path, role), `${role} was admitted`).toBe(false); + } + }); + } + + it('sends a refused caller to /unauthorised rather than just returning false', () => { + userService.currentUser.role = 'Student'; + globalState.loadedUnitRoles.currentValues = []; + + const outcome = activate( + snapshotFor({roleWhitelist: whitelistFor('units/:unitId/admin')}, UNIT_ID), + ); + + expect(outcome).toEqual({commands: ['/unauthorised']}); + }); + + it('lets a system Admin or Auditor into a unit they hold no unit role in', () => { + // roleForRoute falls back to the system role for these two. Every unit whitelist has to keep + // carrying them or site administration loses access to the units it administers. + for (const path of UNIT_ROLE_EXPECTATIONS.map((expectation) => expectation.path)) { + for (const role of ['Admin', 'Auditor']) { + globalState.loadedUnitRoles.currentValues = []; + userService.currentUser.role = role; + + expect( + activate(snapshotFor({roleWhitelist: whitelistFor(path)}, UNIT_ID)), + `${role} was refused on ${path}`, + ).toBe(true); + } + } + }); + + it('still admits a system Convenor to a unit they hold no unit role in', () => { + // Pinning a gap, not endorsing it. roleForRoute returns undefined here, but isAuthorised + // falls back to the system role when it is handed one, so the undefined never refuses + // anybody. A system Convenor with no role in unit 12 reaches /units/12/admin. The card for + // this change says not to touch the guard's logic, so this records today's behaviour and + // will go red when someone fixes it on its own ticket. + globalState.loadedUnitRoles.currentValues = []; + userService.currentUser.role = 'Convenor'; + + expect( + activate(snapshotFor({roleWhitelist: whitelistFor('units/:unitId/admin')}, UNIT_ID)), + ).toBe(true); + }); + + for (const expectation of SYSTEM_ROLE_EXPECTATIONS) { + it(`admits ${expectation.allow.join(' and ')} to ${expectation.path}`, () => { + for (const role of expectation.allow) { + expect(admitsSystemRole(expectation.path, role), `${role} was refused`).toBe(true); + } + }); + + it(`refuses ${expectation.refuse.join(' and ')} on ${expectation.path}`, () => { + for (const role of expectation.refuse) { + expect(admitsSystemRole(expectation.path, role), `${role} was admitted`).toBe(false); + } + }); + } + + it('waits for the global state to finish loading before it decides', () => { + globalState.isLoadingSubject.next(true); + userService.currentUser.role = 'Student'; + + const result = TestBed.runInInjectionContext( + () => + roleWhitelistGuard( + snapshotFor({roleWhitelist: whitelistFor('units/:unitId/admin')}, UNIT_ID), + {} as RouterStateSnapshot, + ) as Observable, + ); + + let outcome: boolean | UrlTree | undefined; + result.subscribe((value) => (outcome = value)); + expect(outcome, 'the guard decided while the roles were still loading').toBeUndefined(); + + globalState.loadedUnitRoles.currentValues = [{unit: {id: UNIT_ID}, role: 'Convenor'}]; + globalState.isLoadingSubject.next(false); + + expect(outcome).toBe(true); + }); + + describe('a guarded route with no whitelist', () => { + const noWhitelist = () => + ({ + data: {}, + paramMap: convertToParamMap({}), + parent: null, + pathFromRoot: [ + {routeConfig: {path: 'units'}}, + {routeConfig: {path: ':unitId'}}, + {routeConfig: {path: 'analytics'}}, + ], + }) as unknown as ActivatedRouteSnapshot; + + it('still lets the caller through, so a missing whitelist cannot lock production out', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + userService.currentUser.role = 'Student'; + + expect(activate(noWhitelist())).toBe(true); + + spy.mockRestore(); + }); + + it('logs an error naming the route so the mistake is visible while developing', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + activate(noWhitelist()); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0][0]).toContain('units/:unitId/analytics'); + + spy.mockRestore(); + }); + }); +}); diff --git a/src/app/common/header/header.component.html b/src/app/common/header/header.component.html index 47bef3cb70..942aeedf14 100644 --- a/src/app/common/header/header.component.html +++ b/src/app/common/header/header.component.html @@ -1,19 +1,20 @@ @if (showHeader) { - @if (!media.isActive('xs')) { + @if (!isExtraSmall) { @if (logoSettings.hasLogo) { } - +
- @if (media.isActive('xs')) { + @if (isExtraSmall) { } - @if (media.isActive('xs')) { + @if (isExtraSmall) { }

Units you teach

@for (unitRole of unitRoles; track unitRole) { @if (!unitRole.unit.teachingPeriod || unitRole.unit.teachingPeriod?.active) { -
@@ -52,7 +59,7 @@

Units y

-
+ } } diff --git a/src/app/common/header/unit-dropdown/unit-dropdown.component.spec.ts b/src/app/common/header/unit-dropdown/unit-dropdown.component.spec.ts index bcf7ff444d..31cab69f47 100644 --- a/src/app/common/header/unit-dropdown/unit-dropdown.component.spec.ts +++ b/src/app/common/header/unit-dropdown/unit-dropdown.component.spec.ts @@ -1,5 +1,5 @@ -import {MediaObserver} from 'ng-flex-layout'; import {beforeEach, describe, expect, it} from 'vitest'; +import {BreakpointObserver} from '@angular/cdk/layout'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {MatMenuModule} from '@angular/material/menu'; @@ -12,7 +12,7 @@ describe('UnitDropdownComponent', () => { await TestBed.configureTestingModule({ declarations: [UnitDropdownComponent], imports: [MatMenuModule], - providers: [{provide: MediaObserver, useValue: {isActive: () => false}}], + providers: [{provide: BreakpointObserver, useValue: {isMatched: () => false}}], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); diff --git a/src/app/common/header/unit-dropdown/unit-dropdown.component.ts b/src/app/common/header/unit-dropdown/unit-dropdown.component.ts index 4190257424..d9d7ead2f4 100644 --- a/src/app/common/header/unit-dropdown/unit-dropdown.component.ts +++ b/src/app/common/header/unit-dropdown/unit-dropdown.component.ts @@ -1,4 +1,4 @@ -import {MediaObserver} from 'ng-flex-layout'; +import {BreakpointObserver} from '@angular/cdk/layout'; import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; import {Project, Unit, UnitRole} from 'src/app/api/models/doubtfire-model'; @@ -16,5 +16,9 @@ export class UnitDropdownComponent { unitTitle: string; - constructor(public media: MediaObserver) {} + get isExtraSmall(): boolean { + return this.breakpointObserver.isMatched('(max-width: 599.98px)'); + } + + constructor(private breakpointObserver: BreakpointObserver) {} } diff --git a/src/app/common/learning-outcome-editor/learning-outcome-editor.component.spec.ts b/src/app/common/learning-outcome-editor/learning-outcome-editor.component.spec.ts new file mode 100644 index 0000000000..d560a60066 --- /dev/null +++ b/src/app/common/learning-outcome-editor/learning-outcome-editor.component.spec.ts @@ -0,0 +1,196 @@ +import {EntityCache, EntityMapping} from 'ngx-entity-service'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {SimpleChange} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {LearningOutcome} from 'src/app/api/models/learning-outcome'; +import {Unit} from 'src/app/api/models/unit'; +import {LearningOutcomeEditorComponent} from './learning-outcome-editor.component'; + +function outcome(id: number, abbreviation: string, extra: Partial = {}) { + return Object.assign(new LearningOutcome(), { + id, + abbreviation, + shortDescription: `About ${abbreviation}`, + fullOutcomeDescription: `All about ${abbreviation}`, + contextType: 'Unit', + ...extra, + }); +} + +function unitWith(...outcomes: LearningOutcome[]): Unit { + const unit = Object.assign(new Unit(), {id: outcomes[0]?.id ?? 1, code: 'SIT101'}); + outcomes.forEach((each) => unit.learningOutcomesCache.add(each)); + return unit; +} + +// The editor uses signals, an effect and inject(), so it is built inside an injection +// context, but it is driven by hand rather than rendered. +function editorFor(context?: Unit) { + const learningOutcomeService = { + cache: new EntityCache(), + mapping: new EntityMapping(), + }; + const alerts = {success: vi.fn(), error: vi.fn()}; + const component = TestBed.runInInjectionContext( + () => + new LearningOutcomeEditorComponent( + alerts as never, + learningOutcomeService as never, + {} as never, // fileDownloaderService + {} as never, // nestedCsvDownloadModalService + {} as never, // feedbackTemplateService + {} as never, // taskService + {} as never, // csvResultModalService + {} as never, // csvUploadModal + {show: vi.fn()} as never, // confirmationModal + ), + ); + component.context = context; + component.ngOnChanges({context: new SimpleChange(undefined, context, true)}); + component.ngOnInit(); + return {component, learningOutcomeService, alerts}; +} + +describe('LearningOutcomeEditorComponent', () => { + afterEach(() => TestBed.resetTestingModule()); + + // The task editor keeps this editor open and swaps the task under it. The table used + // to keep listing, and following, the outcomes of the task shown before. + it('lists the outcomes of the new context when the context changes', () => { + const first = unitWith(outcome(1, 'ULO1')); + const second = unitWith(outcome(2, 'ULO7')); + const {component} = editorFor(first); + expect(component.outcomeSource.data.map((each) => each.abbreviation)).toEqual(['ULO1']); + + component.context = second; + component.ngOnChanges({context: new SimpleChange(first, second, false)}); + + expect(component.outcomeSource.data.map((each) => each.abbreviation)).toEqual(['ULO7']); + + // A change to the old context no longer reaches the table. + first.learningOutcomesCache.add(outcome(3, 'ULO2')); + expect(component.outcomeSource.data.map((each) => each.abbreviation)).toEqual(['ULO7']); + component.ngOnDestroy(); + }); + + // Each emission of the institution cache was appended to the last one, so the + // suggestions to connect filled up with repeats. + it('suggests each institution outcome once, however often the list refreshes', () => { + const {component, learningOutcomeService} = editorFor(unitWith(outcome(1, 'ULO1'))); + + learningOutcomeService.cache.add(outcome(10, 'GLO1', {contextType: null})); + learningOutcomeService.cache.add(outcome(11, 'GLO2', {contextType: null})); + + expect(component.allOutcomes().map((each) => each.abbreviation)).toEqual(['GLO1', 'GLO2']); + component.ngOnDestroy(); + }); + + // The suggestion list was worked out once and never saw outcomes that loaded later. + it('offers outcomes that load after the suggestions were first read', () => { + const {component, learningOutcomeService} = editorFor(unitWith(outcome(1, 'ULO1'))); + expect(component.filteredOutcomes()).toEqual([]); + + learningOutcomeService.cache.add(outcome(10, 'GLO1', {contextType: null})); + + expect(component.filteredOutcomes().map((each) => each.abbreviation)).toEqual(['GLO1']); + component.ngOnDestroy(); + }); + + it('numbers a new outcome after the highest code in use', () => { + const {component} = editorFor( + unitWith(outcome(1, 'ULO3'), outcome(2, 'ULO1'), outcome(3, 'ULOX')), + ); + + expect(component.getNextOutcomeNumber()).toBe(4); + + component.createLearningOutcome(); + expect(component.selectedOutcome.abbreviation).toBe('ULO4'); + component.ngOnDestroy(); + }); + + it('still numbers a new outcome when no code ends in a number', () => { + const {component} = editorFor(unitWith(outcome(1, 'Intro'), outcome(2, 'Core'))); + + expect(component.getNextOutcomeNumber()).toBe(1); + component.ngOnDestroy(); + }); + + it('ignores non-decimal suffixes when numbering outcomes', () => { + const {component} = editorFor( + unitWith(outcome(1, 'ULO2'), outcome(2, 'ULO0x10'), outcome(3, 'ULO1e3')), + ); + + expect(component.getNextOutcomeNumber()).toBe(3); + component.ngOnDestroy(); + }); + + // Saving one row used to close whichever outcome was open, even a different one. + it('keeps the open outcome open when another row is saved', () => { + const open = outcome(1, 'ULO1'); + const other = outcome(2, 'ULO2'); + open.save = vi.fn(() => of(open)); + other.save = vi.fn(() => of(other)); + const {component} = editorFor(unitWith(open, other)); + + component.selectLearningOutcome(open); + component.saveLearningOutcome(other); + expect(component.selectedOutcome).toBe(open); + + component.saveLearningOutcome(open); + expect(component.selectedOutcome).toBeNull(); + component.ngOnDestroy(); + }); + + it('puts an outcome back as it was when editing is cancelled', () => { + const open = outcome(1, 'ULO1'); + const {component} = editorFor(unitWith(open)); + + component.selectLearningOutcome(open); + open.shortDescription = 'Changed my mind'; + component.cancelEdit(); + + expect(open.shortDescription).toBe('About ULO1'); + expect(component.selectedOutcome).toBeNull(); + component.ngOnDestroy(); + }); +}); + +describe('LearningOutcomeEditorComponent.getNextOutcomeNumber', () => { + // Build the component without the Angular injector so the test stays focused + // on the numbering logic. + function editorWith(prefix: string, abbreviations: string[]): LearningOutcomeEditorComponent { + const comp = Object.create( + LearningOutcomeEditorComponent.prototype, + ) as LearningOutcomeEditorComponent; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (comp as any).abbreviationPrefix = prefix; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (comp as any).outcomeSource = {data: abbreviations.map((abbreviation) => ({abbreviation}))}; + return comp; + } + + it('returns 1 when there are no outcomes', () => { + expect(editorWith('TLO', []).getNextOutcomeNumber()).toBe(1); + }); + + it('returns max + 1, not last + 1, when rows are out of order', () => { + expect(editorWith('TLO', ['TLO1', 'TLO3', 'TLO2']).getNextOutcomeNumber()).toBe(4); + }); + + it('ignores a non-numeric abbreviation instead of producing NaN', () => { + expect(editorWith('TLO', ['TLO1', 'TLOx']).getNextOutcomeNumber()).toBe(2); + }); + + it('returns 1 when every abbreviation is non-numeric', () => { + expect(editorWith('TLO', ['TLOa', 'TLOb']).getNextOutcomeNumber()).toBe(1); + }); + + it('ignores an abbreviation where the prefix is not at the start', () => { + expect(editorWith('TLO', ['1TLO', 'TLO2']).getNextOutcomeNumber()).toBe(3); + }); + + it('ignores a decimal suffix rather than producing a fractional number', () => { + expect(editorWith('TLO', ['TLO.5', 'TLO4']).getNextOutcomeNumber()).toBe(5); + }); +}); diff --git a/src/app/common/learning-outcome-editor/learning-outcome-editor.component.ts b/src/app/common/learning-outcome-editor/learning-outcome-editor.component.ts index 183bb47c96..edd2d9c3c6 100644 --- a/src/app/common/learning-outcome-editor/learning-outcome-editor.component.ts +++ b/src/app/common/learning-outcome-editor/learning-outcome-editor.component.ts @@ -19,7 +19,7 @@ import { import {MatAutocompleteSelectedEvent} from '@angular/material/autocomplete'; import {MatChipInputEvent} from '@angular/material/chips'; import {MatPaginator} from '@angular/material/paginator'; -import {MatSort, Sort} from '@angular/material/sort'; +import {MatSort} from '@angular/material/sort'; import {MatTable, MatTableDataSource} from '@angular/material/table'; import {Subscription} from 'rxjs'; import { @@ -41,6 +41,15 @@ import { import {CsvUploadModalService} from '../modals/csv-upload-modal/csv-upload-modal.service'; import {NestedCsvDownloadModalService} from './nested-csv-download-modal/nested-csv-download-modal.service'; +interface OutcomeSnapshot { + abbreviation: string; + shortDescription: string; + fullOutcomeDescription: string; + linkedOutcomeIds: number[]; +} + +let nextEditorId = 0; + @Component({ selector: 'f-learning-outcome-editor', templateUrl: 'learning-outcome-editor.component.html', @@ -54,6 +63,9 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV @ViewChild(MatSort, {static: false}) outcomeSort: MatSort; @ViewChild(MatPaginator, {static: false}) outcomePaginator: MatPaginator; + /** Keeps the field ids apart when more than one editor is on the page. */ + public readonly idPrefix = `learning-outcome-editor-${nextEditorId++}`; + public outcomeSource: MatTableDataSource = new MatTableDataSource([]); public outcomeColumns: string[] = [ 'abbreviation', @@ -65,10 +77,21 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV public selectedOutcome: LearningOutcome; public abbreviationPrefix: 'TLO' | 'ULO' | 'GLO'; - public allOutcomes: LearningOutcome[] = []; + /** + * The outcomes this one can be connected to. They come from two caches that each + * report every change in full, so each is kept on its own and the two are joined, + * rather than appended to one list, which filled the suggestions with repeats. + */ + private readonly institutionOutcomes = signal([]); + private readonly unitOutcomes = signal([]); + public readonly allOutcomes = computed(() => [ + ...this.institutionOutcomes(), + ...this.unitOutcomes(), + ]); public selectedConnectedOutcomes = signal([]); private subscriptions: Subscription[] = []; + private snapshot: OutcomeSnapshot | null = null; constructor( private alerts: AlertService, @@ -83,18 +106,18 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV ) { this.outcomeSource.filterPredicate = (data: LearningOutcome, filter: string) => { const filterValue = filter.trim().toLowerCase(); - return ( - data.abbreviation.toLowerCase().includes(filterValue) || - data.shortDescription.toLowerCase().includes(filterValue) || - data.fullOutcomeDescription.toLowerCase().includes(filterValue) + return [data.abbreviation, data.shortDescription, data.fullOutcomeDescription].some((text) => + (text ?? '').toLowerCase().includes(filterValue), ); }; + this.outcomeSource.sortingDataAccessor = (data: LearningOutcome, column: string) => + (data[column] ?? '').toString().toLowerCase(); effect(() => { const linkedOutcomes = this.selectedConnectedOutcomes().map((outcome) => outcome.id); if ( this.selectedOutcome && - !this.sameIds(linkedOutcomes, this.selectedOutcome.linkedOutcomeIds) + !this.sameIds(linkedOutcomes, this.selectedOutcome.linkedOutcomeIds ?? []) ) { this.selectedOutcome.linkedOutcomeIds = linkedOutcomes; } @@ -122,6 +145,8 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV private subscribeToLearningOutcomes(): void { this.setAbbreviationPrefix(); + this.institutionOutcomes.set([]); + this.unitOutcomes.set([]); if (!this.context) { this.subscriptions.push( @@ -141,27 +166,38 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV this.subscriptions.push( this.learningOutcomeService.cache.values.subscribe((outcomes) => { - const glos = outcomes.filter((outcome) => outcome.contextType === null); - this.allOutcomes = [...this.allOutcomes, ...glos]; + this.institutionOutcomes.set(outcomes.filter((outcome) => outcome.contextType === null)); }), ); if (this.context instanceof TaskDefinition) { this.subscriptions.push( this.context.unit.learningOutcomesCache.values.subscribe((learningOutcomes) => { - this.allOutcomes = [...this.allOutcomes, ...learningOutcomes]; + this.unitOutcomes.set(learningOutcomes); }), ); } } - ngOnChanges(_changes: SimpleChanges): void { + ngOnChanges(changes: SimpleChanges): void { this.setAbbreviationPrefix(); - this.selectedOutcome = null; + this.closeEditor(); + + // The task editor keeps this editor on screen and swaps the task under it. Without + // following the new task, the table kept listing the outcomes of the one before. + if (changes.context && !changes.context.firstChange) { + this.unsubscribeAll(); + this.subscribeToLearningOutcomes(); + } } ngOnDestroy(): void { + this.unsubscribeAll(); + } + + private unsubscribeAll(): void { this.subscriptions.forEach((s) => s.unsubscribe()); + this.subscriptions = []; } setAbbreviationPrefix(): void { @@ -176,30 +212,33 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV public saveLearningOutcome(learningOutcome: LearningOutcome) { if ( - !learningOutcome.abbreviation.trim() || - !learningOutcome.shortDescription.trim() || - !learningOutcome.fullOutcomeDescription.trim() + !learningOutcome.abbreviation?.trim() || + !learningOutcome.shortDescription?.trim() || + !learningOutcome.fullOutcomeDescription?.trim() ) { - this.alerts.error('Failed to save learning outcome. Fill in required fields.'); + this.alerts.error('Fill in the code, the short description and the full outcome first.'); return; } learningOutcome.save().subscribe({ next: () => { - this.alerts.success('Outcome saved'); + this.alerts.success(`${learningOutcome.abbreviation} saved`); learningOutcome.setOriginalSaveData(this.learningOutcomeService.mapping); - this.selectLearningOutcome(this.selectedOutcome); + // Saving from a row leaves whatever else is open alone. + if (this.selectedOutcome === learningOutcome) { + this.closeEditor(); + } }, - error: () => this.alerts.error('Failed to save learning outcome. Please try again.'), + error: () => this.alerts.error('Could not save the learning outcome. Please try again.'), }); } public selectLearningOutcome(learningOutcome: LearningOutcome) { if (this.selectedOutcome === learningOutcome) { - this.selectedOutcome = null; - this.selectedConnectedOutcomes.update((_selectedConnectedOutcomes) => []); + this.closeEditor(); } else { this.selectedOutcome = learningOutcome; - this.selectedConnectedOutcomes.update(() => this.getLinkedOutcomes(learningOutcome)); + this.snapshot = this.snapshotOf(learningOutcome); + this.selectedConnectedOutcomes.set(this.getLinkedOutcomes(learningOutcome)); if (!this.selectedOutcome.context) { this.selectedOutcome.context = this.context; } @@ -210,59 +249,65 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV } } - public sortOutcomeData(sort: Sort) { - const data = this.outcomeSource.data; - - if (!sort.active || sort.direction === '') { - this.outcomeSource.data = data; - return; + /** + * Put the open outcome back as it was when it was opened, and close it. A new outcome + * that was never saved is simply dropped. + */ + public cancelEdit(): void { + if (this.selectedOutcome && this.snapshot) { + Object.assign(this.selectedOutcome, this.snapshot); } + this.closeEditor(); + } - this.outcomeSource.data = data.sort((a, b) => { - const isAsc = sort.direction === 'asc'; - switch (sort.active) { - case 'abbreviation': - return this.compare(a.abbreviation, b.abbreviation, isAsc); - case 'shortDescription': - return this.compare(a.shortDescription, b.shortDescription, isAsc); - case 'fullOutcomeDescription': - return this.compare(a.fullOutcomeDescription, b.fullOutcomeDescription, isAsc); - default: - return 0; - } - }); + private closeEditor(): void { + this.selectedOutcome = null; + this.snapshot = null; + this.selectedConnectedOutcomes.set([]); } - public compare(a: number | string, b: number | string, isAsc: boolean): number { - return (a < b ? -1 : 1) * (isAsc ? 1 : -1); + private snapshotOf(outcome: LearningOutcome): OutcomeSnapshot { + return { + abbreviation: outcome.abbreviation, + shortDescription: outcome.shortDescription, + fullOutcomeDescription: outcome.fullOutcomeDescription, + linkedOutcomeIds: [...(outcome.linkedOutcomeIds ?? [])], + }; } applyFilter(filterValue: string) { - this.outcomeSource.filter = filterValue; + this.outcomeSource.filter = filterValue.trim().toLowerCase(); if (this.outcomeSource.paginator) { this.outcomeSource.paginator.firstPage(); } } + public get filtering(): boolean { + return !!this.outcomeSource.filter; + } + public learningOutcomeHasChanges(learningOutcome: LearningOutcome): boolean { return learningOutcome.hasChanges(this.learningOutcomeService.mapping); } public deleteLearningOutcome(learningOutcome: LearningOutcome) { this.confirmationModal.show( - 'Delete learning outcome', - 'Are you sure you want to delete this outcome? This action is final.', + `Delete ${learningOutcome.abbreviation}?`, + 'The outcome and its links to tasks go for good. This cannot be undone.', () => { learningOutcome.delete().subscribe({ next: () => { - this.alerts.success('Learning outcome deleted'); + this.alerts.success(`${learningOutcome.abbreviation} deleted`); if (this.selectedOutcome === learningOutcome) { - this.selectLearningOutcome(this.selectedOutcome); + this.closeEditor(); } }, - error: () => this.alerts.error('Failed to delete learning outcome. Please try again.'), + error: () => + this.alerts.error('Could not delete the learning outcome. Please try again.'), }); }, + undefined, + 'Delete', ); } @@ -270,6 +315,9 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV let url: string; if (type === 'Learning Outcomes') { + if (!this.context) { + return; + } url = this.context.getOutcomeBatchUploadUrl(); } else { if (this.context) { @@ -279,9 +327,10 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV } } + const what = type === 'Learning Outcomes' ? 'learning outcomes' : 'feedback comments'; this.csvUploadModal.show( - `Upload ${type} as CSV`, - 'Test message', + `Upload ${what}`, + `Upload a CSV of ${what}. Download the current list first to see the columns it needs.`, {file: {name: `${type} CSV Data`, type: 'csv'}}, url, (response: CsvResult) => { @@ -311,6 +360,9 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV let url: string; if (type === 'Learning Outcomes') { + if (!this.context) { + return; + } url = this.context.getOutcomeBatchUploadUrl(); } else { if (this.context) { @@ -350,16 +402,17 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV learningOutcome.abbreviation = this.abbreviationPrefix + String(this.getNextOutcomeNumber()); learningOutcome.shortDescription = ''; learningOutcome.fullOutcomeDescription = ''; - this.selectedConnectedOutcomes.update((_selectedConnectedOutcomes) => []); + this.selectedConnectedOutcomes.set([]); this.selectedOutcome = learningOutcome; + this.snapshot = null; } readonly separatorKeysCodes: number[] = [ENTER, COMMA]; readonly typedConnectedOutcome = model(''); readonly filteredOutcomes = computed(() => { const currentOutcome = this.typedConnectedOutcome().toLowerCase(); - return this.allOutcomes.filter((outcome) => { + return this.allOutcomes().filter((outcome) => { const abbreviation = outcome.abbreviation?.toLowerCase(); return ( !this.selectedConnectedOutcomes().includes(outcome) && @@ -374,7 +427,7 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV const value = (event.value || '').trim(); if (value) { - const outcome = this.allOutcomes.find( + const outcome = this.allOutcomes().find( (o) => o.abbreviation?.toLowerCase() === value.toLowerCase(), ); if (outcome && !this.selectedConnectedOutcomes().includes(outcome)) { @@ -399,9 +452,7 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV } select(event: MatAutocompleteSelectedEvent): void { - const outcome = this.allOutcomes.find( - (o) => o.abbreviation === event.option.viewValue.split(' - ')[0], - ); + const outcome = this.allOutcomes().find((o) => o.abbreviation === event.option.value); if (outcome && !this.selectedConnectedOutcomes().includes(outcome)) { this.selectedConnectedOutcomes.update((selectedConnectedOutcomes) => [ @@ -415,16 +466,25 @@ export class LearningOutcomeEditorComponent implements OnChanges, OnInit, AfterV } getLinkedOutcomes(learningOutcome: LearningOutcome): LearningOutcome[] { - return this.allOutcomes.filter((outcome) => - learningOutcome.linkedOutcomeIds.includes(outcome.id), - ); + const linked = learningOutcome.linkedOutcomeIds ?? []; + return this.allOutcomes().filter((outcome) => linked.includes(outcome.id)); } + /** + * The number for a new outcome's code: one more than the highest already used with + * this prefix. It used to read the last row of the table, which gave "ULONaN" when + * that row's code did not end in a number, or a repeat once the table was sorted. + */ getNextOutcomeNumber(): number { - if (this.outcomeSource.data && this.outcomeSource.data.length > 0) { - let abbr = this.outcomeSource.data[this.outcomeSource.data.length - 1].abbreviation; - abbr = abbr.replace(this.abbreviationPrefix, ''); - return Number(abbr) + 1; + const numbers = (this.outcomeSource.data ?? []) + .map((outcome) => outcome.abbreviation ?? '') + .filter((abbreviation) => abbreviation.startsWith(this.abbreviationPrefix)) + .map((abbreviation) => abbreviation.slice(this.abbreviationPrefix.length)) + .filter((suffix) => /^\d+$/.test(suffix)) + .map((suffix) => Number(suffix)); + + if (numbers.length > 0) { + return Math.max(...numbers) + 1; } return 1; } diff --git a/src/app/common/modals/confirmation-modal/confirmation-modal.component.html b/src/app/common/modals/confirmation-modal/confirmation-modal.component.html index beb1051f27..c7c89ce3dd 100644 --- a/src/app/common/modals/confirmation-modal/confirmation-modal.component.html +++ b/src/app/common/modals/confirmation-modal/confirmation-modal.component.html @@ -14,9 +14,9 @@
- +
diff --git a/src/app/common/modals/extension-modal/extension-modal.component.spec.ts b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts new file mode 100644 index 0000000000..b7e6c495f3 --- /dev/null +++ b/src/app/common/modals/extension-modal/extension-modal.component.spec.ts @@ -0,0 +1,391 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {LOCALE_ID} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ReactiveFormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {provideNativeDateAdapter} from '@angular/material/core'; +import {MatDatepickerInputEvent, MatDatepickerModule} from '@angular/material/datepicker'; +import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; +import {MatInputModule} from '@angular/material/input'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {of, throwError} from 'rxjs'; +import {Task, TaskCommentService} from 'src/app/api/models/doubtfire-model'; +import {AlertService} from '../../services/alert.service'; +import {ExtensionModalComponent} from './extension-modal.component'; + +function buildTask( + dueDate = new Date('2026-09-01T00:00:00Z'), + deadlineDate = new Date('2026-10-01T00:00:00Z'), +): Task { + return { + definition: {abbreviation: '1.1P', name: 'Hello World'}, + localDueDate: () => dueDate, + localDeadlineDate: () => deadlineDate, + } as unknown as Task; +} + +function pickDate(component: ExtensionModalComponent, value: Date | null): void { + component.addEvent('input', {value} as MatDatepickerInputEvent); +} + +function buildComponent(requestExtension = vi.fn(() => of({})), task = buildTask()) { + const close = vi.fn(); + const afterApplication = vi.fn(); + const alerts = {success: vi.fn(), error: vi.fn()}; + const component = new ExtensionModalComponent( + {close} as unknown as MatDialogRef, + {task, afterApplication}, + 'en-AU', + alerts as unknown as AlertService, + {requestExtension} as unknown as TaskCommentService, + ); + + return {component, close, afterApplication, requestExtension, alerts, task}; +} + +describe('ExtensionModalComponent', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('closes without prompting while pristine and confirms before discarding entered text', () => { + const {component} = buildComponent(); + const confirm = vi.fn(() => false); + vi.stubGlobal('confirm', confirm); + + expect(component.canClose()).toBe(true); + + component.extensionData.controls.extensionReason.setValue( + 'A sufficiently detailed reason for the request', + ); + component.extensionData.controls.extensionReason.markAsDirty(); + + expect(component.canClose()).toBe(false); + expect(confirm).toHaveBeenCalledWith( + 'Discard this extension request? Your entered details will be lost.', + ); + + confirm.mockReturnValue(true); + expect(component.canClose()).toBe(true); + }); + + it('keeps the dialog open on a controlled error and closes only after success', () => { + const failure = buildComponent( + vi.fn(() => throwError(() => new Error('sensitive transport detail'))), + ); + failure.component.extensionData.controls.extensionReason.setValue( + 'A sufficiently detailed reason for the request', + ); + // Submit refuses anything the button would refuse, so the date has to be + // picked here as it would be on screen. minDate is always in range. + pickDate(failure.component, failure.component.minDate); + + failure.component.submitApplication(); + + expect(failure.close).not.toHaveBeenCalled(); + expect(failure.component.errorMessage).toBe( + 'The extension request could not be sent. Please try again.', + ); + expect(failure.alerts.error).not.toHaveBeenCalledWith( + expect.stringContaining('sensitive transport detail'), + expect.anything(), + ); + + const success = buildComponent(); + success.component.extensionData.controls.extensionReason.setValue( + 'A sufficiently detailed reason for the request', + ); + pickDate(success.component, success.component.minDate); + success.component.submitApplication(); + + expect(success.requestExtension).toHaveBeenCalled(); + expect(success.afterApplication).toHaveBeenCalled(); + expect(success.close).toHaveBeenCalled(); + expect(success.component.canClose()).toBe(true); + }); + + it('does not throw when the comments panel is not on the page', () => { + vi.useFakeTimers(); + const component = Object.create(ExtensionModalComponent.prototype) as { + scrollCommentsDown(): void; + }; + + expect(() => { + component.scrollCommentsDown(); + vi.runAllTimers(); + }).not.toThrow(); + + vi.useRealTimers(); + }); +}); + +// Local dates, so the weekday labels hold in any time zone. 11 Sep 2026 is a Friday. +const DUE = new Date(2026, 8, 11, 23, 59); +const DEADLINE = new Date(2026, 9, 1, 23, 59); +const REASON = 'I was unwell for three days and missed the lab'; + +describe('ExtensionModalComponent presentation', () => { + beforeEach(() => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 5, 12, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('turns the counter to warning at 230 characters and error at the limit', () => { + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + const reason = component.extensionData.controls.extensionReason; + + reason.setValue('a'.repeat(229)); + expect(component.reasonCounterState).toBe('ok'); + reason.setValue('a'.repeat(230)); + expect(component.reasonCounterState).toBe('warn'); + reason.setValue('a'.repeat(256)); + expect(component.reasonCounterState).toBe('limit'); + }); + + it('summarises a picked date against the current due date in requested weeks', () => { + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + expect(component.extensionSummary).toBe(''); + + pickDate(component, new Date(2026, 8, 17)); + expect(component.extensionDays).toBe(6); + expect(component.extensionSummary).toBe('+1 week · Thu 17 Sep'); + + pickDate(component, new Date(2026, 8, 21)); + expect(component.extensionDays).toBe(10); + expect(component.extensionSummary).toBe('+2 weeks · Mon 21 Sep'); + }); + + it('shows the due date, the allowed range and how far past due the task is', () => { + const onTime = buildComponent(undefined, buildTask(DUE, DEADLINE)).component; + expect(onTime.formatShortDate(onTime.dueDate)).toBe('Fri 11 Sep'); + expect(onTime.dateRangeText).toBe('Sat 12 Sep to Thu 1 Oct'); + expect(onTime.daysPastDue).toBe(0); + + vi.setSystemTime(new Date(2026, 8, 20, 12, 0)); + const late = buildComponent(undefined, buildTask(DUE, DEADLINE)).component; + expect(late.daysPastDue).toBe(8); + }); + + it('requests the earliest date without a pick once the final deadline has passed', () => { + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + expect(component.hasDateRange).toBe(false); + expect(component.dateRangeText).toBe(''); + expect(component.extensionSummary).toBe('+4 weeks · Sun 4 Oct'); + + component.extensionData.controls.extensionReason.setValue(REASON); + expect(component.canSubmit).toBe(true); + }); + + it('refuses a date typed past the final deadline instead of sending it', () => { + // The out-of-range check used to return true outright once there was no + // range, so a typed 1 Jan 2030 went out as a 173 week request. + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + const {component, requestExtension} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + component.extensionData.controls.extensionReason.setValue(REASON); + + pickDate(component, new Date(2030, 0, 1)); + + // The date never lands, so nothing downstream can be derived from it. + expect(component.extensionSummary).toBe('+4 weeks · Sun 4 Oct'); + expect(component.isDateInRange).toBe(true); + expect(component.dateNeedsAttention).toBe(false); + + component.submitApplication(); + expect(requestExtension).toHaveBeenCalledWith(REASON, 4, expect.anything()); + }); + + it('holds the earliest date even if the guard is the only thing left', () => { + // Belt to the braces above: if the field is ever re-enabled, this is what + // stops a far-future date being requested. + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + (component as unknown as {extensionDate: Date}).extensionDate = new Date(2030, 0, 1); + expect(component.isDateInRange).toBe(false); + expect(component.dateErrorText).toBe( + 'The final deadline has passed, so only the earliest date can be requested', + ); + }); + + it('never names a date range that resolved to nothing', () => { + // A task with no deadline leaves hasDateRange true and dateRangeText empty, + // which is the pair that used to render "Pick a date from ." and no hint. + const {component} = buildComponent(undefined, buildTask(DUE, null as unknown as Date)); + + expect(component.hasDateRange).toBe(true); + expect(component.dateRangeText).toBe(''); + expect(component.dateErrorText).toBe('Pick a date on or after Sat 12 Sep'); + }); + + it('keeps submit disabled until the reason and a date in range are both valid', () => { + const {component} = buildComponent(undefined, buildTask(DUE, DEADLINE)); + + expect(component.canSubmit).toBe(false); + component.extensionData.controls.extensionReason.setValue(REASON); + expect(component.canSubmit).toBe(false); + + pickDate(component, new Date(2026, 9, 20)); + expect(component.dateNeedsAttention).toBe(true); + expect(component.canSubmit).toBe(false); + + pickDate(component, null); + expect(component.canSubmit).toBe(false); + + pickDate(component, new Date(2026, 8, 17)); + expect(component.canSubmit).toBe(true); + + component.submitting = true; + expect(component.canSubmit).toBe(false); + }); +}); + +describe('ExtensionModalComponent template', () => { + let fixture: ComponentFixture; + let component: ExtensionModalComponent; + + beforeEach(async () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 20, 12, 0)); + + await TestBed.configureTestingModule({ + declarations: [ExtensionModalComponent], + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatDatepickerModule, + MatDialogModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ], + providers: [ + provideNativeDateAdapter(), + {provide: MatDialogRef, useValue: {close: vi.fn()}}, + {provide: MAT_DIALOG_DATA, useValue: {task: buildTask(DUE, DEADLINE)}}, + {provide: LOCALE_ID, useValue: 'en-US'}, + {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}}, + {provide: TaskCommentService, useValue: {requestExtension: vi.fn(() => of({}))}}, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ExtensionModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const el = () => fixture.nativeElement as HTMLElement; + const submitButton = () => el().querySelector('button.ext-submit'); + + it('renders the title, task context, past due callout and new copy', () => { + expect(el().querySelector('[mat-dialog-title]')?.textContent?.trim()).toBe( + 'Request an extension', + ); + expect(el().querySelector('.ext-context')?.textContent).toContain('1.1P'); + expect(el().querySelector('.ext-context')?.textContent).toContain('Hello World'); + expect(el().querySelector('.ext-context__due')?.textContent?.trim()).toContain( + 'Due Fri 11 Sep', + ); + expect(el().querySelector('.ext-callout')?.textContent?.replace(/\s+/g, ' ').trim()).toContain( + 'This task is 8 days past its due date', + ); + expect(el().querySelector('.ext-description')?.textContent?.replace(/\s+/g, ' ').trim()).toBe( + "Tell your teaching team why you need more time. They'll review your request and reply in the task comments.", + ); + }); + + it('labels the primary action without the date and enables it once valid', () => { + expect(submitButton()?.textContent?.trim()).toBe('send Request extension'); + expect(submitButton()?.disabled).toBe(true); + + component.extensionData.controls.extensionReason.setValue(REASON); + pickDate(component, new Date(2026, 8, 25)); + fixture.detectChanges(); + + expect(submitButton()?.disabled).toBe(false); + expect(el().querySelector('.ext-summary')?.textContent?.trim()).toBe('+2 weeks · Fri 25 Sep'); + }); + + it('marks the counter as a warning from 230 characters', () => { + const counter = () => el().querySelector('.ext-counter'); + component.extensionData.controls.extensionReason.setValue('a'.repeat(230)); + fixture.detectChanges(); + + expect(counter()?.textContent?.trim()).toBe('230 / 256'); + expect(counter()?.classList).toContain('ext-counter--warn'); + }); + + it('shows a spinner and disables both actions while submitting', () => { + component.submitting = true; + fixture.detectChanges(); + + expect(el().querySelector('.ext-submit__spinner')).not.toBeNull(); + const buttons = Array.from( + el().querySelectorAll('.task-dialog-actions button'), + ); + expect(buttons.every((button) => button.disabled)).toBe(true); + }); +}); + +describe('ExtensionModalComponent template past the final deadline', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 9, 3, 12, 0)); + + await TestBed.configureTestingModule({ + declarations: [ExtensionModalComponent], + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatDatepickerModule, + MatDialogModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ], + providers: [ + provideNativeDateAdapter(), + {provide: MatDialogRef, useValue: {close: vi.fn()}}, + {provide: MAT_DIALOG_DATA, useValue: {task: buildTask(DUE, DEADLINE)}}, + {provide: LOCALE_ID, useValue: 'en-US'}, + {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}}, + {provide: TaskCommentService, useValue: {requestExtension: vi.fn(() => of({}))}}, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ExtensionModalComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('closes the date field rather than leaving it open over an empty range', () => { + const el = fixture.nativeElement as HTMLElement; + const input = el.querySelector('.ext-date input'); + const toggle = el.querySelector('mat-datepicker-toggle button'); + + expect(fixture.componentInstance.hasDateRange).toBe(false); + expect(input?.disabled).toBe(true); + // Disabling the input carries the picker and its toggle, so there is no way + // in through the calendar either. + expect(toggle?.disabled).toBe(true); + expect(el.querySelector('.ext-date mat-hint')?.textContent).toContain( + 'The final deadline has passed', + ); + }); +}); diff --git a/src/app/common/modals/extension-modal/extension-modal.component.ts b/src/app/common/modals/extension-modal/extension-modal.component.ts index 70ff977b6b..a56747d48f 100644 --- a/src/app/common/modals/extension-modal/extension-modal.component.ts +++ b/src/app/common/modals/extension-modal/extension-modal.component.ts @@ -1,11 +1,21 @@ -import {addDays, differenceInDays, differenceInWeeks, isAfter} from 'date-fns'; +import { + addDays, + differenceInCalendarDays, + differenceInDays, + differenceInWeeks, + endOfDay, + format, + isAfter, + isBefore, + isSameDay, + startOfDay, +} from 'date-fns'; import {ChangeDetectionStrategy, Component, Inject, LOCALE_ID} from '@angular/core'; import {FormControl, FormGroup, FormGroupDirective, NgForm, Validators} from '@angular/forms'; import {ErrorStateMatcher} from '@angular/material/core'; import {MatDatepickerInputEvent} from '@angular/material/datepicker'; import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; import {Task, TaskComment, TaskCommentService} from 'src/app/api/models/doubtfire-model'; -import {AppInjector} from 'src/app/app-injector'; import {AlertService} from '../../services/alert.service'; /** Error when invalid control is dirty, touched, or submitted. */ @@ -19,20 +29,29 @@ export class ReasonErrorStateMatcher implements ErrorStateMatcher { @Component({ selector: 'extension-modal', templateUrl: './extension-modal.component.html', + styleUrl: './extension-modal.component.scss', changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) export class ExtensionModalComponent { protected reasonMinLength: number = 15; protected reasonMaxLength: number = 256; + /** The counter turns to the warning colour from this many characters. */ + protected readonly reasonWarnLength = 230; + submitting = false; + errorMessage = ''; + private allowClose = false; + private dateChanged = false; + private dateEntryInvalid = false; constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: {task: Task; afterApplication?: () => void}, + @Inject(LOCALE_ID) public currentLocale: string, private alerts: AlertService, + private taskComments: TaskCommentService, ) {} matcher = new ReasonErrorStateMatcher(); - currentLocale = AppInjector.get(LOCALE_ID); extensionData = new FormGroup({ extensionReason: new FormControl('', [ Validators.required, @@ -64,20 +83,168 @@ export class ExtensionModalComponent { maxDate = this.data.task.localDeadlineDate(); // deadline, hard deadline extensionDate = new Date(this.minDate); addEvent(type: string, event: MatDatepickerInputEvent) { - this.extensionDate = new Date(event.value); + // With no range left the request carries the earliest date, so there is + // nothing here to change. Refusing the write makes that an invariant rather + // than something the validity getters have to keep catching, and it means + // extensionDuration can only ever be derived from a date that was allowed. + if (!this.hasDateRange) { + return; + } + + this.dateEntryInvalid = !event.value; + if (event.value) { + this.extensionDate = new Date(event.value); + this.dateChanged = true; + } + } + + /** Short date for the dialog, e.g. "Fri 11 Sep". */ + public formatShortDate(date: Date): string { + return format(date, 'EEE d MMM'); + } + + public get dueDate(): Date { + return this.data.task.localDueDate(); + } + + /** + * Whole days the task is past its due date, or 0 when it is not overdue. Rounds down, + * like the "Passed Due Date By" banner on the task page. + */ + public get daysPastDue(): number { + const diff = Date.now() - this.dueDate.getTime(); + return diff > 0 ? Math.floor(diff / (1000 * 3600 * 24)) : 0; + } + + public get reasonLength(): number { + return this.extensionData.controls.extensionReason.value?.length ?? 0; + } + + public get reasonCounterState(): 'ok' | 'warn' | 'limit' { + if (this.reasonLength >= this.reasonMaxLength) { + return 'limit'; + } + return this.reasonLength >= this.reasonWarnLength ? 'warn' : 'ok'; + } + + /** + * False once the final deadline has passed, when the earliest date is after the latest. + * The request then goes out for the earliest date, as it always has. + */ + public get hasDateRange(): boolean { + return !this.maxDate || !isAfter(startOfDay(this.minDate), this.maxDate); + } + + /** The allowed range for the new due date, e.g. "Sat 12 Sep to Thu 1 Oct". */ + public get dateRangeText(): string { + if (!this.minDate || !this.maxDate || !this.hasDateRange) { + return ''; + } + return `${this.formatShortDate(this.minDate)} to ${this.formatShortDate(this.maxDate)}`; + } + + /** Calendar days between the current due date and the picked date. */ + public get extensionDays(): number { + return differenceInCalendarDays(this.extensionDate, this.dueDate); + } + + public get isDateInRange(): boolean { + if (!this.hasDateRange) { + // There is no range left to choose from, so the earliest date is the only + // one the request can carry. Returning true here instead turned every + // check off: a typed date of 1 Jan 2030 passed validation and went out as + // a 173 week extension. + return isSameDay(this.extensionDate, this.minDate); + } + if (this.minDate && isBefore(this.extensionDate, startOfDay(this.minDate))) { + return false; + } + if (this.maxDate && isAfter(this.extensionDate, endOfDay(this.maxDate))) { + return false; + } + return true; + } + + /** True once the date field holds something that cannot be requested. */ + public get dateNeedsAttention(): boolean { + return this.dateEntryInvalid || (this.dateChanged && !this.isDateInRange); + } + + /** + * Why the date will not do. hasDateRange and dateRangeText do not empty on + * the same condition, so this has to ask about the text it is going to use + * rather than assume a range exists wherever hasDateRange is true. Naming a + * range that resolved to nothing is what produced "Pick a date from ." + */ + public get dateErrorText(): string { + if (!this.hasDateRange) { + return 'The final deadline has passed, so only the earliest date can be requested'; + } + if (!this.dateRangeText) { + return `Pick a date on or after ${this.formatShortDate(this.minDate)}`; + } + return `Pick a date from ${this.dateRangeText}`; + } + + /** + * The picked extension as it will be requested, e.g. "+1 week · Thu 17 Sep". + * Requests are made in whole weeks, so the length is shown in weeks. + */ + public get extensionSummary(): string { + if ((!this.dateChanged && this.hasDateRange) || this.dateNeedsAttention) { + return ''; + } + const weeks = this.extensionDuration; + return `+${weeks} ${weeks === 1 ? 'week' : 'weeks'} · ${this.formatShortDate(this.extensionDate)}`; + } + + public get canSubmit(): boolean { + return ( + this.extensionData.valid && + (this.dateChanged || !this.hasDateRange) && + !this.dateNeedsAttention && + !this.submitting + ); + } + + public get isDirty(): boolean { + return this.extensionData.dirty || this.dateChanged; + } + + public canClose(): boolean { + if (this.allowClose || !this.isDirty) { + return true; + } + + return globalThis.confirm('Discard this extension request? Your entered details will be lost.'); + } + + public requestClose(): void { + this.dialogRef.close(); } private scrollCommentsDown(): void { setTimeout(() => { - const objDiv = document.querySelector('div.comments-body'); - // let wrappedResult = angular.element(objDiv); + const objDiv = document.querySelector('div.comments-body'); + if (!objDiv) { + return; + } objDiv.scrollTop = objDiv.scrollHeight; }, 50); } - submitApplication() { - const tcs: TaskCommentService = AppInjector.get(TaskCommentService); - tcs + submitApplication(): void { + // Checks what the button checks, rather than only the reason. The date was + // guarded by the disabled state alone, so anything that reached this method + // with a bad date sent it. + if (!this.canSubmit) { + this.extensionData.markAllAsTouched(); + return; + } + + this.submitting = true; + this.errorMessage = ''; + this.taskComments .requestExtension( this.extensionData.controls.extensionReason.value, this.extensionDuration, @@ -91,10 +258,14 @@ export class ExtensionModalComponent { if (typeof this.data.afterApplication === 'function') { this.data.afterApplication(); } + this.allowClose = true; + this.dialogRef.close(); }).bind(this), - error: ((response: never) => { - this.alerts.error('Error requesting extension ' + response); + error: (() => { + this.submitting = false; + this.errorMessage = 'The extension request could not be sent. Please try again.'; + this.alerts.error(this.errorMessage, 4000); }).bind(this), }); } diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts index 8322724d1b..20ffb78b20 100644 --- a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts +++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts @@ -41,8 +41,10 @@ export class ScormExtensionModalComponent { private scrollCommentsDown(): void { setTimeout(() => { - const objDiv = document.querySelector('div.comments-body'); - // let wrappedResult = angular.element(objDiv); + const objDiv = document.querySelector('div.comments-body'); + if (!objDiv) { + return; + } objDiv.scrollTop = objDiv.scrollHeight; }, 50); } diff --git a/src/app/common/pipes/marked.pipe.spec.ts b/src/app/common/pipes/marked.pipe.spec.ts index 5ac4060da5..0092cdb163 100644 --- a/src/app/common/pipes/marked.pipe.spec.ts +++ b/src/app/common/pipes/marked.pipe.spec.ts @@ -6,4 +6,23 @@ describe('MarkedPipe', () => { const pipe = new MarkedPipe(); expect(pipe).toBeTruthy(); }); + + it('renders a fenced code block as
', () => {
+    const pipe = new MarkedPipe();
+    const html = pipe.transform('```\ncode\n```');
+    expect(html).toContain('
');
+  });
+
+  it('renders a markdown list as a 
    with two items', () => { + const pipe = new MarkedPipe(); + const html = pipe.transform('- a\n- b'); + expect(html).toContain('
      '); + expect((html.match(/
    • /g) || []).length).toBe(2); + }); + + it('still turns a soft line break into a single
      ', () => { + const pipe = new MarkedPipe(); + const html = pipe.transform('line1\nline2'); + expect((html.match(//g) || []).length).toBe(1); + }); }); diff --git a/src/app/common/pipes/marked.pipe.ts b/src/app/common/pipes/marked.pipe.ts index 3a0e09f2f3..571609fab5 100644 --- a/src/app/common/pipes/marked.pipe.ts +++ b/src/app/common/pipes/marked.pipe.ts @@ -18,7 +18,7 @@ export class MarkedPipe implements PipeTransform { transform(value: string): string { if (value && value.length > 0) { - return marked.parse(value.replaceAll(/\r\n|\r|\n/g, '
      '), {async: false}) as string; + return marked.parse(value, {async: false}) as string; } return value; } diff --git a/src/app/common/pipes/safe.pipe.spec.ts b/src/app/common/pipes/safe.pipe.spec.ts index 1a1332fcd4..fe9e9153ec 100644 --- a/src/app/common/pipes/safe.pipe.spec.ts +++ b/src/app/common/pipes/safe.pipe.spec.ts @@ -1,7 +1,22 @@ -import {describe, it} from 'vitest'; +import {describe, expect, it, vi} from 'vitest'; +import {DomSanitizer} from '@angular/platform-browser'; +import {SafePipe} from './safe.pipe'; describe('SafePipe', () => { - it('create an instance', () => { - /* empty */ + it('hands a blob url to the sanitizer bypass', () => { + const bypass = vi.fn().mockReturnValue('trusted-blob'); + const pipe = new SafePipe({bypassSecurityTrustResourceUrl: bypass} as unknown as DomSanitizer); + const blobUrl = 'blob:https://doubtfire.deakin.edu.au/8f14e45f-ceea-4d9e-9915-99b1c4e2f2d1'; + + expect(pipe.transform(blobUrl)).toBe('trusted-blob'); + expect(bypass).toHaveBeenCalledWith(blobUrl); + }); + + it('hands a regular url to the sanitizer bypass', () => { + const bypass = vi.fn().mockReturnValue('trusted'); + const pipe = new SafePipe({bypassSecurityTrustResourceUrl: bypass} as unknown as DomSanitizer); + + expect(pipe.transform('https://example.test/doc.pdf')).toBe('trusted'); + expect(bypass).toHaveBeenCalledWith('https://example.test/doc.pdf'); }); }); diff --git a/src/app/common/pwa-connection/pwa-connection-status.component.html b/src/app/common/pwa-connection/pwa-connection-status.component.html new file mode 100644 index 0000000000..d5335b0ce8 --- /dev/null +++ b/src/app/common/pwa-connection/pwa-connection-status.component.html @@ -0,0 +1,8 @@ +
      + @if (offline()) { +

      + You’re offline. Reconnect to sign in, load current information or submit + work. Work is not queued for upload. +

      + } +
      diff --git a/src/app/common/pwa-connection/pwa-connection-status.component.scss b/src/app/common/pwa-connection/pwa-connection-status.component.scss new file mode 100644 index 0000000000..2d4561bab5 --- /dev/null +++ b/src/app/common/pwa-connection/pwa-connection-status.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; +} + +.connection-status { + margin: 0; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--ot-color-warning); + background: var(--ot-color-surface); + color: var(--ot-color-text); + text-align: center; + line-height: 1.5; +} diff --git a/src/app/common/pwa-connection/pwa-connection-status.component.spec.ts b/src/app/common/pwa-connection/pwa-connection-status.component.spec.ts new file mode 100644 index 0000000000..0f6e107231 --- /dev/null +++ b/src/app/common/pwa-connection/pwa-connection-status.component.spec.ts @@ -0,0 +1,74 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {PwaConnectionStatusComponent} from './pwa-connection-status.component'; + +describe('PwaConnectionStatusComponent', () => { + let fixture: ComponentFixture; + let online: boolean; + + beforeEach(async () => { + online = true; + vi.spyOn(window.navigator, 'onLine', 'get').mockImplementation(() => online); + await TestBed.configureTestingModule({ + imports: [PwaConnectionStatusComponent], + }).compileComponents(); + }); + + afterEach(() => { + fixture?.destroy(); + vi.restoreAllMocks(); + }); + + function createComponent(): HTMLElement { + fixture = TestBed.createComponent(PwaConnectionStatusComponent); + fixture.detectChanges(); + return fixture.nativeElement; + } + + it('keeps an empty live region without a banner when the browser is online', () => { + const element = createComponent(); + const status = element.querySelector('[role="status"]'); + + expect(status?.getAttribute('aria-live')).toBe('polite'); + expect(status?.getAttribute('aria-atomic')).toBe('true'); + expect(status?.textContent?.trim()).toBe(''); + expect(element.querySelector('.connection-status')).toBeNull(); + }); + + it('explains the limits of the cached app when launched offline', () => { + online = false; + const element = createComponent(); + + expect(element.textContent?.replace(/\s+/g, ' ').trim()).toBe( + 'You’re offline. Reconnect to sign in, load current information or submit work. Work is not queued for upload.', + ); + }); + + it('announces connection loss and removes the warning on reconnection without reloading', () => { + const element = createComponent(); + const liveRegion = element.querySelector('[role="status"]'); + + online = false; + window.dispatchEvent(new Event('offline')); + fixture.detectChanges(); + expect(element.querySelector('.connection-status')).not.toBeNull(); + expect(element.querySelector('[role="status"]')).toBe(liveRegion); + + online = true; + window.dispatchEvent(new Event('online')); + fixture.detectChanges(); + expect(element.querySelector('.connection-status')).toBeNull(); + expect(element.querySelector('[role="status"]')).toBe(liveRegion); + }); + + it('stops responding to window events when destroyed', () => { + createComponent(); + const component = fixture.componentInstance; + fixture.destroy(); + + online = false; + window.dispatchEvent(new Event('offline')); + + expect(component.offline()).toBe(false); + }); +}); diff --git a/src/app/common/pwa-connection/pwa-connection-status.component.ts b/src/app/common/pwa-connection/pwa-connection-status.component.ts new file mode 100644 index 0000000000..a5d95607fc --- /dev/null +++ b/src/app/common/pwa-connection/pwa-connection-status.component.ts @@ -0,0 +1,22 @@ +import {DOCUMENT} from '@angular/common'; +import {ChangeDetectionStrategy, Component, HostListener, inject, signal} from '@angular/core'; + +@Component({ + selector: 'f-pwa-connection-status', + standalone: true, + templateUrl: './pwa-connection-status.component.html', + styleUrl: './pwa-connection-status.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PwaConnectionStatusComponent { + private readonly document = inject(DOCUMENT); + + // This reports the browser's connection hint, not whether the API is healthy. + readonly offline = signal(this.document.defaultView?.navigator.onLine === false); + + @HostListener('window:online') + @HostListener('window:offline') + updateConnectionStatus(): void { + this.offline.set(this.document.defaultView?.navigator.onLine === false); + } +} diff --git a/src/app/common/pwa/pwa-install-button.component.html b/src/app/common/pwa/pwa-install-button.component.html new file mode 100644 index 0000000000..5c6468eb62 --- /dev/null +++ b/src/app/common/pwa/pwa-install-button.component.html @@ -0,0 +1,3 @@ + diff --git a/src/app/common/pwa/pwa-install-button.component.spec.ts b/src/app/common/pwa/pwa-install-button.component.spec.ts new file mode 100644 index 0000000000..7c2a88ad75 --- /dev/null +++ b/src/app/common/pwa/pwa-install-button.component.spec.ts @@ -0,0 +1,54 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatDialog} from '@angular/material/dialog'; +import {PwaInstallButtonComponent} from './pwa-install-button.component'; +import {PwaInstallDialogComponent} from './pwa-install-dialog.component'; +import {PwaInstallService} from './pwa-install.service'; + +describe('PwaInstallButtonComponent', () => { + let fixture: ComponentFixture; + let installed: ReturnType>; + let openDialog: ReturnType; + + beforeEach(async () => { + installed = signal(false); + openDialog = vi.fn(); + await TestBed.configureTestingModule({ + imports: [PwaInstallButtonComponent], + providers: [ + {provide: PwaInstallService, useValue: {isInstalled: installed}}, + {provide: MatDialog, useValue: {open: openDialog}}, + ], + }).compileComponents(); + fixture = TestBed.createComponent(PwaInstallButtonComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('offers installation help from a button without submitting the sign-in form', () => { + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button.type).toBe('button'); + expect(button.textContent).toContain('Install OnTrack app'); + expect(openDialog).not.toHaveBeenCalled(); + + button.click(); + expect(openDialog).toHaveBeenCalledWith(PwaInstallDialogComponent, { + width: '560px', + maxWidth: 'calc(100vw - 32px)', + autoFocus: 'first-heading', + }); + }); + + it('keeps help accessible after installation with an appropriate label', () => { + installed.set(true); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + expect(button.textContent).toContain('OnTrack app help'); + button.click(); + expect(openDialog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/common/pwa/pwa-install-button.component.ts b/src/app/common/pwa/pwa-install-button.component.ts new file mode 100644 index 0000000000..cd1933b945 --- /dev/null +++ b/src/app/common/pwa/pwa-install-button.component.ts @@ -0,0 +1,25 @@ +import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatDialog} from '@angular/material/dialog'; +import {PwaInstallDialogComponent} from './pwa-install-dialog.component'; +import {PwaInstallService} from './pwa-install.service'; + +@Component({ + selector: 'f-pwa-install-button', + standalone: true, + imports: [MatButtonModule], + templateUrl: './pwa-install-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PwaInstallButtonComponent { + readonly pwaInstall = inject(PwaInstallService); + private readonly dialog = inject(MatDialog); + + openInstallHelp(): void { + this.dialog.open(PwaInstallDialogComponent, { + width: '560px', + maxWidth: 'calc(100vw - 32px)', + autoFocus: 'first-heading', + }); + } +} diff --git a/src/app/common/pwa/pwa-install-dialog.component.html b/src/app/common/pwa/pwa-install-dialog.component.html new file mode 100644 index 0000000000..a3405e1029 --- /dev/null +++ b/src/app/common/pwa/pwa-install-dialog.component.html @@ -0,0 +1,105 @@ +

      {{ pwaInstall.isInstalled() ? 'OnTrack app' : 'Install OnTrack' }}

      + + + @if (pwaInstall.isInstalled()) { +

      OnTrack is installed or running in its app window.

      +

      Open OnTrack from your device’s app launcher, Start menu, Dock, or home screen.

      + } @else { +

      Keep OnTrack in its own window and open it straight from your desktop or home screen.

      + @if (!pwaInstall.canInstall() && !pwaInstall.isInstalling()) { +

      + Choose your browser below for installation instructions. You can also keep using this tab. +

      + } + } + +

      + You still need an internet connection to sign in, load coursework, and submit work. +

      + +

      {{ pwaInstall.installMessage() }}

      + + @if (!pwaInstall.isInstalled()) { +
      +
      + Chrome on Windows, macOS or Linux +
        +
      1. Open OnTrack in a regular Chrome window.
      2. +
      3. + Select the install icon in the address bar, or open the browser menu and choose + Cast, save and share → Install page as app. +
      4. +
      5. Confirm installation, then open OnTrack from your app launcher.
      6. +
      +
      + +
      + Microsoft Edge +
        +
      1. Open OnTrack in a regular Edge window.
      2. +
      3. + Select the install icon in the address bar, or choose + Settings and more → More tools → Apps → Install this site as an app. +
      4. +
      5. Confirm installation. You can find OnTrack in Edge’s Apps page.
      6. +
      +

      If Edge on Linux does not offer installation, use Chrome or continue in your browser.

      +
      + +
      + Safari on macOS +
        +
      1. Open OnTrack in Safari on macOS Sonoma 14 or later.
      2. +
      3. Choose File → Add to Dock from the menu bar.
      4. +
      5. Choose Add, then open OnTrack from the Dock.
      6. +
      +

      You may need to sign in again in the new app window.

      +
      + +
      + Firefox and other desktop browsers +

      + On Windows, Firefox 143 or later offers a web apps button in the address bar to add a site + to your taskbar. Firefox installed from the Microsoft Store needs version 150 or later. +

      +

      + If your browser has no install option, including Firefox on macOS or Linux, keep using + OnTrack in this tab or open it in Chrome. On a supported Mac, you can also use Safari’s + Add to Dock. +

      +
      + +
      + iPhone, iPad or Android +

      + iPhone and iPad: Open OnTrack in Safari, tap Share, then + Add to Home Screen and confirm. +

      +

      + Android: Open OnTrack in Chrome, open its menu, then choose + Install app or Add to Home screen and confirm. +

      +
      +
      +

      + Missing an install option? Check whether OnTrack is already in your app launcher. Try a + regular browsing window; private browsing and your organisation’s browser settings can limit + installation. +

      + } +
      + + + + @if (!pwaInstall.isInstalled() && (pwaInstall.canInstall() || pwaInstall.isInstalling())) { + + } + diff --git a/src/app/common/pwa/pwa-install-dialog.component.scss b/src/app/common/pwa/pwa-install-dialog.component.scss new file mode 100644 index 0000000000..94c71d68f6 --- /dev/null +++ b/src/app/common/pwa/pwa-install-dialog.component.scss @@ -0,0 +1,43 @@ +:host { + display: block; +} + +.connection-note { + font-size: 0.9rem; +} + +.install-status:empty { + margin: 0; +} + +.browser-instructions { + margin-block: 1rem; + + details { + border-block-start: 1px solid var(--ot-color-border); + padding-block: 0.75rem; + } + + summary { + cursor: pointer; + font-weight: 600; + padding-block: 0.25rem; + } + + summary:focus-visible { + outline: 2px solid currentColor; + outline-offset: 4px; + } + + ol { + padding-inline-start: 1.5rem; + } + + li + li { + margin-block-start: 0.5rem; + } +} + +.installation-help { + font-size: 0.9rem; +} diff --git a/src/app/common/pwa/pwa-install-dialog.component.spec.ts b/src/app/common/pwa/pwa-install-dialog.component.spec.ts new file mode 100644 index 0000000000..1774e13ab3 --- /dev/null +++ b/src/app/common/pwa/pwa-install-dialog.component.spec.ts @@ -0,0 +1,98 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {PwaInstallDialogComponent} from './pwa-install-dialog.component'; +import {PwaInstallService} from './pwa-install.service'; + +describe('PwaInstallDialogComponent', () => { + let fixture: ComponentFixture; + let service: { + isInstalled: ReturnType>; + canInstall: ReturnType>; + isInstalling: ReturnType>; + installMessage: ReturnType>; + install: ReturnType; + }; + + beforeEach(async () => { + service = { + isInstalled: signal(false), + canInstall: signal(false), + isInstalling: signal(false), + installMessage: signal(''), + install: vi.fn().mockResolvedValue('accepted'), + }; + await TestBed.configureTestingModule({ + imports: [PwaInstallDialogComponent], + providers: [{provide: PwaInstallService, useValue: service}], + }).compileComponents(); + fixture = TestBed.createComponent(PwaInstallDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + function installButton(): HTMLButtonElement | null { + return fixture.nativeElement.querySelector('button[mat-flat-button]'); + } + + it('opens with manual browser instructions and never installs automatically', () => { + expect(service.install).not.toHaveBeenCalled(); + expect(installButton()).toBeNull(); + const labels = Array.from( + fixture.nativeElement.querySelectorAll('summary') as NodeListOf, + ).map((summary) => summary.textContent); + expect(labels).toEqual([ + 'Chrome on Windows, macOS or Linux', + 'Microsoft Edge', + 'Safari on macOS', + 'Firefox and other desktop browsers', + 'iPhone, iPad or Android', + ]); + expect(fixture.nativeElement.textContent).toContain('internet connection'); + expect(fixture.nativeElement.textContent).toContain('Add to Home Screen'); + }); + + it('only shows direct installation when the browser provides that capability', () => { + service.canInstall.set(true); + fixture.detectChanges(); + expect(installButton()).not.toBeNull(); + expect(service.install).not.toHaveBeenCalled(); + + installButton().click(); + expect(service.install).toHaveBeenCalledTimes(1); + }); + + it('disables the installation action while awaiting the browser', () => { + service.isInstalling.set(true); + fixture.detectChanges(); + + expect(installButton().disabled).toBe(true); + expect(installButton().textContent).toContain('Installing'); + installButton().click(); + expect(service.install).not.toHaveBeenCalled(); + }); + + it('announces installation outcomes in a live status region', () => { + service.installMessage.set('Installation cancelled.'); + fixture.detectChanges(); + const status: HTMLElement = fixture.nativeElement.querySelector('[role="status"]'); + + expect(status.getAttribute('aria-live')).toBe('polite'); + expect(status.textContent).toContain('Installation cancelled'); + }); + + it('shows app launch help instead of installation instructions once installed', () => { + // appinstalled may arrive before the outstanding prompt promise settles. + service.isInstalling.set(true); + service.isInstalled.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('h2').textContent).toBe('OnTrack app'); + expect(fixture.nativeElement.textContent).toContain('Start menu, Dock, or home screen'); + expect(fixture.nativeElement.querySelector('details')).toBeNull(); + expect(installButton()).toBeNull(); + }); +}); diff --git a/src/app/common/pwa/pwa-install-dialog.component.ts b/src/app/common/pwa/pwa-install-dialog.component.ts new file mode 100644 index 0000000000..fb515036ff --- /dev/null +++ b/src/app/common/pwa/pwa-install-dialog.component.ts @@ -0,0 +1,20 @@ +import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatDialogModule} from '@angular/material/dialog'; +import {PwaInstallService} from './pwa-install.service'; + +@Component({ + selector: 'f-pwa-install-dialog', + standalone: true, + imports: [MatButtonModule, MatDialogModule], + templateUrl: './pwa-install-dialog.component.html', + styleUrl: './pwa-install-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PwaInstallDialogComponent { + readonly pwaInstall = inject(PwaInstallService); + + install(): void { + void this.pwaInstall.install(); + } +} diff --git a/src/app/common/pwa/pwa-install.service.spec.ts b/src/app/common/pwa/pwa-install.service.spec.ts new file mode 100644 index 0000000000..5d07773f38 --- /dev/null +++ b/src/app/common/pwa/pwa-install.service.spec.ts @@ -0,0 +1,251 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {DOCUMENT} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {PwaInstallService} from './pwa-install.service'; + +interface TestMediaQuery extends EventTarget { + matches: boolean; + media: string; +} + +describe('PwaInstallService', () => { + let browser: EventTarget & { + navigator: {standalone: boolean}; + matchMedia: ReturnType TestMediaQuery>>; + }; + let queries: Map; + + beforeEach(() => { + queries = new Map(); + browser = Object.assign(new EventTarget(), { + navigator: {standalone: false}, + matchMedia: vi.fn((media: string) => { + if (!queries.has(media)) { + const query = Object.assign(new EventTarget(), {matches: false, media}); + queries.set(media, query); + } + return queries.get(media); + }), + }); + TestBed.configureTestingModule({ + providers: [PwaInstallService, {provide: DOCUMENT, useValue: {defaultView: browser}}], + }); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + vi.restoreAllMocks(); + }); + + function offerInstall(outcome: 'accepted' | 'dismissed' = 'accepted') { + const prompt = vi.fn().mockResolvedValue(undefined); + const event = new Event('beforeinstallprompt', {cancelable: true}); + Object.assign(event, { + prompt, + userChoice: Promise.resolve({outcome, platform: 'web'}), + }); + browser.dispatchEvent(event); + return {event, prompt}; + } + + it('starts without install capability and gives menu help when no event exists', async () => { + const service = TestBed.inject(PwaInstallService); + expect(service.canInstall()).toBe(false); + expect(service.isInstalled()).toBe(false); + expect(await service.install()).toBe('unavailable'); + expect(service.installMessage()).toContain('browser’s menu'); + }); + + it('retains an install event without automatically displaying the browser prompt', () => { + const service = TestBed.inject(PwaInstallService); + const {event, prompt} = offerInstall(); + + expect(event.defaultPrevented).toBe(true); + expect(service.canInstall()).toBe(true); + expect(service.installMessage()).toBe(''); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('invokes prompt synchronously from the user action and consumes each event once', async () => { + const service = TestBed.inject(PwaInstallService); + const {prompt} = offerInstall(); + + const result = service.install(); + expect(prompt).toHaveBeenCalledTimes(1); + expect(service.isInstalling()).toBe(true); + expect(service.canInstall()).toBe(false); + expect(await service.install()).toBe('unavailable'); + expect(await result).toBe('accepted'); + expect(service.isInstalling()).toBe(false); + expect(service.isInstalled()).toBe(false); + expect(service.installMessage()).toContain('Installation requested'); + expect(await service.install()).toBe('unavailable'); + expect(prompt).toHaveBeenCalledTimes(1); + }); + + it('allows another browser event after dismissal without permanently suppressing installation', async () => { + const service = TestBed.inject(PwaInstallService); + const first = offerInstall('dismissed'); + + expect(await service.install()).toBe('dismissed'); + expect(service.isInstalled()).toBe(false); + expect(service.installMessage()).toContain('cancelled'); + expect(service.canInstall()).toBe(false); + + const second = offerInstall(); + expect(service.canInstall()).toBe(true); + expect(service.installMessage()).toBe(''); + expect(await service.install()).toBe('accepted'); + expect(first.prompt).toHaveBeenCalledTimes(1); + expect(second.prompt).toHaveBeenCalledTimes(1); + }); + + it('handles a rejected prompt and permits retry only after a fresh event', async () => { + const service = TestBed.inject(PwaInstallService); + const {prompt} = offerInstall(); + prompt.mockRejectedValue(new Error('Installation is unavailable')); + + expect(await service.install()).toBe('error'); + expect(service.isInstalling()).toBe(false); + expect(service.isInstalled()).toBe(false); + expect(service.canInstall()).toBe(false); + expect(service.installMessage()).toContain('could not complete'); + + offerInstall(); + expect(await service.install()).toBe('accepted'); + }); + + it('handles a synchronously thrown prompt error', async () => { + const service = TestBed.inject(PwaInstallService); + const {prompt} = offerInstall(); + prompt.mockImplementation(() => { + throw new Error('User activation expired'); + }); + + expect(await service.install()).toBe('error'); + expect(service.isInstalling()).toBe(false); + }); + + it('handles a rejected user choice without an unhandled rejection', async () => { + const service = TestBed.inject(PwaInstallService); + let rejectChoice: (error: Error) => void; + const event = Object.assign(new Event('beforeinstallprompt'), { + prompt: vi.fn().mockResolvedValue(undefined), + userChoice: new Promise((_resolve, reject) => { + rejectChoice = reject; + }), + }); + browser.dispatchEvent(event); + const result = service.install(); + await Promise.resolve(); + rejectChoice(new Error('Browser closed')); + + expect(await result).toBe('error'); + expect(service.isInstalling()).toBe(false); + }); + + it('marks installation complete only when the browser confirms appinstalled', async () => { + const service = TestBed.inject(PwaInstallService); + const {prompt} = offerInstall(); + browser.dispatchEvent(new Event('appinstalled')); + + expect(service.isInstalled()).toBe(true); + expect(service.canInstall()).toBe(false); + expect(service.installMessage()).toContain('OnTrack is installed'); + expect(await service.install()).toBe('unavailable'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('does not replace an appinstalled success message with a late prompt result', async () => { + const service = TestBed.inject(PwaInstallService); + offerInstall('dismissed'); + const result = service.install(); + browser.dispatchEvent(new Event('appinstalled')); + await result; + + expect(service.isInstalled()).toBe(true); + expect(service.installMessage()).toContain('OnTrack is installed'); + }); + + it('accepts a fresh install event after an app has been uninstalled in the same session', () => { + const service = TestBed.inject(PwaInstallService); + browser.dispatchEvent(new Event('appinstalled')); + offerInstall(); + + expect(service.isInstalled()).toBe(false); + expect(service.canInstall()).toBe(true); + }); + + it.each(['standalone', 'minimal-ui', 'window-controls-overlay'])( + 'recognises %s app windows and reacts to display mode changes', + (displayMode) => { + const query = browser.matchMedia(`(display-mode: ${displayMode})`); + query.matches = true; + const service = TestBed.inject(PwaInstallService); + expect(service.isInstalled()).toBe(true); + offerInstall(); + expect(service.canInstall()).toBe(false); + + query.matches = false; + query.dispatchEvent(new Event('change')); + expect(service.isInstalled()).toBe(false); + offerInstall(); + expect(service.canInstall()).toBe(true); + + query.matches = true; + query.dispatchEvent(new Event('change')); + expect(service.canInstall()).toBe(false); + }, + ); + + it('recognises iOS standalone mode without requiring matchMedia', () => { + browser.navigator.standalone = true; + browser.matchMedia = undefined; + const service = TestBed.inject(PwaInstallService); + + expect(service.isInstalled()).toBe(true); + offerInstall(); + expect(service.canInstall()).toBe(false); + }); + + it('continues working when matchMedia is unavailable', () => { + browser.matchMedia = undefined; + const service = TestBed.inject(PwaInstallService); + offerInstall(); + + expect(service.isInstalled()).toBe(false); + expect(service.canInstall()).toBe(true); + }); + + it('does not infer an installation when no browser window is present', () => { + TestBed.overrideProvider(DOCUMENT, {useValue: {defaultView: null}}); + const service = TestBed.inject(PwaInstallService); + expect(service.isInstalled()).toBe(false); + expect(service.canInstall()).toBe(false); + }); + + it('ignores events without a callable browser prompt', () => { + const service = TestBed.inject(PwaInstallService); + browser.dispatchEvent(new Event('beforeinstallprompt')); + expect(service.canInstall()).toBe(false); + }); + + it('removes window and media query listeners on destruction', () => { + const service = TestBed.inject(PwaInstallService); + const removeWindowListener = vi.spyOn(browser, 'removeEventListener'); + const query = queries.get('(display-mode: standalone)'); + const removeMediaListener = vi.spyOn(query, 'removeEventListener'); + + service.ngOnDestroy(); + offerInstall(); + browser.dispatchEvent(new Event('appinstalled')); + Object.defineProperty(query, 'matches', {value: true}); + query.dispatchEvent(new Event('change')); + + expect(service.canInstall()).toBe(false); + expect(service.isInstalled()).toBe(false); + expect(removeWindowListener).toHaveBeenCalledWith('beforeinstallprompt', expect.any(Function)); + expect(removeWindowListener).toHaveBeenCalledWith('appinstalled', expect.any(Function)); + expect(removeMediaListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); +}); diff --git a/src/app/common/pwa/pwa-install.service.ts b/src/app/common/pwa/pwa-install.service.ts new file mode 100644 index 0000000000..7347ee4ff7 --- /dev/null +++ b/src/app/common/pwa/pwa-install.service.ts @@ -0,0 +1,143 @@ +import {DOCUMENT, Injectable, NgZone, OnDestroy, computed, inject, signal} from '@angular/core'; + +export interface PwaBeforeInstallPromptEvent extends Event { + readonly userChoice: Promise<{outcome: 'accepted' | 'dismissed'; platform: string}>; + prompt(): Promise; +} + +export type PwaInstallResult = 'accepted' | 'dismissed' | 'unavailable' | 'error'; + +/** Retains browser install capability until an explicit user action consumes it. */ +@Injectable({providedIn: 'root'}) +export class PwaInstallService implements OnDestroy { + private readonly browser = inject(DOCUMENT).defaultView; + private readonly zone = inject(NgZone); + private readonly deferredPrompt = signal(null); + private readonly installedThisSession = signal(false); + private readonly standalone = signal(false); + private readonly installing = signal(false); + private readonly message = signal(''); + private readonly displayModes: MediaQueryList[] = []; + + // A browser tab cannot reliably discover installations from previous visits. + // Do not persist an installed flag: it would become stale after uninstalling. + readonly isInstalled = computed(() => this.standalone() || this.installedThisSession()); + readonly isInstalling = this.installing.asReadonly(); + readonly installMessage = this.message.asReadonly(); + readonly canInstall = computed( + () => !!this.deferredPrompt() && !this.isInstalled() && !this.installing(), + ); + + private readonly beforeInstallPrompt = (event: Event): void => { + const prompt = event as PwaBeforeInstallPromptEvent; + if (typeof prompt.prompt !== 'function') { + return; + } + + event.preventDefault(); + this.zone.run(() => { + if (this.standalone()) { + return; + } + + // A fresh capability can also mean the user has since uninstalled the app. + this.installedThisSession.set(false); + this.deferredPrompt.set(prompt); + this.message.set(''); + }); + }; + + private readonly appInstalled = (): void => { + this.zone.run(() => { + this.installedThisSession.set(true); + this.deferredPrompt.set(null); + this.message.set('OnTrack is installed. Open it from your device’s app launcher.'); + }); + }; + + private readonly displayModeChanged = (): void => { + this.zone.run(() => { + this.standalone.set( + this.displayModes.some((mode) => mode.matches) || + !!(this.browser?.navigator as Navigator & {standalone?: boolean})?.standalone, + ); + if (this.standalone()) { + this.deferredPrompt.set(null); + this.message.set(''); + } + }); + }; + + constructor() { + if (!this.browser) { + return; + } + + if (typeof this.browser.matchMedia === 'function') { + for (const mode of ['standalone', 'minimal-ui', 'window-controls-overlay']) { + const query = this.browser.matchMedia(`(display-mode: ${mode})`); + this.displayModes.push(query); + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', this.displayModeChanged); + } else { + query.addListener?.(this.displayModeChanged); + } + } + } + + this.displayModeChanged(); + this.browser.addEventListener('beforeinstallprompt', this.beforeInstallPrompt); + this.browser.addEventListener('appinstalled', this.appInstalled); + } + + /** Call directly from a click handler so prompt() retains browser user activation. */ + async install(): Promise { + if (this.installing() || this.isInstalled()) { + return 'unavailable'; + } + + const prompt = this.deferredPrompt(); + if (!prompt) { + this.message.set('Use the instructions below to install from your browser’s menu.'); + return 'unavailable'; + } + + // Each event can only be used once, including after dismissal or failure. + this.deferredPrompt.set(null); + this.installing.set(true); + this.message.set(''); + try { + await prompt.prompt(); + const choice = await prompt.userChoice; + if (!this.isInstalled()) { + this.message.set( + choice.outcome === 'accepted' + ? 'Installation requested. Finish any steps shown by your browser.' + : 'Installation cancelled. You can keep using OnTrack here or install from your browser’s menu later.', + ); + } + return choice.outcome; + } catch { + if (!this.isInstalled()) { + this.message.set( + 'Your browser could not complete installation. Try its install menu, or reload this page to try again.', + ); + } + return 'error'; + } finally { + this.installing.set(false); + } + } + + ngOnDestroy(): void { + this.browser?.removeEventListener('beforeinstallprompt', this.beforeInstallPrompt); + this.browser?.removeEventListener('appinstalled', this.appInstalled); + for (const query of this.displayModes) { + if (typeof query.removeEventListener === 'function') { + query.removeEventListener('change', this.displayModeChanged); + } else { + query.removeListener?.(this.displayModeChanged); + } + } + } +} diff --git a/src/app/common/services/emoji.service.ts b/src/app/common/services/emoji.service.ts index 3e129c3d42..4cb4705b7f 100644 --- a/src/app/common/services/emoji.service.ts +++ b/src/app/common/services/emoji.service.ts @@ -8,9 +8,9 @@ import {Injectable} from '@angular/core'; export class EmojiService { // DO NOT CHANGE THIS LINE EVEN WITH AN ESLINT RULE // prettier-ignore - // eslint-disable-next-line max-len, no-misleading-character-class + // eslint-disable-next-line no-misleading-character-class emojiMatch: RegExp = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c\ude32-\ude3a]|[\ud83c\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/; - // eslint-disable-next-line no-useless-escape + colonPairMatch: RegExp = /(^|[^\w/])(:[\w+-]+:)(?!\/)/; constructor(private emojiSearch: EmojiSearch) {} @@ -56,7 +56,7 @@ export class EmojiService { }, ); } - // eslint-disable-next-line no-useless-escape + const result = s.replaceAll('<><>', ':'); return result; } diff --git a/src/app/common/services/http-authentication.interceptor.spec.ts b/src/app/common/services/http-authentication.interceptor.spec.ts new file mode 100644 index 0000000000..0364746a9f --- /dev/null +++ b/src/app/common/services/http-authentication.interceptor.spec.ts @@ -0,0 +1,73 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {HttpHandler, HttpRequest, HttpResponse} from '@angular/common/http'; +import {of} from 'rxjs'; +import {UserService} from 'src/app/api/services/user.service'; +import API_URL from 'src/app/config/constants/apiUrl'; +import LTI_API_URL from 'src/app/config/constants/ltiApiUrl'; +import {HttpAuthenticationInterceptor} from './http-authentication.interceptor'; + +describe('HttpAuthenticationInterceptor', () => { + const currentUser = {authenticationToken: '', username: '', ltik: ''}; + let interceptor: HttpAuthenticationInterceptor; + const handle = vi.fn(() => of(new HttpResponse())); + + beforeEach(() => { + Object.assign(currentUser, { + authenticationToken: 'session-token', + username: 'student', + ltik: '', + }); + handle.mockClear(); + interceptor = new HttpAuthenticationInterceptor({currentUser} as UserService); + }); + + function intercept(url: string): HttpRequest { + const request: HttpRequest = new HttpRequest('GET', url); + interceptor.intercept(request, {handle} as HttpHandler).subscribe(); + return handle.mock.calls[0][0] as HttpRequest; + } + + it.each([`${API_URL}/units`, `${LTI_API_URL}/launch`])( + 'adds session credentials to a configured API request %s', + (url) => { + const request = intercept(url); + expect(request.headers.get('Auth-Token')).toBe('session-token'); + expect(request.headers.get('Username')).toBe('student'); + expect(request.headers.has('Authorization')).toBe(false); + }, + ); + + it('adds an LTI bearer token when the launch supplies one', () => { + currentUser.ltik = 'launch-token'; + expect(intercept(`${LTI_API_URL}/units`).headers.get('Authorization')).toBe( + 'Bearer launch-token', + ); + }); + + it('does not invent session headers for an anonymous request', () => { + currentUser.authenticationToken = ''; + const request = intercept(`${API_URL}/auth`); + expect(request.headers.has('Auth-Token')).toBe(false); + expect(request.headers.has('Username')).toBe(false); + }); + + it.each(['https://other.example/api/units', '/assets/config.json'])( + 'does not attach credentials to unrelated request %s', + (url) => { + currentUser.ltik = 'launch-token'; + expect(intercept(url).headers.keys()).toEqual([]); + }, + ); + + it('keeps the original request immutable and preserves caller headers', () => { + const original = new HttpRequest('GET', `${API_URL}/units`).clone({ + setHeaders: {'X-Request-ID': 'request-1'}, + }); + interceptor.intercept(original, {handle} as HttpHandler).subscribe(); + const outgoing = handle.mock.calls[0][0] as HttpRequest; + + expect(original.headers.has('Auth-Token')).toBe(false); + expect(outgoing.headers.get('X-Request-ID')).toBe('request-1'); + expect(handle).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/common/services/http-error-context.ts b/src/app/common/services/http-error-context.ts new file mode 100644 index 0000000000..0aeb07480f --- /dev/null +++ b/src/app/common/services/http-error-context.ts @@ -0,0 +1,6 @@ +import {HttpContextToken} from '@angular/common/http'; + +/** Opt in when callers need HTTP status codes to recover from conflicts or missing resources. */ +export const PRESERVE_HTTP_ERROR_RESPONSE: HttpContextToken = new HttpContextToken( + () => false, +); diff --git a/src/app/common/services/http-error.interceptor.spec.ts b/src/app/common/services/http-error.interceptor.spec.ts new file mode 100644 index 0000000000..8414cf0a64 --- /dev/null +++ b/src/app/common/services/http-error.interceptor.spec.ts @@ -0,0 +1,89 @@ +import {describe, expect, it} from 'vitest'; +import {HttpErrorResponse, HttpHandler, HttpRequest} from '@angular/common/http'; +import {Observable, throwError} from 'rxjs'; +import {HttpErrorInterceptor} from './http-error.interceptor'; + +// authenticationTokenExpiry is null on purpose. isAccessTokenExpired parses it, +// gets NaN and returns false, so the request reaches the handler below instead +// of being turned into a synthetic 419. +const userService = { + currentUser: { + authenticationToken: 'tok', + authenticationTokenExpiry: null as string | null, + username: 'u', + }, + isAnonymousUser: () => false, +}; + +const authenticationService = { + attemptLoginUsingRefreshToken: () => {}, + timeoutAuthentication: () => {}, +}; + +function messageFor(error: HttpErrorResponse, path = '/api/units'): Promise { + const interceptor = new HttpErrorInterceptor( + authenticationService as never, + userService as never, + ); + + const handler: HttpHandler = { + handle: (): Observable => throwError(() => error), + }; + + return new Promise((resolve) => { + interceptor + .intercept(new HttpRequest('GET', path), handler) + .subscribe({error: (message: unknown) => resolve(message)}); + }); +} + +describe('HttpErrorInterceptor', () => { + it.each([0, 403, 404, 419, 503])( + 'preserves the typed access-token failure for status %s', + async (status) => { + const failure = new HttpErrorResponse({status, statusText: 'Session request failed'}); + const error = await messageFor(failure, '/api/auth/access-token'); + expect(error).toBe(failure); + }, + ); + + it('falls back to the status text when the response body is empty', async () => { + const message = await messageFor( + new HttpErrorResponse({status: 502, statusText: 'Bad Gateway', error: null}), + ); + + expect(message).toBe('Bad Gateway'); + }); + + it('uses the message from an ordinary api error', async () => { + const message = await messageFor( + new HttpErrorResponse({ + status: 404, + statusText: 'Not Found', + error: {error: 'Unit not found'}, + }), + ); + + expect(message).toBe('Unit not found'); + }); + + it('passes a plain string body through unchanged', async () => { + const message = await messageFor( + new HttpErrorResponse({status: 500, statusText: 'Server Error', error: 'went wrong'}), + ); + + expect(message).toBe('went wrong'); + }); + + it('falls back to the status text when the body could not be parsed', async () => { + const message = await messageFor( + new HttpErrorResponse({ + status: 500, + statusText: 'Internal Server Error', + error: {error: new SyntaxError('Unexpected token <'), text: ''}, + }), + ); + + expect(message).toBe('Internal Server Error'); + }); +}); diff --git a/src/app/common/services/http-error.interceptor.ts b/src/app/common/services/http-error.interceptor.ts index fe205397cd..7979d3df64 100644 --- a/src/app/common/services/http-error.interceptor.ts +++ b/src/app/common/services/http-error.interceptor.ts @@ -11,6 +11,7 @@ import {Injectable} from '@angular/core'; import {BehaviorSubject, Observable, Subject, throwError} from 'rxjs'; import {catchError, filter, finalize, switchMap, take} from 'rxjs/operators'; import {AuthenticationService, UserService} from 'src/app/api/models/doubtfire-model'; +import {PRESERVE_HTTP_ERROR_RESPONSE} from './http-error-context'; @Injectable() export class HttpErrorInterceptor implements HttpInterceptor { @@ -44,11 +45,12 @@ export class HttpErrorInterceptor implements HttpInterceptor { return request$.pipe( catchError((error: HttpErrorResponse) => { + // Session restoration needs the status to distinguish expiry from an + // offline or unavailable server. Other callers still receive messages. + if (this.isAccessTokenRequest(request)) { + return throwError(() => error); + } if (this.isAuthError(error)) { - if (this.isAccessTokenRequest(request)) { - return throwError(() => this.extractErrorMessage(error)); - } - if (!this.refreshTokenInProgress) { console.log('Refreshing access token'); this.refreshTokenInProgress = true; @@ -66,7 +68,7 @@ export class HttpErrorInterceptor implements HttpInterceptor { if (!(err instanceof HttpErrorResponse)) { return throwError(() => err); } - return throwError(() => this.extractErrorMessage(err)); + return throwError(() => this.errorForRequest(request, err)); }), finalize(() => (this.refreshTokenInProgress = false)), ); @@ -76,13 +78,13 @@ export class HttpErrorInterceptor implements HttpInterceptor { take(1), switchMap(() => next.handle(this.injectToken(request))), catchError((err: HttpErrorResponse) => { - return throwError(() => this.extractErrorMessage(err)); + return throwError(() => this.errorForRequest(request, err)); }), ); } } - return throwError(() => this.extractErrorMessage(error)); + return throwError(() => this.errorForRequest(request, error)); }), ); } @@ -107,6 +109,12 @@ export class HttpErrorInterceptor implements HttpInterceptor { return request.url.endsWith('/auth/access-token'); } + private errorForRequest(request: HttpRequest, error: HttpErrorResponse) { + // Keep reporting and legacy message handling while retaining structured errors for opt-in callers. + const message = this.extractErrorMessage(error); + return request.context.get(PRESERVE_HTTP_ERROR_RESPONSE) ? error : message; + } + private extractErrorMessage(error: HttpErrorResponse) { let errorMessage: string; let logMessage: string = ''; @@ -116,18 +124,27 @@ export class HttpErrorInterceptor implements HttpInterceptor { } else if (error.error instanceof ProgressEvent) { errorMessage = error.statusText; } else { - // server-side error - if (error.error.error) { - errorMessage = error.error.error; - } else if (error.error instanceof Blob) { + // Server-side error. error.error is null whenever the response body was + // empty, which is every 502 from a proxy and every 500 that returns + // nothing, so the property read has to come after the checks rather than + // before them. The blob check moves up for the same reason: it only worked + // where it was because a Blob happens to have no error property. + const body: unknown = error.error; + if (body instanceof Blob) { errorMessage = error.statusText; + } else if (typeof body === 'string') { + errorMessage = body; } else { - errorMessage = error.error; + // An unparseable body arrives as {error: SyntaxError, text: ''}, + // so the nested value has to be a string before it is used as one. + const nested = (body as {error?: unknown})?.error; + errorMessage = + typeof nested === 'string' ? nested : error.statusText || 'Something went wrong'; } logMessage = `Error Code: ${error.status}`; } - this.throwError(`${logMessage}: ${errorMessage}`, error.status); + this.reportError(`${logMessage}: ${errorMessage}`, error.status); console.error(`${logMessage}: ${errorMessage}`); return errorMessage; @@ -142,20 +159,14 @@ export class HttpErrorInterceptor implements HttpInterceptor { }); } - throwError(message: string, statusCode: number) { - Sentry.diagnoseSdkConnectivity().then(() => { - Sentry.startSpan( - { - name: `Error ${statusCode}`, - op: 'http.client_error', - attributes: { - 'http.response.status_code': statusCode, - }, - }, - () => { - throw new HttpRequestError(message, statusCode); - }, - ); + // Reports the failure rather than throwing at Sentry. The old version threw + // inside a startSpan callback, which rethrows, inside a then() with no catch, + // so every failed request in the app left an unhandled promise rejection that + // nothing in the observable chain could see. Named reportError because rxjs + // throwError is imported into this same file. + private reportError(message: string, statusCode: number) { + Sentry.captureException(new HttpRequestError(message, statusCode), { + tags: {'http.response.status_code': statusCode}, }); } } diff --git a/src/app/common/skeleton-loader/skeleton-loader.component.html b/src/app/common/skeleton-loader/skeleton-loader.component.html new file mode 100644 index 0000000000..1f1c654691 --- /dev/null +++ b/src/app/common/skeleton-loader/skeleton-loader.component.html @@ -0,0 +1,55 @@ + diff --git a/src/app/common/skeleton-loader/skeleton-loader.component.scss b/src/app/common/skeleton-loader/skeleton-loader.component.scss new file mode 100644 index 0000000000..5d4e87f30f --- /dev/null +++ b/src/app/common/skeleton-loader/skeleton-loader.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/src/app/common/skeleton-loader/skeleton-loader.component.spec.ts b/src/app/common/skeleton-loader/skeleton-loader.component.spec.ts new file mode 100644 index 0000000000..4fc3a085ab --- /dev/null +++ b/src/app/common/skeleton-loader/skeleton-loader.component.spec.ts @@ -0,0 +1,105 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {SkeletonLoaderComponent} from './skeleton-loader.component'; + +describe('SkeletonLoaderComponent', () => { + let component: SkeletonLoaderComponent; + let fixture: ComponentFixture; + + const placeholders = (): HTMLElement[] => + Array.from(fixture.nativeElement.querySelectorAll('ngx-skeleton-loader')); + + // The element ngx-skeleton-loader itself renders the theme onto, as an + // inline style. Reaching one level past the host is what makes the shape + // test below discriminating rather than a check of our own template. + const renderedHeight = (index: number): string => + (placeholders()[index].querySelector('.skeleton-loader') as HTMLElement).style.height; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SkeletonLoaderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SkeletonLoaderComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('defaults to three row placeholders', () => { + fixture.detectChanges(); + + expect(placeholders()).toHaveLength(3); + }); + + it('renders the configured number of placeholders', () => { + component.count = 5; + fixture.detectChanges(); + + expect(placeholders()).toHaveLength(5); + }); + + it('renders nothing for a count of zero', () => { + component.count = 0; + fixture.detectChanges(); + + expect(placeholders()).toHaveLength(0); + }); + + // Discriminating: a shape-blind implementation could satisfy the count + // tests above by rendering the same markup for every placeholder + // regardless of `shape`. This fails unless the two shapes actually render + // a visibly different placeholder height. + it('renders a taller placeholder for the card shape than for the row shape', () => { + component.shape = 'row'; + component.count = 1; + fixture.detectChanges(); + const rowHeight = renderedHeight(0); + + component.shape = 'card'; + fixture.detectChanges(); + const cardHeight = renderedHeight(0); + + expect(rowHeight).toBe('48px'); + expect(cardHeight).toBe('120px'); + expect(cardHeight).not.toEqual(rowHeight); + }); + + it('hides the placeholders from assistive technology, since they carry no content', () => { + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[aria-hidden="true"]')).not.toBeNull(); + }); + it('renders table rows with the requested columns and row height', () => { + component.shape = 'table-row'; + component.count = 2; + component.columns = 9; + component.rowHeight = '72px'; + fixture.detectChanges(); + expect(placeholders()).toHaveLength(18); + expect(placeholders()[0].parentElement.style.height).toBe('72px'); + }); + + it('keeps only avatars in a compact inbox, and restores text in the wide view', () => { + component.shape = 'list-row'; + component.count = 2; + component.compact = true; + fixture.detectChanges(); + expect(placeholders()).toHaveLength(2); + component.compact = false; + fixture.detectChanges(); + expect(placeholders()).toHaveLength(8); + }); + + it('accepts caller dimensions and safely ignores an invalid repeat count', () => { + component.count = 1; + component.theme = {height: '300px'}; + fixture.detectChanges(); + expect(renderedHeight(0)).toBe('300px'); + component.count = Number.NaN; + fixture.detectChanges(); + expect(placeholders()).toHaveLength(0); + }); +}); diff --git a/src/app/common/skeleton-loader/skeleton-loader.component.ts b/src/app/common/skeleton-loader/skeleton-loader.component.ts new file mode 100644 index 0000000000..bd11e3063f --- /dev/null +++ b/src/app/common/skeleton-loader/skeleton-loader.component.ts @@ -0,0 +1,47 @@ +import {NgxSkeletonLoaderModule} from 'ngx-skeleton-loader'; +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; + +export type SkeletonShape = 'row' | 'card' | 'line' | 'table-row' | 'list-row'; + +/** Shared presentation for loading collections. Callers own their loading and error states. */ +@Component({ + selector: 'f-skeleton-loader', + standalone: true, + imports: [NgxSkeletonLoaderModule], + templateUrl: './skeleton-loader.component.html', + styleUrl: './skeleton-loader.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, +}) +export class SkeletonLoaderComponent { + @Input() shape: SkeletonShape = 'row'; + @Input() count = 3; + @Input() columns = 5; + @Input() rowHeight = '60px'; + @Input() compact = false; + @Input() theme: Record = {}; + + get placeholders(): number[] { + return this.indices(this.count); + } + + get tableColumns(): number[] { + return this.indices(this.columns); + } + + get placeholderTheme(): Record { + return { + height: this.shape === 'card' ? '120px' : this.shape === 'line' ? '16px' : '48px', + width: '100%', + 'border-radius': 'var(--ot-radius-sm)', + margin: '0', + ...this.theme, + }; + } + + private indices(value: number): number[] { + return Array.from( + {length: Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0}, + (_unused, index) => index, + ); + } +} diff --git a/src/app/common/user-icon/user-icon.component.secure-context.spec.ts b/src/app/common/user-icon/user-icon.component.secure-context.spec.ts new file mode 100644 index 0000000000..65c83f3c0f --- /dev/null +++ b/src/app/common/user-icon/user-icon.component.secure-context.spec.ts @@ -0,0 +1,18 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {UserIconComponent} from './user-icon.component'; + +describe('UserIconComponent outside a secure context', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('still resolves the background without SubtleCrypto', async () => { + vi.stubGlobal('crypto', {}); + const component = new UserIconComponent({currentUser: null} as never); + component.user = {email: 'demo@example.com', name: 'Demo Student'} as never; + + const url = await ( + component as unknown as {backgroundUrl: () => Promise} + ).backgroundUrl(); + + expect(url).toBeNull(); + }); +}); diff --git a/src/app/common/user-icon/user-icon.component.ts b/src/app/common/user-icon/user-icon.component.ts index 212eee6e02..fbe3ac907b 100644 --- a/src/app/common/user-icon/user-icon.component.ts +++ b/src/app/common/user-icon/user-icon.component.ts @@ -72,13 +72,26 @@ export class UserIconComponent implements AfterViewInit, OnChanges { constructor(private userService: UserService) {} - private async backgroundUrl(): Promise { - const hash = await this.sha256(this.email?.trim().toLowerCase() ?? ''); - return `https://www.gravatar.com/avatar/${hash}.png?default=blank&size=${this.size * 4}`; + /** + * The Gravatar photo behind the initials, or null when it cannot be worked out. + * crypto.subtle only exists on https or localhost, so opening OnTrack by a LAN + * address threw here and the avatar never drew at all. Without a hash the + * initials still show. + */ + private async backgroundUrl(): Promise { + try { + const hash = await this.sha256(this.email?.trim().toLowerCase() ?? ''); + return `https://www.gravatar.com/avatar/${hash}.png?default=blank&size=${this.size * 4}`; + } catch { + return null; + } } private async sha256(value: string): Promise { const bytes = new TextEncoder().encode(value); + if (!globalThis.crypto?.subtle) { + throw new Error('SubtleCrypto is unavailable outside a secure context'); + } const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( '', @@ -239,13 +252,15 @@ export class UserIconComponent implements AfterViewInit, OnChanges { .attr('fill', 'white') .text((d) => d.text); - svg - .append('image') - .attr('xlink:href', backgroundUrl) - .attr('width', this.size) - .attr('height', this.size) - .attr('x', 0) - .attr('y', 0) - .attr('clip-path', `url(#image-clip-${id})`); + if (backgroundUrl) { + svg + .append('image') + .attr('xlink:href', backgroundUrl) + .attr('width', this.size) + .attr('height', this.size) + .attr('x', 0) + .attr('y', 0) + .attr('clip-path', `url(#image-clip-${id})`); + } } } diff --git a/src/app/config/constants/doubtfire-constants.spec.ts b/src/app/config/constants/doubtfire-constants.spec.ts new file mode 100644 index 0000000000..c7ad32fccc --- /dev/null +++ b/src/app/config/constants/doubtfire-constants.spec.ts @@ -0,0 +1,281 @@ +import {provideHttpClient} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import API_URL from 'src/app/config/constants/apiUrl'; +import { + AuthenticatedSettingsResponseFormat, + DoubtfireConstants, +} from 'src/app/config/constants/doubtfire-constants'; + +describe('DoubtfireConstants', () => { + let httpMock: HttpTestingController; + + const publicSettings = { + externalName: 'OnTrack', + hasLogo: true, + logoUrl: '/logo.svg', + logoLinkUrl: '/', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + function flushSignoutUrl(): void { + const request = httpMock.expectOne(`${API_URL}/auth/signout_url`); + request.flush({auth_signout_url: '/sign_out'}); + } + + /** + * Bring the service up through the normal bootstrap path and answer the two + * requests the constructor makes, so a test can start from a signed out + * browser without repeating the plumbing. + */ + function bootstrap(): DoubtfireConstants { + const service = TestBed.inject(DoubtfireConstants); + + httpMock.expectOne(`${API_URL}/settings/public`).flush(publicSettings); + flushSignoutUrl(); + + return service; + } + + it('loads public branding without authentication headers', () => { + const service = TestBed.inject(DoubtfireConstants); + + const request = httpMock.expectOne(`${API_URL}/settings/public`); + + expect(request.request.headers.has('Auth-Token')).toBe(false); + expect(request.request.headers.has('Username')).toBe(false); + + request.flush(publicSettings); + flushSignoutUrl(); + + expect(service.ExternalName.value).toBe('OnTrack'); + expect(service.LogoSettings.value).toEqual({ + hasLogo: true, + logoUrl: '/logo.svg', + logoLinkUrl: '/', + }); + }); + + it('falls back to the legacy settings route during a staged deployment', () => { + const service = TestBed.inject(DoubtfireConstants); + + const publicRequest = httpMock.expectOne(`${API_URL}/settings/public`); + publicRequest.flush( + {error: 'Not found'}, + { + status: 404, + statusText: 'Not Found', + }, + ); + + const legacyRequest = httpMock.expectOne(`${API_URL}/settings`); + legacyRequest.flush({ + ...publicSettings, + overseerEnabled: true, + tiiEnabled: true, + d2lEnabled: true, + }); + + flushSignoutUrl(); + + expect(service.ExternalName.value).toBe('OnTrack'); + }); + + it('applies feature flags received after authentication', () => { + const service = bootstrap(); + + service.applyAuthenticatedSettings({ + overseerEnabled: true, + tiiEnabled: true, + d2lEnabled: false, + pushEnabled: true, + vapidPublicKey: 'BTestVapidPublicKey', + }); + + expect(service.IsOverseerEnabled.value).toBe(true); + expect(service.IsTiiEnabled.value).toBe(true); + expect(service.IsD2LEnabled.value).toBe(false); + }); + + /** + * Push configuration moved behind authentication when /settings did. The + * pre-auth client here carries no token on purpose, so nothing it fetches may + * be trusted to populate these two. A signed out browser has to read as "push + * is not available", because PushNotificationService gates the opt-in on + * IsPushEnabled and VapidPublicKey both being truthy. + */ + it('reports push as unavailable before anyone signs in', () => { + const service = bootstrap(); + + expect(service.IsPushEnabled.value).toBe(false); + expect(service.VapidPublicKey.value).toBe(''); + }); + + /** + * The regression this file exists to catch. An older api still answers the + * legacy /settings with push fields in the body, and the bootstrap request is + * anonymous. Reading them there would hand the VAPID key to an unauthenticated + * caller and would also mean the flags never refresh at sign in, so bootstrap + * has to ignore them whatever the body says. + */ + it('ignores push fields returned by the legacy settings route', () => { + const service = TestBed.inject(DoubtfireConstants); + + httpMock.expectOne(`${API_URL}/settings/public`).flush( + {error: 'Not found'}, + { + status: 404, + statusText: 'Not Found', + }, + ); + + httpMock.expectOne(`${API_URL}/settings`).flush({ + ...publicSettings, + overseerEnabled: true, + tiiEnabled: true, + d2lEnabled: true, + pushEnabled: true, + vapidPublicKey: 'BLegacyKeyFromAnonymousRequest', + }); + + flushSignoutUrl(); + + expect(service.IsPushEnabled.value).toBe(false); + expect(service.VapidPublicKey.value).toBe(''); + }); + + it('publishes the push configuration once the authenticated settings arrive', () => { + const service = bootstrap(); + + service.applyAuthenticatedSettings({ + overseerEnabled: false, + tiiEnabled: false, + d2lEnabled: false, + pushEnabled: true, + vapidPublicKey: 'BTestVapidPublicKey', + }); + + expect(service.IsPushEnabled.value).toBe(true); + expect(service.VapidPublicKey.value).toBe('BTestVapidPublicKey'); + }); + + /** + * The api sends null rather than an empty string when DOUBTFIRE_VAPID_PUBLIC_KEY + * is unset, so pushEnabled is false and the key is absent. VapidPublicKey is a + * BehaviorSubject and PushNotificationService reads .value directly, so + * a null landing in it would read as truthy nowhere but would still be the wrong + * type. It has to normalise to ''. + */ + it('normalises a null vapid public key to an empty string', () => { + const service = bootstrap(); + + service.applyAuthenticatedSettings({ + overseerEnabled: false, + tiiEnabled: false, + d2lEnabled: false, + pushEnabled: false, + vapidPublicKey: null, + }); + + expect(service.IsPushEnabled.value).toBe(false); + expect(service.VapidPublicKey.value).toBe(''); + expect(service.VapidPublicKey.value).not.toBeNull(); + }); + + /** + * The other half of a staged deployment: a web build carrying push talking to + * an api that predates it, so /settings answers with the three original flags + * and neither push field. The assertion is what makes the ?? in + * applyAuthenticatedSettings load bearing rather than decoration. + * + * AuthenticatedSettingsResponseFormat keeps the two push fields optional for + * this staged-deployment case. + */ + it('leaves push disabled when the api omits the push fields entirely', () => { + const service = bootstrap(); + + const settingsFromAnOlderApi = { + overseerEnabled: true, + tiiEnabled: false, + d2lEnabled: true, + } as AuthenticatedSettingsResponseFormat; + + service.applyAuthenticatedSettings(settingsFromAnOlderApi); + + expect(service.IsOverseerEnabled.value).toBe(true); + expect(service.IsD2LEnabled.value).toBe(true); + expect(service.IsPushEnabled.value).toBe(false); + expect(service.VapidPublicKey.value).toBe(''); + }); + + /** + * Signing out and back in as somebody on a server that has since had its keys + * pulled must not leave the previous key sitting in the subject. Every apply + * overwrites both, it never merges. + */ + it('clears a previously published key when push is later turned off', () => { + const service = bootstrap(); + + service.applyAuthenticatedSettings({ + overseerEnabled: false, + tiiEnabled: false, + d2lEnabled: false, + pushEnabled: true, + vapidPublicKey: 'BFirstKey', + }); + + service.applyAuthenticatedSettings({ + overseerEnabled: false, + tiiEnabled: false, + d2lEnabled: false, + pushEnabled: false, + vapidPublicKey: null, + }); + + expect(service.IsPushEnabled.value).toBe(false); + expect(service.VapidPublicKey.value).toBe(''); + }); + + it('resets every authenticated setting to its signed-out default', () => { + const service = bootstrap(); + + service.applyAuthenticatedSettings({ + overseerEnabled: true, + tiiEnabled: true, + d2lEnabled: true, + pushEnabled: true, + vapidPublicKey: 'BPreviousSessionKey', + }); + + service.resetAuthenticatedSettings(); + + expect(service.IsOverseerEnabled.value).toBe(false); + expect(service.IsTiiEnabled.value).toBe(false); + expect(service.IsD2LEnabled.value).toBe(false); + expect(service.IsPushEnabled.value).toBe(false); + expect(service.VapidPublicKey.value).toBe(''); + }); + it('keeps tutorial disabled for older APIs and accepts only an explicit authenticated true', () => { + const service = bootstrap(); + const base = {overseerEnabled: false, tiiEnabled: false, d2lEnabled: false}; + expect(service.IsTutorialEnabled.value).toBe(false); + service.applyAuthenticatedSettings({...base, tutorialEnabled: true}); + expect(service.IsTutorialEnabled.value).toBe(true); + service.applyAuthenticatedSettings(base); + expect(service.IsTutorialEnabled.value).toBe(false); + service.applyAuthenticatedSettings({...base, tutorialEnabled: true}); + service.resetAuthenticatedSettings(); + expect(service.IsTutorialEnabled.value).toBe(false); + }); +}); diff --git a/src/app/config/constants/doubtfire-constants.ts b/src/app/config/constants/doubtfire-constants.ts index 16207391e7..4c43a3a8cb 100644 --- a/src/app/config/constants/doubtfire-constants.ts +++ b/src/app/config/constants/doubtfire-constants.ts @@ -1,17 +1,25 @@ -import {HttpBackend, HttpClient} from '@angular/common/http'; +import {HttpBackend, HttpClient, HttpErrorResponse} from '@angular/common/http'; import {Injectable} from '@angular/core'; -import {BehaviorSubject} from 'rxjs'; +import {BehaviorSubject, catchError, throwError} from 'rxjs'; import API_URL from 'src/app/config/constants/apiUrl'; import HOST_URL from 'src/app/config/constants/hostUrl'; -interface SettingsResponseFormat { +interface PublicSettingsResponseFormat { externalName: string; hasLogo: boolean; logoUrl: string; logoLinkUrl: string; +} + +export interface AuthenticatedSettingsResponseFormat { overseerEnabled: boolean; tiiEnabled: boolean; d2lEnabled: boolean; + // Optional so an existing caller that predates push still type-checks, and + // because applyAuthenticatedSettings already defaults both below. + tutorialEnabled?: boolean; + pushEnabled?: boolean; + vapidPublicKey?: string | null; } export interface LogoSettings { @@ -40,7 +48,7 @@ export class DoubtfireConstants { // Where should we redirect users on signout? public SignoutURL: string; - // initialise exernal name to loading. + // Initialise external name to loading. public ExternalName: BehaviorSubject = new BehaviorSubject('Loading...'); /** @@ -54,7 +62,7 @@ export class DoubtfireConstants { public IsD2LEnabled: BehaviorSubject = new BehaviorSubject(false); /** - * Details on the logo + * Details on the logo. */ public LogoSettings: BehaviorSubject = new BehaviorSubject({ hasLogo: false, @@ -67,37 +75,99 @@ export class DoubtfireConstants { */ public IsTiiEnabled: BehaviorSubject = new BehaviorSubject(false); - private readonly settingsUrl: string = `${this.API_URL}/settings`; + /** Institution-level tutorial switch, populated only after authentication. */ + public IsTutorialEnabled: BehaviorSubject = new BehaviorSubject(false); + + /** + * Whether the api has VAPID keys configured. False means push cannot work at + * all, so the opt-in is not offered. + * + * Populated after sign-in, not at bootstrap. /settings is authenticated, and + * the pre-auth client here deliberately carries no token. + */ + public IsPushEnabled: BehaviorSubject = new BehaviorSubject(false); + + /** + * The VAPID public key the browser needs to subscribe to push. Not a secret — + * it is sent to the push service in the clear. Empty until settings load. + */ + public VapidPublicKey: BehaviorSubject = new BehaviorSubject(''); + + private readonly publicSettingsUrl: string = `${this.API_URL}/settings/public`; + private readonly legacySettingsUrl: string = `${this.API_URL}/settings`; constructor(handler: HttpBackend) { - // Don't use interceptors for Doubtfire Constants + // Do not use interceptors for pre-authentication Doubtfire constants. this.http = new HttpClient(handler); - this.loadSettings(); + this.loadPublicSettings(); this.loadSignoutUrl(); } - private loadSignoutUrl() { + private loadSignoutUrl(): void { const url: string = `${this.API_URL}/auth/signout_url`; - this.http.get(url).subscribe( - (result) => (this.SignoutURL = result.auth_signout_url), - (error) => console.error(error), - ); + this.http.get(url).subscribe({ + next: (result) => (this.SignoutURL = result.auth_signout_url), + error: (error) => console.error(error), + }); } - // publish update to ExternalName when get request finishes. - private loadSettings() { - this.http.get(this.settingsUrl).subscribe((result) => { - this.ExternalName.next(result.externalName); - this.IsOverseerEnabled.next(result.overseerEnabled); - this.IsTiiEnabled.next(result.tiiEnabled); - this.IsD2LEnabled.next(result.d2lEnabled); - - this.LogoSettings.next({ - hasLogo: result.hasLogo, - logoUrl: result.logoUrl, - logoLinkUrl: result.logoLinkUrl, + /** + * Load branding needed before the user signs in. + * + * The fallback allows this web change to be deployed before the API change. + * The current API returns the same branding fields from /settings. + */ + private loadPublicSettings(): void { + this.http + .get(this.publicSettingsUrl) + .pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 404) { + return this.http.get(this.legacySettingsUrl); + } + + return throwError(() => error); + }), + ) + .subscribe({ + next: (result) => { + this.ExternalName.next(result.externalName); + this.LogoSettings.next({ + hasLogo: result.hasLogo, + logoUrl: result.logoUrl, + logoLinkUrl: result.logoLinkUrl, + }); + }, + error: (error) => console.error('Unable to load public settings', error), }); - }); + } + + /** + * Apply settings returned after successful authentication. + */ + public applyAuthenticatedSettings(result: AuthenticatedSettingsResponseFormat): void { + this.IsOverseerEnabled.next(result.overseerEnabled); + this.IsTiiEnabled.next(result.tiiEnabled); + this.IsD2LEnabled.next(result.d2lEnabled); + this.IsTutorialEnabled.next(result.tutorialEnabled === true); + this.IsPushEnabled.next(result.pushEnabled ?? false); + this.VapidPublicKey.next(result.vapidPublicKey ?? ''); + } + + /** + * Remove configuration that may only be used by an authenticated session. + * + * DoubtfireConstants is a root singleton, so these subjects otherwise retain + * the previous session's values after sign out. Keep the defaults fail closed + * while signed out and when the authenticated settings request fails. + */ + public resetAuthenticatedSettings(): void { + this.IsOverseerEnabled.next(false); + this.IsTiiEnabled.next(false); + this.IsD2LEnabled.next(false); + this.IsTutorialEnabled.next(false); + this.IsPushEnabled.next(false); + this.VapidPublicKey.next(''); } } diff --git a/src/app/courseflow/coursemap/course-flow-checks.spec.ts b/src/app/courseflow/coursemap/course-flow-checks.spec.ts new file mode 100644 index 0000000000..ce35b88c9f --- /dev/null +++ b/src/app/courseflow/coursemap/course-flow-checks.spec.ts @@ -0,0 +1,86 @@ +import {describe, expect, it} from 'vitest'; +import {CourseFlowCourse, CourseFlowSlot} from 'src/app/api/models/course-flow'; +import {checkCourseFlowPlan} from './course-flow-checks'; + +const course: CourseFlowCourse = { + id: 7, + code: 'TEST', + name: 'Test catalog', + version: 'test-only', + elective_count: 1, + units: [ + { + code: 'A', + name: 'Required A', + required: true, + prerequisites: [], + offered_trimesters: [1, 2, 3], + }, + { + code: 'B', + name: 'Required B', + required: true, + prerequisites: ['A'], + offered_trimesters: [1, 2], + }, + { + code: 'C', + name: 'Elective C', + required: false, + prerequisites: ['B'], + offered_trimesters: [1, 2, 3], + }, + { + code: 'D', + name: 'Elective D', + required: false, + prerequisites: [], + offered_trimesters: [1, 2, 3], + }, + ], +}; +const slot = ( + unit_code: string, + year: number, + trimester: number, + position = 1, +): CourseFlowSlot => ({unit_code, year, trimester, position}); + +describe('Course Flow catalog planning checks', () => { + it('reports every missing required unit and the configured elective count', () => { + expect(checkCourseFlowPlan(course, []).map((issue) => [issue.code, issue.unit_code])).toEqual([ + ['missing_required', 'A'], + ['missing_required', 'B'], + ['elective_count', undefined], + ]); + }); + + it('accepts a valid sequence across year boundaries independently of array order', () => { + const plan = [slot('C', 2027, 2), slot('A', 2026, 3), slot('B', 2027, 1)]; + expect(checkCourseFlowPlan(course, plan)).toEqual([]); + }); + + it('rejects missing, concurrent and later prerequisites and unavailable trimesters', () => { + const plan = [slot('A', 2026, 3, 2), slot('B', 2026, 3), slot('C', 2026, 2)]; + const issues = checkCourseFlowPlan(course, plan); + expect( + issues.filter((issue) => issue.code === 'prerequisite').map((issue) => issue.unit_code), + ).toEqual(['B', 'C']); + expect( + issues.some((issue) => issue.code === 'unavailable_trimester' && issue.unit_code === 'B'), + ).toBe(true); + expect( + checkCourseFlowPlan(course, [slot('C', 2026, 2)]).some( + (issue) => issue.code === 'prerequisite', + ), + ).toBe(true); + }); + + it('reports surplus electives and follows a different catalog allowance', () => { + const plan = [slot('A', 2026, 1), slot('B', 2026, 2), slot('C', 2026, 3), slot('D', 2027, 1)]; + expect(checkCourseFlowPlan(course, plan).map((issue) => issue.code)).toEqual([ + 'elective_count', + ]); + expect(checkCourseFlowPlan({...course, elective_count: 2}, plan)).toEqual([]); + }); +}); diff --git a/src/app/courseflow/coursemap/course-flow-checks.ts b/src/app/courseflow/coursemap/course-flow-checks.ts new file mode 100644 index 0000000000..f595b4d074 --- /dev/null +++ b/src/app/courseflow/coursemap/course-flow-checks.ts @@ -0,0 +1,52 @@ +import {CourseFlowCourse, CourseFlowIssue, CourseFlowSlot} from 'src/app/api/models/course-flow'; + +/** Immediate feedback while editing. The API repeats these checks on every save. */ +export function checkCourseFlowPlan( + course: CourseFlowCourse, + slots: CourseFlowSlot[], +): CourseFlowIssue[] { + const issues: CourseFlowIssue[] = []; + const planned = new Map(slots.map((slot) => [slot.unit_code, slot])); + const periodOrder = (slot: CourseFlowSlot): number => slot.year * 3 + slot.trimester; + let electives = 0; + for (const unit of course.units) { + const slot = planned.get(unit.code); + if (!slot) { + if (unit.required) { + issues.push({ + code: 'missing_required', + unit_code: unit.code, + message: `Required unit ${unit.code} is not planned.`, + }); + } + continue; + } + if (!unit.required) { + electives++; + } + if (!unit.offered_trimesters.includes(slot.trimester)) { + issues.push({ + code: 'unavailable_trimester', + unit_code: unit.code, + message: `${unit.code} is not offered in trimester ${slot.trimester} in this catalog.`, + }); + } + for (const prerequisite of unit.prerequisites) { + const earlier = planned.get(prerequisite); + if (!earlier || periodOrder(earlier) >= periodOrder(slot)) { + issues.push({ + code: 'prerequisite', + unit_code: unit.code, + message: `Plan ${prerequisite} in an earlier study period than ${unit.code}.`, + }); + } + } + } + if (electives !== course.elective_count) { + issues.push({ + code: 'elective_count', + message: `Plan exactly ${course.elective_count} elective units; ${electives} are currently planned.`, + }); + } + return issues; +} diff --git a/src/app/courseflow/coursemap/course-flow-dirty.guard.ts b/src/app/courseflow/coursemap/course-flow-dirty.guard.ts new file mode 100644 index 0000000000..310e0fffe1 --- /dev/null +++ b/src/app/courseflow/coursemap/course-flow-dirty.guard.ts @@ -0,0 +1,8 @@ +import {CanDeactivateFn} from '@angular/router'; + +export interface CourseFlowNavigation { + canLeave(): boolean; +} + +export const courseFlowDirtyGuard: CanDeactivateFn = (component) => + component.canLeave(); diff --git a/src/app/courseflow/coursemap/coursemap.component.html b/src/app/courseflow/coursemap/coursemap.component.html new file mode 100644 index 0000000000..2137370f5f --- /dev/null +++ b/src/app/courseflow/coursemap/coursemap.component.html @@ -0,0 +1,351 @@ +
      +
      +
      +

      Your study, planned ahead

      +

      Course Flow

      +

      Arrange units across study periods and save plans to your account.

      +
      + +
      + +

      + {{ loading ? 'Loading course plans…' : status }} +

      + @if (loadError) { + + } + @if (actionError) { + + } + + @if (!loading && !loadError) { +
      +
      + Your saved plans + @if (maps.length === 0) { +

      You have no saved plans yet. Choose a course to start one.

      + } @else { +
        + @for (map of maps; track map.id) { +
      • + +
      • + } +
      + } +
      +
      + Course catalog + @if (courses.length === 0) { +

      No course catalogs are available yet. Ask your administrator to publish a catalog.

      + + } @else { + + +

      + Each plan uses one catalog version. Start a new plan to choose another course. +

      + } +
      +
      + + @if (selectedCourse; as course) { +
      + Plan details +
      + + +
      +
      + + @if (currentMap) { + + } + {{ dirty ? 'Unsaved changes' : 'Saved to your account' }} +
      +
      + +
      +

      Planning checks

      +

      + {{ plannedRequired }} of {{ requiredCount }} required units · {{ plannedElectives }} of + {{ course.elective_count }} electives planned +

      +

      + These checks use the configured catalog. They do not confirm graduation eligibility. Plans + with issues can still be saved as drafts. +

      + @if (issues.length === 0) { +

      Your plan satisfies the configured planning rules.

      + } @else { +
      + + {{ issues.length }} planning {{ issues.length === 1 ? 'issue' : 'issues' }} to review + +
        + @for (issue of issues; track $index) { +
      • {{ issue.message }}
      • + } +
      +
      + } +
      + +
      + Place or move a unit +

      + Use these controls or drag a unit into a slot. Choose an empty slot for an unplanned unit. + Two planned units can swap slots. +

      +
      +
      + + +
      +
      + + +
      + + +
      +
      + +
      + + +
      +

      Study periods

      +
      +
      + Add a study period +
      + +
      +
      + +
      + +
      +
      + @for (year of years; track year) { +
      +

      {{ year }}

      + @for (period of periodsInYear(year); track period.trimester) { +
      +
      +

      Trimester {{ period.trimester }}

      + +
      +
      + @for (position of positions; track position) { +
      + Slot {{ position }} + @if (slotAt(period, position); as slot) { + @if (unitByCode(slot.unit_code); as unit) { +
      +
      {{ unit.code }}
      +

      {{ unit.name }}

      +

      + {{ unit.required ? 'Required' : 'Elective' }} · Offered: + {{ unit.offered_trimesters.join(', ') }}
      Prerequisites: + {{ + unit.prerequisites.length ? unit.prerequisites.join(', ') : 'None' + }} +

      + +
      + } + } @else { + Empty + } +
      + } +
      +
      + } +
      + } +
      +
      + } + } +
      diff --git a/src/app/courseflow/coursemap/coursemap.component.scss b/src/app/courseflow/coursemap/coursemap.component.scss new file mode 100644 index 0000000000..d90dc692d7 --- /dev/null +++ b/src/app/courseflow/coursemap/coursemap.component.scss @@ -0,0 +1,333 @@ +:host { + display: block; + color: var(--ot-color-text); +} + +.course-flow { + max-width: 1500px; + margin: 0 auto; + padding: 24px; +} + +h1, +h2, +h3, +h4, +h5, +p { + margin-top: 0; +} +h1 { + font-size: 2rem; + margin-bottom: 8px; +} +h2 { + font-size: 1.2rem; +} +h3 { + font-size: 1.1rem; +} +h4, +h5 { + font-size: 1rem; + margin-bottom: 6px; +} +p { + line-height: 1.5; +} + +.page-heading, +.period-heading, +.button-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.eyebrow { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.panel { + border: 1px solid var(--ot-color-border); + border-radius: 10px; + padding: 18px; + margin: 0 0 20px; + background: var(--ot-color-surface); + min-width: 0; +} +legend { + font-weight: 700; + padding: 0 6px; +} +label { + display: block; + font-weight: 600; + margin-bottom: 6px; +} +input, +select, +button { + font: inherit; +} +input, +select { + width: 100%; + min-width: 0; + min-height: 44px; + padding: 8px 10px; + border: 1px solid var(--ot-color-border); + border-radius: 6px; + color: inherit; + background: var(--ot-color-surface); +} +button { + border: 1px solid var(--ot-color-border); + border-radius: 6px; + min-height: 44px; + padding: 9px 14px; + color: inherit; + background: var(--ot-color-surface-raised); + cursor: pointer; +} +button:hover:not(:disabled) { + background: var(--ot-color-hover); +} +button:disabled, +fieldset:disabled button { + cursor: default; + opacity: 0.55; +} +button:focus-visible, +input:focus-visible, +select:focus-visible, +summary:focus-visible { + outline: 3px solid var(--ot-color-primary); + outline-offset: 3px; +} +button.primary { + background: var(--ot-color-primary); + color: var(--ot-color-on-primary); + border-color: transparent; +} +button.primary:hover:not(:disabled) { + filter: brightness(0.9); +} +button.danger { + color: var(--ot-color-error); +} +button.secondary { + background: transparent; +} +.hint, +.unit-rules { + font-size: 0.875rem; + color: var(--ot-color-text-muted); +} +.hint { + margin: 10px 0; +} +.status { + min-height: 24px; +} +.error-panel { + border: 1px solid var(--ot-color-error); + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; +} +.error-panel p { + margin-bottom: 10px; +} +.plan-setup { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 20px; +} +.saved-plans ul { + margin: 0; + padding: 0; + list-style: none; + max-height: 200px; + overflow: auto; +} +.plan-link { + text-align: left; + border: 0; + background: transparent; + width: 100%; + overflow-wrap: anywhere; +} +.plan-link[aria-current] { + font-weight: 700; + background: var(--ot-color-surface-raised); +} +.plan-details { + display: flex; + align-items: end; + gap: 24px; + flex-wrap: wrap; +} +.name-field { + flex: 1; + min-width: 230px; +} +.button-row { + justify-content: flex-start; +} +.save-state { + font-size: 0.875rem; +} +.planning-checks p { + margin-bottom: 8px; +} +.planning-checks details { + margin-top: 16px; +} +.planning-checks summary { + cursor: pointer; + padding: 8px 0; + font-weight: 600; +} +.planning-checks li { + margin-bottom: 6px; +} +.check-success { + font-weight: 600; +} +.placement-fields { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.25fr) auto auto; + gap: 12px; + align-items: end; +} +.planner-grid { + display: grid; + grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} +.catalog-list { + min-height: 48px; + padding-bottom: 10px; +} +.unit-card { + border: 1px solid var(--ot-color-border); + border-radius: 6px; + padding: 12px; + margin-bottom: 10px; + background: var(--ot-color-surface-raised); + cursor: grab; + overflow-wrap: anywhere; +} +.unit-card p { + margin-bottom: 6px; +} +.unit-card .unit-rules { + margin-bottom: 0; +} +.unit-card button { + margin-top: 12px; +} +.unit-card.cdk-drag-disabled { + cursor: default; +} +.add-period { + display: flex; + align-items: end; + gap: 12px; + flex-wrap: wrap; +} +.add-period > div { + flex: 1; + min-width: 100px; +} +.year-heading { + margin-bottom: 12px; + font-size: 1.4rem; +} +.period-heading { + margin-bottom: 16px; +} +.period-heading h4 { + margin: 0; +} +.slots { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.slot { + border: 1px dashed var(--ot-color-border); + border-radius: 6px; + min-height: 135px; + padding: 8px; + min-width: 0; +} +.slot-label { + display: block; + font-size: 0.75rem; + margin-bottom: 8px; +} +.empty-slot { + display: block; + padding: 22px 0; + text-align: center; + font-size: 0.875rem; +} +.planned-unit { + padding: 10px; + margin: 0; +} +.cdk-drag-preview { + box-sizing: border-box; + box-shadow: 0 5px 12px var(--ot-scrim); +} +.cdk-drag-placeholder { + opacity: 0.35; +} + +@media (max-width: 1150px) { + .placement-fields { + grid-template-columns: 1fr 1fr; + } + .slots { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 720px) { + .course-flow { + padding: 16px; + } + .plan-setup, + .planner-grid { + grid-template-columns: 1fr; + gap: 0; + } + .catalog { + max-height: 400px; + overflow-y: auto; + } + .placement-fields { + grid-template-columns: 1fr; + } + .name-field { + min-width: 0; + flex-basis: 100%; + } + .study-period { + padding: 12px; + } + .slots { + gap: 8px; + } +} +@media (max-width: 380px) { + .slots { + grid-template-columns: 1fr; + } +} diff --git a/src/app/courseflow/coursemap/coursemap.component.spec.ts b/src/app/courseflow/coursemap/coursemap.component.spec.ts new file mode 100644 index 0000000000..3cc98b8ac4 --- /dev/null +++ b/src/app/courseflow/coursemap/coursemap.component.spec.ts @@ -0,0 +1,502 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {HttpErrorResponse} from '@angular/common/http'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ActivatedRoute, Router, convertToParamMap, provideRouter} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; +import {BehaviorSubject, Subject, of, throwError} from 'rxjs'; +import {CourseFlowCourse, CourseFlowDraft, CourseFlowMap} from 'src/app/api/models/course-flow'; +import {CourseFlowService} from 'src/app/api/services/course-flow.service'; +import {courseFlowDirtyGuard} from './course-flow-dirty.guard'; +import {CoursemapComponent} from './coursemap.component'; + +const course: CourseFlowCourse = { + id: 7, + code: 'DEMO', + name: 'Example catalog', + version: 'test-only', + elective_count: 1, + units: [ + { + code: 'REQ1', + name: 'First required', + required: true, + prerequisites: [], + offered_trimesters: [1, 2, 3], + }, + { + code: 'REQ2', + name: 'Second required', + required: true, + prerequisites: ['REQ1'], + offered_trimesters: [2], + }, + { + code: 'ELEC1', + name: 'First elective', + required: false, + prerequisites: [], + offered_trimesters: [1, 2, 3], + }, + { + code: 'ELEC2', + name: 'Second elective', + required: false, + prerequisites: [], + offered_trimesters: [1, 2, 3], + }, + ], +}; +const otherCourse: CourseFlowCourse = { + ...course, + id: 8, + code: 'OTHER', + units: [], + elective_count: 0, +}; +const savedMap = (patch: Partial = {}): CourseFlowMap => ({ + id: 12, + course_id: course.id, + name: 'My saved plan', + lock_version: 3, + periods: [ + {year: 2026, trimester: 1}, + {year: 2026, trimester: 2}, + ], + slots: [{unit_code: 'REQ1', year: 2026, trimester: 1, position: 3}], + issues: [], + complete: false, + updated_at: '2026-09-22T00:00:00Z', + ...patch, +}); + +function mockService() { + return { + getCourses: vi.fn().mockReturnValue(of([course, otherCourse])), + getMaps: vi.fn().mockReturnValue(of([savedMap()])), + getMap: vi.fn().mockImplementation((id: number) => of(savedMap({id}))), + createMap: vi + .fn() + .mockImplementation((draft: CourseFlowDraft) => + of(savedMap({...draft, id: 55, lock_version: 0})), + ), + updateMap: vi + .fn() + .mockImplementation((id: number, draft: CourseFlowDraft, version: number) => + of(savedMap({...draft, id, lock_version: version + 1})), + ), + deleteMap: vi.fn().mockReturnValue(of(undefined)), + }; +} + +describe('Course Flow account planning', () => { + let fixture: ComponentFixture; + let component: CoursemapComponent; + let params: BehaviorSubject>; + let service: ReturnType; + let router: {navigate: ReturnType}; + let confirm: ReturnType; + + beforeEach(async () => { + params = new BehaviorSubject(convertToParamMap({})); + service = mockService(); + router = {navigate: vi.fn().mockResolvedValue(true)}; + confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + await TestBed.configureTestingModule({ + imports: [CoursemapComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + paramMap: params, + get snapshot() { + return {paramMap: params.value}; + }, + }, + }, + {provide: Router, useValue: router}, + {provide: CourseFlowService, useValue: service}, + ], + }).compileComponents(); + fixture = TestBed.createComponent(CoursemapComponent); + component = fixture.componentInstance; + }); + afterEach(() => vi.restoreAllMocks()); + + function start(): void { + fixture.detectChanges(); + component.selectCourse(course.id); + fixture.detectChanges(); + } + function load(): void { + params.next(convertToParamMap({courseMapId: '12'})); + fixture.detectChanges(); + } + async function choose(selector: string, value: string): Promise { + const select: HTMLSelectElement = fixture.nativeElement.querySelector(selector); + select.value = value; + select.dispatchEvent(new Event('change')); + await fixture.whenStable(); + fixture.detectChanges(); + } + + it('loads only authenticated catalog and own plans without creating records or choosing a course', () => { + fixture.detectChanges(); + expect(service.getCourses).toHaveBeenCalledOnce(); + expect(service.getMaps).toHaveBeenCalledOnce(); + expect(service.getMap).not.toHaveBeenCalled(); + expect(service.createMap).not.toHaveBeenCalled(); + expect(component.selectedCourse).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('My saved plan'); + expect(fixture.nativeElement.querySelector('#course-select')).not.toBeNull(); + }); + + it('shows an explicit empty catalog and makes no demo writes', () => { + service.getCourses.mockReturnValue(of([])); + service.getMaps.mockReturnValue(of([])); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('No course catalogs are available'); + expect(fixture.nativeElement.textContent).toContain('You have no saved plans'); + expect(service.createMap).not.toHaveBeenCalled(); + }); + + it('renders loading, cancels old reads on map navigation, and unsubscribes on destruction', () => { + const first: Subject = new Subject(); + service.getMap.mockReturnValueOnce(first); + load(); + expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('Loading'); + expect(first.observed).toBe(true); + params.next(convertToParamMap({courseMapId: '13'})); + fixture.detectChanges(); + expect(first.observed).toBe(false); + expect(component.currentMap.id).toBe(13); + fixture.destroy(); + params.next(convertToParamMap({courseMapId: '14'})); + expect(service.getMap.mock.calls).toEqual([[12], [13]]); + }); + + it('reports an unavailable private map without displaying other saved content and retries', () => { + service.getMap.mockReturnValueOnce(throwError(() => new HttpErrorResponse({status: 404}))); + load(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'does not belong to you', + ); + expect(fixture.nativeElement.textContent).not.toContain('My saved plan'); + component.retryLoad(); + fixture.detectChanges(); + expect(component.currentMap.id).toBe(12); + expect(component.loadError).toBe(''); + }); + + it.each(['../1', '0', '-1', '9007199254740993'])( + 'rejects invalid map ID %s before requests', + (id) => { + params.next(convertToParamMap({courseMapId: id})); + fixture.detectChanges(); + expect(component.loadError).toContain('Invalid'); + expect(service.getCourses).not.toHaveBeenCalled(); + expect(service.getMap).not.toHaveBeenCalled(); + }, + ); + + it('restores the visible course selector when discarding changes is declined', async () => { + fixture.detectChanges(); + await choose('#course-select', String(course.id)); + confirm.mockReturnValue(false); + await choose('#course-select', String(otherCourse.id)); + expect(component.selectedCourse.id).toBe(course.id); + expect(fixture.nativeElement.querySelector('#course-select').value).toBe(String(course.id)); + confirm.mockReturnValue(true); + await choose('#course-select', String(otherCourse.id)); + expect(component.selectedCourse.id).toBe(otherCourse.id); + expect(component.slots).toEqual([]); + expect(component.requiredCount).toBe(0); + }); + + it('uses a valid initial period even after an invalid add-period draft and new-plan action', () => { + start(); + component.periodYear = 2300; + component.periodTrimester = 9; + component.startNewPlan(); + component.selectCourse(course.id); + expect(component.periods).toEqual([{year: new Date().getFullYear(), trimester: 1}]); + component.periodYear = null; + component.selectCourse(otherCourse.id); + expect(component.periods[0].year).toBe(new Date().getFullYear()); + }); + + it('supports native keyboard controls for placing, swapping and removing units', async () => { + start(); + const year = component.periods[0].year; + await choose('#placement-unit', 'REQ1'); + await choose('#placement-destination', `${year}-1-1`); + const buttons = () => + Array.from(fixture.nativeElement.querySelectorAll('button')) as HTMLButtonElement[]; + buttons() + .find((button) => button.textContent.trim() === 'Place or move unit') + .click(); + fixture.detectChanges(); + expect(component.slots[0]).toEqual({unit_code: 'REQ1', year, trimester: 1, position: 1}); + expect(fixture.nativeElement.querySelector(`#slot-${year}-1-1`).textContent).toContain('REQ1'); + await choose('#placement-unit', 'ELEC1'); + await choose('#placement-destination', `${year}-1-2`); + buttons() + .find((button) => button.textContent.trim() === 'Place or move unit') + .click(); + await choose('#placement-unit', 'REQ1'); + await choose('#placement-destination', `${year}-1-2`); + buttons() + .find((button) => button.textContent.trim() === 'Place or move unit') + .click(); + fixture.detectChanges(); + expect(component.slotAt({year, trimester: 1}, 1).unit_code).toBe('ELEC1'); + expect(component.slotAt({year, trimester: 1}, 2).unit_code).toBe('REQ1'); + expect(component.plannedElectives).toBe(1); + buttons() + .find((button) => button.textContent.trim() === 'Remove selected unit') + .click(); + fixture.detectChanges(); + expect(component.availableRequired.map((unit) => unit.code)).toContain('REQ1'); + expect(component.availableElectives.map((unit) => unit.code)).not.toContain('REQ1'); + }); + + it('uses the same placement rules for drag/drop, rejects unknown units, and preserves occupied slots', () => { + start(); + const period = component.periods[0]; + const drop = (code: string, target: object) => + component.drop({container: {data: target}, item: {data: code}} as Parameters< + CoursemapComponent['drop'] + >[0]); + drop('REQ1', {...period, position: 1, kind: 'slot'}); + drop('ELEC1', {...period, position: 1, kind: 'slot'}); + expect(component.actionError).toContain('empty slot'); + expect(component.slots.map((slot) => slot.unit_code)).toEqual(['REQ1']); + drop('FOREIGN', {...period, position: 2, kind: 'slot'}); + expect(component.slots).toHaveLength(1); + drop('REQ1', {...period, position: 2, kind: 'slot'}); + expect(component.slots[0].position).toBe(2); + drop('REQ1', {kind: 'catalog'}); + expect(component.slots).toEqual([]); + expect(component.availableRequired).toHaveLength(2); + }); + + it('removes periods with their slots, preserves empty periods, and prevents duplicate or invalid periods', () => { + start(); + const first = component.periods[0]; + component.placeUnit('REQ1', first, 1); + component.periodYear = first.year; + component.periodTrimester = 2; + component.addPeriod(); + component.addPeriod(); + expect(component.actionError).toContain('already'); + expect(component.periods).toHaveLength(2); + component.removePeriod(first); + expect(component.slots).toEqual([]); + expect(component.availableRequired).toHaveLength(2); + component.removePeriod(component.periods[0]); + expect(component.periods).toHaveLength(1); + component.periodYear = 2300; + component.addPeriod(); + expect(component.actionError).toContain('2000 to 2200'); + component.save(); + expect(service.createMap.mock.calls[0][0].periods).toEqual([{year: first.year, trimester: 2}]); + expect(service.createMap.mock.calls[0][0].slots).toEqual([]); + }); + + it('creates a named plan with course codes and explicit empty periods, then opens its saved URL', () => { + start(); + const first = component.periods[0]; + component.name = ' My plan '; + component.placeUnit('REQ1', first, 3); + component.periodYear = first.year + 1; + component.periodTrimester = 3; + component.addPeriod(); + component.save(); + expect(service.createMap).toHaveBeenCalledWith({ + course_id: 7, + name: 'My plan', + periods: [first, {year: first.year + 1, trimester: 3}], + slots: [{unit_code: 'REQ1', ...first, position: 3}], + }); + expect(component.currentMap.id).toBe(55); + expect(component.dirty).toBe(false); + expect(component.status).toContain('stored in your account'); + expect(router.navigate).toHaveBeenCalledWith(['/coursemap', 55], {replaceUrl: true}); + }); + + it('loads exact saved positions and empty periods and updates with the returned lock version', async () => { + load(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.periods).toEqual(savedMap().periods); + expect(component.slotAt({year: 2026, trimester: 1}, 3).unit_code).toBe('REQ1'); + expect(fixture.nativeElement.querySelector('#course-select').disabled).toBe(true); + expect(fixture.nativeElement.querySelector('#course-select').value).toBe(String(course.id)); + component.name = 'Changed'; + component.save(); + expect(service.updateMap.mock.calls[0]).toEqual([ + 12, + { + course_id: 7, + name: 'Changed', + periods: savedMap().periods, + slots: savedMap().slots, + }, + 3, + ]); + expect(component.currentMap.lock_version).toBe(4); + component.name = 'Changed again'; + component.save(); + expect(service.updateMap.mock.calls[1][2]).toBe(4); + }); + + it('blocks editing and navigation while saving and retains local work on a connection failure', () => { + start(); + const pending: Subject = new Subject(); + service.createMap.mockReturnValue(pending); + component.save(); + fixture.detectChanges(); + expect(component.saving).toBe(true); + expect(component.canLeave()).toBe(false); + expect(fixture.nativeElement.querySelector('.plan-details').disabled).toBe(true); + const before = component.periods; + component.addPeriod(); + component.startNewPlan(); + component.openMap(12); + expect(component.periods).toBe(before); + expect(router.navigate).not.toHaveBeenCalled(); + pending.error(new HttpErrorResponse({status: 0})); + fixture.detectChanges(); + expect(component.dirty).toBe(true); + expect(component.saving).toBe(false); + expect(component.actionError).toContain('Your edits are still here'); + }); + + it('keeps edits and recovery controls after conflicts and can save an independent copy', () => { + load(); + service.updateMap.mockReturnValue(throwError(() => new HttpErrorResponse({status: 409}))); + component.name = 'My local edits'; + component.save(); + expect(component.conflict).toBe(true); + expect(component.name).toBe('My local edits'); + expect(component.currentMap.lock_version).toBe(3); + component.name = 'More local edits'; + component.nameChanged(); + component.placeUnit('ELEC1', component.periods[0], 1); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'Save a copy', + ); + confirm.mockReturnValue(false); + component.reloadSaved(); + expect(service.getMap).toHaveBeenCalledOnce(); + component.save(true); + expect(service.createMap.mock.calls[0][0].name).toBe('More local edits (copy)'); + expect(component.currentMap.id).toBe(55); + expect(component.slots.some((slot) => slot.unit_code === 'ELEC1')).toBe(true); + expect(component.conflict).toBe(false); + }); + + it('confirms deletion, uses optimistic locking and retains edits if delete conflicts', () => { + load(); + component.name = 'Unsaved'; + confirm.mockReturnValue(false); + component.deletePlan(); + expect(service.deleteMap).not.toHaveBeenCalled(); + confirm.mockReturnValue(true); + service.deleteMap.mockReturnValueOnce(throwError(() => new HttpErrorResponse({status: 409}))); + component.deletePlan(); + expect(service.deleteMap).toHaveBeenCalledWith(12, 3); + expect(component.name).toBe('Unsaved'); + expect(component.conflict).toBe(true); + component.deletePlan(); + expect(component.currentMap).toBeNull(); + expect(component.maps).toEqual([]); + expect(component.dirty).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/coursemap']); + }); + + it('protects browser exit and new-plan changes, but allows leaving an unchanged saved plan', () => { + load(); + expect(component.canLeave()).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + component.name = 'Changed'; + confirm.mockReturnValue(false); + component.startNewPlan(); + expect(component.currentMap.id).toBe(12); + const event = new Event('beforeunload', {cancelable: true}); + component.beforeUnload(event as BeforeUnloadEvent); + expect(event.defaultPrevented).toBe(true); + confirm.mockReturnValue(true); + component.startNewPlan(); + expect(component.currentMap).toBeNull(); + expect(component.dirty).toBe(false); + }); + + it('shows prerequisite, availability and elective checks without blocking draft saves', () => { + start(); + component.placeUnit('REQ2', component.periods[0], 1); + expect(component.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'missing_required', + 'elective_count', + 'prerequisite', + 'unavailable_trimester', + ]), + ); + component.save(); + expect(service.createMap).toHaveBeenCalledOnce(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain( + 'They do not confirm graduation eligibility', + ); + expect(fixture.nativeElement.textContent).toContain( + 'REQ1 in an earlier study period than REQ2', + ); + }); + + it('rejects empty or oversized names without sending a request and accepts the 200-character bound', () => { + start(); + component.name = ' '; + component.save(); + component.name = 'x'.repeat(201); + component.save(); + expect(service.createMap).not.toHaveBeenCalled(); + component.name = 'x'.repeat(200); + component.save(); + expect(service.createMap).toHaveBeenCalledOnce(); + }); +}); + +describe('Course Flow navigation guard with real Angular routing', () => { + afterEach(() => vi.restoreAllMocks()); + + it('protects path-parameter navigation between saved plans and route exit', async () => { + const service = mockService(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + {path: 'coursemap', component: CoursemapComponent, canDeactivate: [courseFlowDirtyGuard]}, + { + path: 'coursemap/:courseMapId', + component: CoursemapComponent, + canDeactivate: [courseFlowDirtyGuard], + }, + ]), + {provide: CourseFlowService, useValue: service}, + ], + }); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + const harness = await RouterTestingHarness.create('/coursemap/12'); + const component = harness.routeDebugElement.componentInstance as CoursemapComponent; + component.name = 'Unsaved change'; + await harness.navigateByUrl('/coursemap/13'); + expect(TestBed.inject(Router).url).toBe('/coursemap/12'); + expect(service.getMap.mock.calls).toEqual([[12]]); + await harness.navigateByUrl('/coursemap'); + expect(TestBed.inject(Router).url).toBe('/coursemap/12'); + confirm.mockReturnValue(true); + await harness.navigateByUrl('/coursemap/13'); + expect(TestBed.inject(Router).url).toBe('/coursemap/13'); + expect(service.getMap.mock.calls).toEqual([[12], [13]]); + }); +}); diff --git a/src/app/courseflow/coursemap/coursemap.component.ts b/src/app/courseflow/coursemap/coursemap.component.ts new file mode 100644 index 0000000000..cb175eb323 --- /dev/null +++ b/src/app/courseflow/coursemap/coursemap.component.ts @@ -0,0 +1,529 @@ +import {CdkDragDrop, DragDropModule} from '@angular/cdk/drag-drop'; +import {HttpErrorResponse} from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + HostListener, + OnInit, + inject, +} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {FormsModule, NgModel} from '@angular/forms'; +import {ActivatedRoute, Router} from '@angular/router'; +import {BehaviorSubject, catchError, combineLatest, forkJoin, of, switchMap} from 'rxjs'; +import { + CourseFlowCourse, + CourseFlowDraft, + CourseFlowIssue, + CourseFlowMap, + CourseFlowPeriod, + CourseFlowSlot, + CourseFlowUnit, +} from 'src/app/api/models/course-flow'; +import {CourseFlowService} from 'src/app/api/services/course-flow.service'; +import {checkCourseFlowPlan} from './course-flow-checks'; + +interface DropTarget { + kind: 'catalog' | 'slot'; + year?: number; + trimester?: number; + position?: number; +} + +@Component({ + selector: 'f-coursemap', + changeDetection: ChangeDetectionStrategy.Eager, + templateUrl: './coursemap.component.html', + styleUrls: ['./coursemap.component.scss'], + standalone: true, + imports: [DragDropModule, FormsModule], +}) +export class CoursemapComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly service = inject(CourseFlowService); + private readonly refresh = new BehaviorSubject(0); + private savedSnapshot: string | null = null; + + courses: CourseFlowCourse[] = []; + maps: CourseFlowMap[] = []; + selectedCourse: CourseFlowCourse | null = null; + currentMap: CourseFlowMap | null = null; + name = ''; + periods: CourseFlowPeriod[] = []; + slots: CourseFlowSlot[] = []; + loading = true; + saving = false; + deleting = false; + loadError = ''; + actionError = ''; + conflict = false; + status = ''; + unitSelection = ''; + destinationSelection = ''; + periodYear = new Date().getFullYear(); + periodTrimester = 1; + readonly positions = [1, 2, 3, 4]; + readonly catalogTarget: DropTarget = {kind: 'catalog'}; + + slotTarget(period: CourseFlowPeriod, position: number): DropTarget { + return {kind: 'slot', ...period, position}; + } + + ngOnInit(): void { + combineLatest([this.route.paramMap, this.refresh]) + .pipe( + switchMap(([params]) => { + this.loading = true; + this.loadError = ''; + const routeId = params.get('courseMapId'); + if ( + routeId !== null && + (!/^[1-9]\d*$/.test(routeId) || !Number.isSafeInteger(Number(routeId))) + ) { + this.loadError = + 'Invalid course plan ID. Start a new plan or choose one of your saved plans.'; + this.loading = false; + return of(null); + } + return forkJoin({ + courses: this.service.getCourses(), + maps: this.service.getMaps(), + map: routeId ? this.service.getMap(Number(routeId)) : of(null), + }).pipe( + catchError((error: HttpErrorResponse) => { + this.loadError = + error.status === 404 + ? 'This course plan is unavailable or does not belong to you.' + : 'Course plans could not be loaded. Check your connection and try again.'; + this.loading = false; + return of(null); + }), + ); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((data) => { + if (!data) { + return; + } + this.courses = data.courses; + this.maps = data.maps; + this.clearDraft(); + if (data.map) { + const course = this.courses.find((item) => item.id === data.map.course_id); + if (!course) { + this.loadError = + 'The catalog for this saved plan is unavailable. Please contact your administrator.'; + } else { + this.applyMap(data.map, course); + } + } + this.loading = false; + }); + } + + get busy(): boolean { + return this.loading || this.saving || this.deleting; + } + + get dirty(): boolean { + return this.selectedCourse !== null && this.snapshot() !== this.savedSnapshot; + } + + get issues(): CourseFlowIssue[] { + return this.selectedCourse ? checkCourseFlowPlan(this.selectedCourse, this.slots) : []; + } + + get years(): number[] { + return [...new Set(this.periods.map((period) => period.year))].sort((a, b) => a - b); + } + + get plannedRequired(): number { + return ( + this.selectedCourse?.units.filter((unit) => unit.required && this.isPlanned(unit.code)) + .length ?? 0 + ); + } + + get requiredCount(): number { + return this.selectedCourse?.units.filter((unit) => unit.required).length ?? 0; + } + + get plannedElectives(): number { + return ( + this.selectedCourse?.units.filter((unit) => !unit.required && this.isPlanned(unit.code)) + .length ?? 0 + ); + } + + get availableRequired(): CourseFlowUnit[] { + return ( + this.selectedCourse?.units.filter((unit) => unit.required && !this.isPlanned(unit.code)) ?? [] + ); + } + + get availableElectives(): CourseFlowUnit[] { + return ( + this.selectedCourse?.units.filter((unit) => !unit.required && !this.isPlanned(unit.code)) ?? + [] + ); + } + + get destinations(): {value: string; label: string}[] { + return this.periods.flatMap((period) => + this.positions.map((position) => { + const occupant = this.slotAt(period, position); + return { + value: `${period.year}-${period.trimester}-${position}`, + label: `${period.year}, trimester ${period.trimester}, slot ${position}${occupant ? ` (${occupant.unit_code})` : ' (empty)'}`, + }; + }), + ); + } + + periodsInYear(year: number): CourseFlowPeriod[] { + return this.periods.filter((period) => period.year === year); + } + + isPlanned(code: string): boolean { + return this.slots.some((slot) => slot.unit_code === code); + } + + unitByCode(code: string): CourseFlowUnit | undefined { + return this.selectedCourse?.units.find((unit) => unit.code === code); + } + + slotAt(period: CourseFlowPeriod, position: number): CourseFlowSlot | undefined { + return this.slots.find( + (slot) => + slot.year === period.year && + slot.trimester === period.trimester && + slot.position === position, + ); + } + + canLeave(): boolean { + if (this.saving || this.deleting) { + return false; + } + return !this.dirty || window.confirm('Discard unsaved changes to this course plan?'); + } + + @HostListener('window:beforeunload', ['$event']) + beforeUnload(event: BeforeUnloadEvent): void { + if (this.dirty || this.saving || this.deleting) { + event.preventDefault(); + event.returnValue = ''; + } + } + + selectCourse(id: number | null): void { + if (this.busy || this.currentMap || this.selectedCourse?.id === id || !this.canLeave()) { + return; + } + this.clearDraft(); + this.selectedCourse = this.courses.find((course) => course.id === id) ?? null; + if (this.selectedCourse) { + this.name = `${this.selectedCourse.code} study plan`; + this.periods = [{year: this.periodYear, trimester: 1}]; + this.status = 'New plan. Save to keep your changes.'; + } + } + + selectCourseFromInput(value: string, control: NgModel): void { + this.selectCourse(value ? Number(value) : null); + control.control.setValue(this.selectedCourse ? String(this.selectedCourse.id) : '', { + emitEvent: false, + emitViewToModelChange: false, + }); + } + + startNewPlan(): void { + if (this.busy || !this.canLeave()) { + return; + } + this.clearDraft(); + if (this.route.snapshot.paramMap.has('courseMapId')) { + void this.router.navigate(['/coursemap']); + } + } + + openMap(id: number): void { + if (this.busy) { + return; + } + if (this.currentMap?.id === id) { + this.reloadSaved(); + } else { + void this.router.navigate(['/coursemap', id]); + } + } + + retryLoad(): void { + if (!this.busy && this.canLeave()) { + this.refresh.next(this.refresh.value + 1); + } + } + + reloadSaved(): void { + if (!this.busy && this.canLeave()) { + this.refresh.next(this.refresh.value + 1); + } + } + + nameChanged(): void { + this.status = 'Unsaved changes.'; + if (!this.conflict) { + this.actionError = ''; + } + } + + addPeriod(): void { + if (this.busy || !this.selectedCourse) { + return; + } + if ( + !Number.isInteger(this.periodYear) || + this.periodYear < 2000 || + this.periodYear > 2200 || + ![1, 2, 3].includes(this.periodTrimester) + ) { + this.actionError = 'Choose a year from 2000 to 2200 and a trimester from 1 to 3.'; + return; + } + if (this.periods.length >= 60) { + this.actionError = 'A plan can contain at most 60 study periods.'; + return; + } + if ( + this.periods.some( + (period) => period.year === this.periodYear && period.trimester === this.periodTrimester, + ) + ) { + this.actionError = 'This study period is already in the plan.'; + return; + } + this.periods = [...this.periods, {year: this.periodYear, trimester: this.periodTrimester}].sort( + (a, b) => a.year - b.year || a.trimester - b.trimester, + ); + this.edited(`Added ${this.periodYear}, trimester ${this.periodTrimester}.`); + } + + removePeriod(period: CourseFlowPeriod): void { + if (this.busy || this.periods.length <= 1) { + return; + } + this.periods = this.periods.filter( + (item) => item.year !== period.year || item.trimester !== period.trimester, + ); + this.slots = this.slots.filter( + (slot) => slot.year !== period.year || slot.trimester !== period.trimester, + ); + this.destinationSelection = ''; + this.edited( + `Removed ${period.year}, trimester ${period.trimester}. Its units are available in the catalog again.`, + ); + } + + placeSelectedUnit(): void { + const [year, trimester, position] = this.destinationSelection.split('-').map(Number); + this.placeUnit(this.unitSelection, {year, trimester}, position); + } + + placeUnit(code: string, period: CourseFlowPeriod, position: number): void { + if (this.busy || !this.unitByCode(code)) { + return; + } + if ( + !this.positions.includes(position) || + !this.periods.some((item) => item.year === period.year && item.trimester === period.trimester) + ) { + this.actionError = 'Choose a unit and a valid destination slot.'; + return; + } + const source = this.slots.find((slot) => slot.unit_code === code); + const target = this.slotAt(period, position); + if (target?.unit_code === code) { + return; + } + if (target && !source) { + this.actionError = + 'Choose an empty slot for an unplanned unit. Two planned units can swap slots.'; + return; + } + this.slots = this.slots.filter((slot) => slot !== source && slot !== target); + this.slots.push({unit_code: code, year: period.year, trimester: period.trimester, position}); + if (source && target) { + this.slots.push({...source, unit_code: target.unit_code}); + } + this.edited( + target + ? `Swapped ${code} and ${target.unit_code}.` + : `Placed ${code} in ${period.year}, trimester ${period.trimester}, slot ${position}.`, + ); + } + + removeUnit(code: string): void { + if (this.busy || !this.isPlanned(code)) { + return; + } + this.slots = this.slots.filter((slot) => slot.unit_code !== code); + this.edited(`${code} returned to the catalog.`); + } + + drop(event: CdkDragDrop): void { + const target = event.container.data; + if (target.kind === 'catalog') { + this.removeUnit(event.item.data); + } else { + this.placeUnit( + event.item.data, + {year: target.year, trimester: target.trimester}, + target.position, + ); + } + } + + save(asCopy = false): void { + if (this.busy || !this.selectedCourse) { + return; + } + if (!this.name.trim() || this.name.trim().length > 200) { + this.actionError = 'Enter a plan name from 1 to 200 characters.'; + return; + } + const draft = this.draft(); + if (asCopy) { + draft.name = `${draft.name.slice(0, 193)} (copy)`; + } + this.saving = true; + this.actionError = ''; + this.status = 'Saving course plan…'; + const request = + this.currentMap && !asCopy + ? this.service.updateMap(this.currentMap.id, draft, this.currentMap.lock_version) + : this.service.createMap(draft); + request.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (map) => { + this.saving = false; + this.applyMap(map, this.selectedCourse); + this.maps = [map, ...this.maps.filter((item) => item.id !== map.id)]; + if (this.route.snapshot.paramMap.get('courseMapId') !== String(map.id)) { + void this.router.navigate(['/coursemap', map.id], {replaceUrl: true}); + } + }, + error: (error: HttpErrorResponse) => { + this.saving = false; + this.status = ''; + this.writeFailed(error, 'save'); + }, + }); + } + + deletePlan(): void { + if ( + this.busy || + !this.currentMap || + !window.confirm( + `Delete saved plan “${this.currentMap.name}”? This also discards any unsaved changes and cannot be undone.`, + ) + ) { + return; + } + const map = this.currentMap; + this.deleting = true; + this.actionError = ''; + this.status = 'Deleting course plan…'; + this.service + .deleteMap(map.id, map.lock_version) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.deleting = false; + this.maps = this.maps.filter((item) => item.id !== map.id); + this.clearDraft(); + this.status = 'Course plan deleted.'; + void this.router.navigate(['/coursemap']); + }, + error: (error: HttpErrorResponse) => { + this.deleting = false; + this.status = ''; + this.writeFailed(error, 'delete'); + }, + }); + } + + private writeFailed(error: HttpErrorResponse, action: 'save' | 'delete'): void { + this.conflict = error.status === 409; + if (this.conflict) { + this.actionError = + 'This plan changed in another session. Your edits are still here. Reload the saved plan to discard these edits, or save a copy to keep them.'; + } else if (error.status === 404) { + this.actionError = + 'The saved plan is no longer available. Your edits are still here; save a copy to keep them.'; + this.conflict = true; + } else if (error.status === 422) { + this.actionError = + 'The server could not accept this plan. Check the name, course and study periods, then try saving again. Your edits are still here.'; + } else { + this.actionError = `Could not ${action} the course plan. Your edits are still here. Check your connection and try again.`; + } + } + + private applyMap(map: CourseFlowMap, course: CourseFlowCourse): void { + this.currentMap = map; + this.selectedCourse = course; + this.name = map.name; + this.periods = map.periods + .map((period) => ({...period})) + .sort((a, b) => a.year - b.year || a.trimester - b.trimester); + this.slots = map.slots.map((slot) => ({...slot})); + this.savedSnapshot = this.snapshot(); + this.actionError = ''; + this.conflict = false; + this.status = 'Course plan saved. Changes are stored in your account.'; + } + + private clearDraft(): void { + this.selectedCourse = null; + this.currentMap = null; + this.name = ''; + this.periods = []; + this.slots = []; + this.savedSnapshot = null; + this.actionError = ''; + this.conflict = false; + this.status = ''; + this.unitSelection = ''; + this.destinationSelection = ''; + this.periodYear = new Date().getFullYear(); + this.periodTrimester = 1; + } + + private edited(message: string): void { + if (!this.conflict) { + this.actionError = ''; + } + this.status = `${message} Save to keep your changes.`; + } + + private draft(): CourseFlowDraft { + return { + course_id: this.selectedCourse.id, + name: this.name.trim(), + periods: this.periods.map((period) => ({...period})), + slots: this.slots.map((slot) => ({...slot})), + }; + } + + private snapshot(): string { + return JSON.stringify({ + ...this.draft(), + name: this.name, + slots: [...this.slots].sort((a, b) => a.unit_code.localeCompare(b.unit_code)), + }); + } +} diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index c4abd459bc..6ffd5b2308 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -1,5 +1,3 @@ -// Lottie animation module -// import {LottieModule, LottieCacheModule} from 'ngx-lottie'; import {PickerModule} from '@ctrl/ngx-emoji-mart'; import {EmojiModule} from '@ctrl/ngx-emoji-mart/ngx-emoji'; import {CodeEditorModule} from '@ngstack/code-editor'; @@ -18,17 +16,15 @@ import {adapterFactory} from 'angular-calendar/date-adapters/date-fns'; import {enAU} from 'date-fns/locale'; import player from 'lottie-web'; import {PdfViewerModule} from 'ng2-pdf-viewer'; -import {FlexLayoutModule} from 'ng-flex-layout'; import {LottieComponent, provideLottieOptions} from 'ngx-lottie'; // TODO: replace back to original ngx-monaco-editor-v2 once it supports angular 22 import {MonacoEditorModule} from 'ngx-monaco-editor-v2-alternative'; import {NgxSkeletonLoaderModule} from 'ngx-skeleton-loader'; import {environment} from 'src/environments/environment'; -// import {GradeTaskModalComponent} from './tasks/modals/grade-task-modal/grade-task-modal.component'; -// import {PrivacyPolicy} from './config/privacy-policy/privacy-policy'; import {ClipboardModule} from '@angular/cdk/clipboard'; import {DragDropModule} from '@angular/cdk/drag-drop'; import {ScrollingModule} from '@angular/cdk/scrolling'; +import {TextFieldModule} from '@angular/cdk/text-field'; import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; import {APP_INITIALIZER, ErrorHandler, Injector, NgModule} from '@angular/core'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; @@ -97,6 +93,7 @@ import { TaskCommentComposerComponent, } from 'src/app/tasks/task-comment-composer/task-comment-composer.component'; import {EditProfileComponent} from './account/edit-profile/edit-profile.component'; +import {VerifyAdditionalEmailComponent} from './account/verify-additional-email/verify-additional-email.component'; import {ActivityTypeListComponent} from './admin/institution-settings/activity-type-list/activity-type-list.component'; import {CampusListComponent} from './admin/institution-settings/campuses/campus-list/campus-list.component'; import {InstitutionSettingsComponent} from './admin/institution-settings/institution-settings.component'; @@ -162,13 +159,17 @@ import {TutorNoteService} from './api/services/tutor-note.service'; import {setAppInjector} from './app-injector'; import {AppComponent} from './app.component'; import {routes} from './app.routes'; +import {AdditionalNotificationEmailComponent} from './common/additional-notification-email/additional-notification-email.component'; import {ArchiveViewerComponent} from './common/archive-viewer/archive-viewer.component'; import {AudioPlayerComponent} from './common/audio-player/audio-player.component'; import {AudioCommentRecorderComponent} from './common/audio-recorder/audio/audio-comment-recorder/audio-comment-recorder'; import {MicrophoneTesterComponent} from './common/audio-recorder/audio/microphone-tester/microphone-tester.component'; +import {AnimatedCheckComponent} from './common/celebrate/animated-check.component'; +import {CelebrationParticlesComponent} from './common/celebrate/celebration-particles.component'; import {ChartBaseComponent} from './common/chart-base/chart-base-component/chart-base-component.component'; import {DragDropDirective} from './common/directives/drag-drop.directive'; import {EditProfileFormComponent} from './common/edit-profile-form/edit-profile-form.component'; +import {EmptyStateComponent} from './common/empty-state/empty-state.component'; import {FChipComponent} from './common/f-chip/chip.component'; import {FeedbackTemplateEditorComponent} from './common/feedback-template-editor/feedback-template-editor.component'; import {FileDownloaderService} from './common/file-downloader/file-downloader.service'; @@ -186,6 +187,7 @@ import {TasksOfTaskDefinitionPipe} from './common/filters/tasks-of-task-definiti import {FooterComponent} from './common/footer/footer.component'; import {GradeIconComponent} from './common/grade-icon/grade-icon.component'; import {HeaderComponent} from './common/header/header.component'; +import {NotificationBellComponent} from './common/header/notification-bell/notification-bell.component'; import {TaskDropdownComponent} from './common/header/task-dropdown/task-dropdown.component'; import {UnitDropdownComponent} from './common/header/unit-dropdown/unit-dropdown.component'; import {HeroSidebarComponent} from './common/hero-sidebar/hero-sidebar.component'; @@ -210,7 +212,14 @@ import {SpecConModalComponent} from './common/modals/spec-con-modal/spec-con-mod import {SpecConModalService} from './common/modals/spec-con-modal/spec-con-modal.service'; import {TaskAssessmentModalComponent} from './common/modals/task-assessment-modal/task-assessment-modal.component'; import {TutorNotesModalComponent} from './common/modals/tutor-notes-modal/tutor-notes-modal.component'; +import {NotificationSettingsComponent} from './common/notification-settings/notification-settings.component'; +import {NotificationsPageComponent} from './common/notifications-page/notifications-page.component'; import {ObjectSelectComponent} from './common/obect-select/object-select.component'; +import {PageContainerComponent} from './common/page-container/page-container.component'; +import {PanelCollapseButtonComponent} from './common/panel-layout/panel-collapse-button.component'; +import {PanelFullscreenButtonComponent} from './common/panel-layout/panel-fullscreen-button.component'; +import {PanelLayoutComponent} from './common/panel-layout/panel-layout.component'; +import {PanelComponent} from './common/panel-layout/panel.component'; import {PdfViewerPanelComponent} from './common/pdf-viewer-panel/pdf-viewer-panel.component'; import {fPdfViewerComponent} from './common/pdf-viewer/pdf-viewer.component'; import {HumanizedDatePipe} from './common/pipes/humanized-date.pipe'; @@ -218,20 +227,32 @@ import {IsActiveUnitRole} from './common/pipes/is-active-unit-role.pipe'; import {LocalizedDatePipe} from './common/pipes/localized-date.pipe'; import {MarkedPipe} from './common/pipes/marked.pipe'; import {SafePipe} from './common/pipes/safe.pipe'; +import {ProjectCardComponent} from './common/project-card/project-card.component'; import {ProjectProgressBarComponent} from './common/project-progress-bar/project-progress-bar.component'; import {ProjectProgressGaugeComponent} from './common/project-progress/project-progress-gauge.component'; +import {PwaConnectionStatusComponent} from './common/pwa-connection/pwa-connection-status.component'; +import {PwaInstallButtonComponent} from './common/pwa/pwa-install-button.component'; +import {PwaInstallService} from './common/pwa/pwa-install.service'; import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; import {EmojiService} from './common/services/emoji.service'; import {GradeService} from './common/services/grade.service'; import {HttpAuthenticationInterceptor} from './common/services/http-authentication.interceptor'; import {HttpErrorInterceptor} from './common/services/http-error.interceptor'; +import {SkeletonLoaderComponent} from './common/skeleton-loader/skeleton-loader.component'; import {StatusIconComponent} from './common/status-icon/status-icon.component'; import {SubmissionFilesDownloadComponent} from './common/submission-files-download/submission-files-download.component'; import {SuccessCloseComponent} from './common/success-close/success-close.component'; +import {MonacoThemeDirective} from './common/theme/monaco-theme.directive'; +import {ThemeSettingsComponent} from './common/theme/theme-settings.component'; +import {ThemeToggleComponent} from './common/theme/theme-toggle.component'; import {UnitCodeComponent} from './common/unit-code/unit-code.component'; import {UserBadgeComponent} from './common/user-badge/user-badge.component'; import {UserIconComponent} from './common/user-icon/user-icon.component'; import {PrivacyPolicy} from './config/privacy-policy/privacy-policy'; +import {CrossDashboardComponent} from './dashboard/f-cross-dashboard.component'; +import {DashboardListItemComponent} from './dashboard/list-item/dashboard-list-item.component'; +import {DashboardExpandedListItemComponent} from './dashboard/list-item/expanded-list-item/expanded-list-item.component'; +import {DemoToolsModule} from './demo/demo-tools.module'; import {TimeoutComponent} from './errors/states/timeout/timeout.component'; import {UnauthorisedComponent} from './errors/states/unauthorised/unauthorised.component'; import {UnavailableCardComponent} from './errors/unavailable-card/unavailable-card.component'; @@ -250,13 +271,17 @@ import {ProjectProgressDashboardComponent} from './projects/project-progress-das import {AddEngagementDialogComponent} from './projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/add-engagement-dialog/add-engagement-dialog.component'; import {EngagementDetailDialogComponent} from './projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/engagement-detail-dialog/engagement-detail-dialog.component'; import {EngagementPassportCardComponent} from './projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/engagement-passport-card.component'; +import {PeerProgressUnitSummaryComponent} from './projects/states/dashboard/directives/progress-dashboard/peer-progress-unit-summary/peer-progress-unit-summary.component'; import {ProgressDashboardComponent} from './projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component'; +import {DownloadFilterDialogComponent} from './projects/states/dashboard/directives/progress-dashboard/task-planner-card/download-filter-dialog/download-filter-dialog.component'; import {TaskPlannerCardComponent} from './projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component'; import {CreatePortfolioTaskListItemComponent} from './projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component'; import {TaskListItemComponent} from './projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component'; import {DiscussionPromptsViewComponent} from './projects/states/dashboard/directives/task-dashboard/directives/discussion-prompts-view/discussion-prompts-view.component'; +import {PreviousSubmissionsComponent} from './projects/states/dashboard/directives/task-dashboard/directives/previous-submissions/previous-submissions.component'; import {StaffNotesViewComponent} from './projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component'; import {TaskAssessmentCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.component'; +import {PpiWidgetComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component'; import {TaskDescriptionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component'; import {TaskDueCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component'; import {TaskIlosCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-ilos-card/task-ilos-card.component'; @@ -296,9 +321,11 @@ import {TutorialsComponent} from './projects/states/tutorials/tutorials.componen // import {PrivacyPolicy} from './config/privacy-policy/privacy-policy'; import {CheckForUpdateService} from './sessions/service-worker-updater/check-for-update.service'; import {SignInComponent} from './sessions/states/sign-in/sign-in.component'; +import {StudentOnboardingComponent} from './student-onboarding/student-onboarding.component'; import {FeedbackAppealModalComponent} from './tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component'; import {GradeTaskModalComponent} from './tasks/modals/grade-task-modal/grade-task-modal.component'; import {SubmissionTypeModalComponent} from './tasks/modals/submission-type-modal/submission-type-modal.component'; +import {TaskUploadRequirementsComponent} from './tasks/modals/upload-submission-modal/task-upload-requirements/task-upload-requirements.component'; import {UploadSubmissionModalComponent} from './tasks/modals/upload-submission-modal/upload-submission-modal.component'; import {ProjectTasksListComponent} from './tasks/project-tasks-list/project-tasks-list.component'; import {DiscussionPromptComposerComponent} from './tasks/task-comment-composer/discussion-prompt-composer/discussion-prompt-composer.component'; @@ -313,6 +340,7 @@ import {IntelligentDiscussionRecorderComponent} from './tasks/task-comments-view import {PdfImageCommentComponent} from './tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.component'; import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/scorm-comment.component'; import {ScormExtensionCommentComponent} from './tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component'; +import {SentAttachmentCardComponent} from './tasks/task-comments-viewer/sent-attachment-card/sent-attachment-card.component'; import {TaskAssessmentCommentComponent} from './tasks/task-comments-viewer/task-assessment-comment/task-assessment-comment.component'; import {TaskCommentsViewerComponent} from './tasks/task-comments-viewer/task-comments-viewer.component'; import {UnitStudentEnrolmentModalComponent} from './units/modals/unit-student-enrolment-modal/unit-student-enrolment-modal.component'; @@ -382,6 +410,9 @@ import {FUnitTaskListComponent} from './units/task-viewer/directives/unit-task-l import {TaskViewerStateComponent} from './units/task-viewer/task-viewer-state.component'; import {UnitRootStateComponent} from './units/unit-root-state.component'; import {ProgressBurndownChartComponent} from './visualisations/progress-burndown-chart/progress-burndown-chart.component'; +import {SummaryTaskStatusScatterComponent} from './visualisations/summary-task-status-scatter/summary-task-status-scatter.component'; +import {TargetGradePieChartComponent} from './visualisations/target-grade-pie-chart/target-grade-pie-chart.component'; +import {TaskCompletionBoxPlotComponent} from './visualisations/task-completion-box-plot/task-completion-box-plot.component'; import {TaskStatusPieChartComponent} from './visualisations/task-status-pie-chart/task-status-pie-chart.component'; import {TaskVisualisationComponent} from './visualisations/task-visualisation/task-visualisation.component'; import {WelcomeComponent} from './welcome/welcome.component'; @@ -452,12 +483,20 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { @NgModule({ // Components we declare declarations: [ + MonacoThemeDirective, AppComponent, + CrossDashboardComponent, + DashboardListItemComponent, + DashboardExpandedListItemComponent, TaskStatusPieChartComponent, + SummaryTaskStatusScatterComponent, + TargetGradePieChartComponent, + TaskCompletionBoxPlotComponent, AlertComponent, AddEngagementDialogComponent, EngagementPassportCardComponent, EngagementDetailDialogComponent, + PeerProgressUnitSummaryComponent, ProgressDashboardComponent, UnitStudentEnrolmentModalComponent, AboutDoubtfireModalContent, @@ -477,6 +516,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { IntelligentDiscussionRecorderComponent, ExtensionCommentComponent, PdfImageCommentComponent, + SentAttachmentCardComponent, CampusListComponent, ActivityTypeListComponent, OverseerImageListComponent, @@ -511,6 +551,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { TaskListItemComponent, CreatePortfolioTaskListItemComponent, TaskDescriptionCardComponent, + PpiWidgetComponent, StatusIconComponent, TaskCommentsViewerComponent, UserIconComponent, @@ -539,6 +580,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { HeaderComponent, UnitDropdownComponent, TaskDropdownComponent, + NotificationBellComponent, SplashScreenComponent, SubmissionFilesDownloadComponent, ProjectDashboardComponent, @@ -551,10 +593,13 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { SignInComponent, EditProfileFormComponent, EditProfileComponent, + VerifyAdditionalEmailComponent, + AdditionalNotificationEmailComponent, UserBadgeComponent, TaskStatusCardComponent, TaskDueCardComponent, FooterComponent, + PageContainerComponent, TaskAssessmentCardComponent, TaskSubmissionCardComponent, TaskDashboardComponent, @@ -566,6 +611,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { CreateNewUnitModalContentComponent, TiiActionLogComponent, FChipComponent, + ProjectCardComponent, UnitCodeComponent, NewTeachingPeriodDialogComponent, FileViewerComponent, @@ -588,6 +634,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { ProgressBurndownChartComponent, TaskVisualisationComponent, ScormPlayerComponent, + NotificationsPageComponent, ScormCommentComponent, TaskScormCardComponent, ScormExtensionCommentComponent, @@ -628,6 +675,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { TaskPlannerComponent, DownloadStaffNotesComponent, TaskPlannerCardComponent, + DownloadFilterDialogComponent, TaskPlannerPrerequisitesModalComponent, TaskOverseerReportComponent, SubmissionFilesModalComponent, @@ -655,6 +703,7 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { GroupSelectorComponent, GroupSetManagerComponent, FileUploaderComponent, + TaskUploadRequirementsComponent, PortfolioWelcomeStepComponent, PortfolioLearningSummaryReportStepComponent, PortfolioAddExtraFilesStepComponent, @@ -674,6 +723,8 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { UnitGroupSetEditorComponent, UnitTaskInboxStateComponent, LegacyRoutePlaceholderComponent, + NotificationSettingsComponent, + ThemeSettingsComponent, ], providers: [ // Services we provide @@ -784,9 +835,17 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { }, ], imports: [ - FlexLayoutModule, + PreviousSubmissionsComponent, + PanelLayoutComponent, + PanelComponent, + PanelCollapseButtonComponent, + PanelFullscreenButtonComponent, + ThemeToggleComponent, + AnimatedCheckComponent, + CelebrationParticlesComponent, BrowserModule, BrowserAnimationsModule, + DemoToolsModule, RouterModule.forRoot(routes, { initialNavigation: 'enabledNonBlocking', }), @@ -795,6 +854,12 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { ClipboardModule, DragDropModule, ScrollingModule, + StudentOnboardingComponent, + // Seven templates ask a textarea to grow with its content, and set a minimum + // of three rows while they are at it. Without this the directive is an inert + // attribute, so every one of them rendered at the browser default of two rows + // and never grew. + TextFieldModule, MatToolbarModule, MatSidenavModule, MatFormFieldModule, @@ -837,7 +902,14 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { PdfViewerModule, LottieComponent, ServiceWorkerModule.register('ngsw-worker.js', { - enabled: environment.production, + // Push notifications need the worker running in development too, so this + // is a flag rather than just `environment.production`. See + // docs/service-worker.md for how to turn it off and how to clear a stuck + // worker. + enabled: environment.production || environment.enableServiceWorker, + + // Registration happens six seconds after bootstrap, not at bootstrap. + // Anything that asks for the worker during app init finds nothing there. registrationStrategy: () => interval(6000).pipe(take(1)), }), CalendarModule.forRoot({provide: CalendarDateAdapter, useFactory: adapterFactory}), @@ -846,6 +918,10 @@ const DEFAULT_TOOLTIP_OPTIONS: MatTooltipDefaultOptions = { MonacoEditorModule.forRoot(), MatChipListbox, NgxSkeletonLoaderModule, + EmptyStateComponent, + SkeletonLoaderComponent, + PwaInstallButtonComponent, + PwaConnectionStatusComponent, ], bootstrap: [AppComponent], }) @@ -858,6 +934,7 @@ export class DoubtfireAngularModule { private constants: DoubtfireConstants, private title: Title, private updater: CheckForUpdateService, + private installer: PwaInstallService, private matIconRegistry: MatIconRegistry, private domSanitizer: DomSanitizer, ) { diff --git a/src/app/errors/states/timeout/timeout.component.html b/src/app/errors/states/timeout/timeout.component.html index 746ef32088..8331fdeaa9 100644 --- a/src/app/errors/states/timeout/timeout.component.html +++ b/src/app/errors/states/timeout/timeout.component.html @@ -1,5 +1,9 @@ -
      - schedule -

      Authentication Timeout

      -

      Your session has expired and you need to log back in to proceed.

      +
      + schedule +

      Authentication Timeout

      +

      + Your session has expired and you need to log back in to proceed. +

      diff --git a/src/app/errors/states/timeout/timeout.component.spec.ts b/src/app/errors/states/timeout/timeout.component.spec.ts new file mode 100644 index 0000000000..d348e8b049 --- /dev/null +++ b/src/app/errors/states/timeout/timeout.component.spec.ts @@ -0,0 +1,26 @@ +import {describe, expect, it, vi} from 'vitest'; +import {TimeoutComponent} from './timeout.component'; + +describe('TimeoutComponent', () => { + it('signs in again immediately when the user asks to', () => { + const auth = {signOut: vi.fn()}; + const component = new TimeoutComponent(auth as never); + + component.signInAgain(); + + expect(auth.signOut).toHaveBeenCalledWith(false); + }); + + it('does not sign the user out twice after they act', () => { + vi.useFakeTimers(); + const auth = {signOut: vi.fn()}; + const component = new TimeoutComponent(auth as never); + + component.ngOnInit(); + component.signInAgain(); + vi.runAllTimers(); + + expect(auth.signOut).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/src/app/errors/states/timeout/timeout.component.ts b/src/app/errors/states/timeout/timeout.component.ts index b9b4094231..f006b4463f 100644 --- a/src/app/errors/states/timeout/timeout.component.ts +++ b/src/app/errors/states/timeout/timeout.component.ts @@ -18,6 +18,14 @@ export class TimeoutComponent implements OnInit, OnDestroy { }, 5000); } + signInAgain(): void { + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle); + this.timeoutHandle = null; + } + this.authenticationService.signOut(false); + } + ngOnDestroy(): void { if (this.timeoutHandle) { clearTimeout(this.timeoutHandle); diff --git a/src/app/eula/accept-eula/accept-eula.component.spec.ts b/src/app/eula/accept-eula/accept-eula.component.spec.ts index 9912d785e5..90b765c3fa 100644 --- a/src/app/eula/accept-eula/accept-eula.component.spec.ts +++ b/src/app/eula/accept-eula/accept-eula.component.spec.ts @@ -1,33 +1,56 @@ -import {beforeEach, describe, expect, it} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {Router} from '@angular/router'; -import {EMPTY} from 'rxjs'; +import {BehaviorSubject, Subscription, of} from 'rxjs'; import {UserService} from 'src/app/api/models/doubtfire-model'; +import {AuthenticationService} from 'src/app/api/services/authentication.service'; import {TiiService} from 'src/app/api/services/tii.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {AcceptEulaComponent} from './accept-eula.component'; -const constantsStub = { - ExternalName: EMPTY, - IsTiiEnabled: EMPTY, -}; const emptyProvider = {}; describe('AcceptEulaComponent', () => { let component: AcceptEulaComponent; let fixture: ComponentFixture; + let afterAuthCallback: ((authenticated: boolean) => void) | undefined; + let authenticationSubscription: Subscription; + let tiiEnabled: BehaviorSubject; + let tiiService: {getTiiEula: ReturnType}; + let router: {navigateByUrl: ReturnType}; beforeEach(async () => { + afterAuthCallback = undefined; + authenticationSubscription = new Subscription(); + tiiEnabled = new BehaviorSubject(false); + tiiService = {getTiiEula: vi.fn().mockReturnValue(of('

      EULA

      '))}; + router = {navigateByUrl: vi.fn()}; + await TestBed.configureTestingModule({ declarations: [AcceptEulaComponent], providers: [ - {provide: DoubtfireConstants, useValue: constantsStub}, - {provide: TiiService, useValue: emptyProvider}, + { + provide: DoubtfireConstants, + useValue: { + ExternalName: of('OnTrack'), + IsTiiEnabled: tiiEnabled, + }, + }, + { + provide: AuthenticationService, + useValue: { + afterAuthCall: vi.fn((callback: (authenticated: boolean) => void) => { + afterAuthCallback = callback; + return authenticationSubscription; + }), + }, + }, + {provide: TiiService, useValue: tiiService}, {provide: UserService, useValue: emptyProvider}, {provide: AlertService, useValue: emptyProvider}, - {provide: Router, useValue: emptyProvider}, + {provide: Router, useValue: router}, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -43,4 +66,34 @@ describe('AcceptEulaComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('does not treat the pre-authentication TurnItIn default as a disabled feature', () => { + expect(afterAuthCallback).toBeDefined(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(tiiService.getTiiEula).not.toHaveBeenCalled(); + }); + + it('loads the EULA after authenticated settings enable TurnItIn', () => { + tiiEnabled.next(true); + + afterAuthCallback?.(true); + + expect(tiiService.getTiiEula).toHaveBeenCalledTimes(1); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('redirects after authenticated settings disable TurnItIn', () => { + afterAuthCallback?.(true); + + expect(tiiService.getTiiEula).not.toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/home'); + }); + + it('stops waiting for authentication when the component is destroyed', () => { + const unsubscribe = vi.spyOn(authenticationSubscription, 'unsubscribe'); + + fixture.destroy(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/eula/accept-eula/accept-eula.component.ts b/src/app/eula/accept-eula/accept-eula.component.ts index a8c0ede0e0..cd59a8ee4e 100644 --- a/src/app/eula/accept-eula/accept-eula.component.ts +++ b/src/app/eula/accept-eula/accept-eula.component.ts @@ -1,7 +1,8 @@ -import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {ChangeDetectionStrategy, Component, OnDestroy} from '@angular/core'; import {Router} from '@angular/router'; -import {Observable, ReplaySubject, take} from 'rxjs'; +import {Observable, ReplaySubject, Subscription, take} from 'rxjs'; import {UserService} from 'src/app/api/models/doubtfire-model'; +import {AuthenticationService} from 'src/app/api/services/authentication.service'; import {TiiService} from 'src/app/api/services/tii.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; @@ -13,21 +14,27 @@ import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class AcceptEulaComponent { +export class AcceptEulaComponent implements OnDestroy { public toolName: Observable; public eulaHtml: string; public iframeDoc$: ReplaySubject = new ReplaySubject(1); + private authenticationSubscription: Subscription; + constructor( private constants: DoubtfireConstants, + private authenticationService: AuthenticationService, private tiiService: TiiService, private userService: UserService, private alertService: AlertService, private router: Router, ) { - this.constants.IsTiiEnabled.subscribe((enabled) => { - if (enabled) { + // Auth completion now includes the protected settings request. Do not react + // to IsTiiEnabled's pre-authentication false default: on a cold /eula load + // that used to redirect before a remembered session could be restored. + this.authenticationSubscription = this.authenticationService.afterAuthCall((authenticated) => { + if (authenticated && this.constants.IsTiiEnabled.value) { this.getEulaHtml(); } else { this.router.navigateByUrl('/home'); @@ -37,7 +44,11 @@ export class AcceptEulaComponent { this.toolName = constants.ExternalName; } - public getEulaHtml() { + public ngOnDestroy(): void { + this.authenticationSubscription.unsubscribe(); + } + + public getEulaHtml(): void { this.tiiService.getTiiEula().subscribe((eulaHtml) => { this.eulaHtml = eulaHtml; this.updateHtmlEulaInIFrame(); diff --git a/src/app/groups/group-set-manager/group-set-manager.component.html b/src/app/groups/group-set-manager/group-set-manager.component.html index 96f29aed21..dffc6a4f1d 100644 --- a/src/app/groups/group-set-manager/group-set-manager.component.html +++ b/src/app/groups/group-set-manager/group-set-manager.component.html @@ -1,60 +1,114 @@ -
      +
      - + (selectedGroupSetChange)="onGroupSetChange($event)" + > - @if (selectedGroup) { - - - Members of - @if (!editingGroupName) { - {{ selectedGroup?.name }} - @if (unitRole || selectedGroup.groupSet?.allowStudentsToManageGroups) { - } - } @else { - @if (unitRole || selectedGroup.groupSet?.allowStudentsToManageGroups) { - - - - - - } - } - - @if (selectedGroup.locked) { - lock +
      + } @else { +
      + + Group name + + + + +
      } - + +

      + + {{ selectedGroup.memberCount }} + {{ selectedGroup.memberCount === 1 ? 'member' : 'members' }} + + @if (selectedGroup.tutorial) { + + Tutorial {{ selectedGroup.tutorial.abbreviation }} + } + @if (selectedGroup.locked) { + + + + Locked + + } +

      + - + > + @if (unitRole) { - - +
      + + Add a student + - @for (project of filteredProjects | async; track project) { - {{ project.student.name }} + @for (candidate of memberCandidates; track candidate.id) { + + {{ candidate.student?.name }} + @if (candidate.student?.username) { + · {{ candidate.student.username }} + } + } - +
      } - - } + } @else { + + } +
      diff --git a/src/app/groups/group-set-manager/group-set-manager.component.spec.ts b/src/app/groups/group-set-manager/group-set-manager.component.spec.ts new file mode 100644 index 0000000000..67c9547561 --- /dev/null +++ b/src/app/groups/group-set-manager/group-set-manager.component.spec.ts @@ -0,0 +1,263 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA, SimpleChange} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {Observable, Subject, of} from 'rxjs'; +import {Group} from 'src/app/api/models/groups/group'; +import {GroupSet} from 'src/app/api/models/groups/group-set'; +import {Project} from 'src/app/api/models/project'; +import {Unit} from 'src/app/api/models/unit'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {User} from 'src/app/api/models/user/user'; +import {GroupService} from 'src/app/api/services/group.service'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {GroupSetManagerComponent} from './group-set-manager.component'; + +function student(id: number, name: string, username: string): Project { + const project = new Project(); + project.id = id; + const user = new User(); + user.firstName = name; + user.lastName = ''; + user.username = username; + project.student = user; + return project; +} + +function makeGroup(set: GroupSet, id: number, name: string): Group { + const group = new Group(set.unit); + group.id = id; + group.name = name; + group.groupSet = set; + set.groupsCache.add(group); + return group; +} + +describe('GroupSetManagerComponent', () => { + let update: ReturnType; + let loadStudents: ReturnType Observable>>; + let unit: Unit; + let set: GroupSet; + let ana: Project; + let bo: Project; + + beforeEach(async () => { + update = vi.fn(); + loadStudents = vi.fn<(unit: Unit) => Observable>(() => of([])); + + ana = student(1, 'Ana', 'aamos'); + bo = student(2, 'Bo', 'bbarnes'); + unit = { + id: 1, + studentsForGroupTypeAhead: (group: Group) => + [ana, bo].filter((project) => !group.projectsCache.has(project.id)), + } as unknown as Unit; + set = new GroupSet(unit); + set.id = 100; + + await TestBed.configureTestingModule({ + declarations: [GroupSetManagerComponent], + providers: [ + {provide: GroupService, useValue: {update}}, + {provide: ProjectService, useValue: {loadStudents}}, + {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(GroupSetManagerComponent, {set: {template: ''}}) + .compileComponents(); + }); + + function create(unitRole?: UnitRole): GroupSetManagerComponent { + const component = TestBed.createComponent(GroupSetManagerComponent).componentInstance; + component.unit = unit; + component.selectedGroupSet = set; + component.unitRole = unitRole; + component.ngOnInit(); + return component; + } + + it('keeps a saved rename when another group is opened', () => { + const first = makeGroup(set, 1, 'Old name'); + const second = makeGroup(set, 2, 'Second'); + update.mockReturnValue(of(first)); + + const component = create({} as UnitRole); + component.newGroupSelected(first); + component.startEditingGroupName(); + first.name = 'New name'; + component.updateGroup(); + + component.newGroupSelected(second); + + expect(first.name).toBe('New name'); + }); + + it('throws away a rename that was never saved', () => { + const first = makeGroup(set, 1, 'Old name'); + const second = makeGroup(set, 2, 'Second'); + + const component = create({} as UnitRole); + component.newGroupSelected(first); + component.startEditingGroupName(); + first.name = 'Half typed'; + + component.newGroupSelected(second); + + expect(first.name).toBe('Old name'); + expect(component.editingGroupName).toBe(false); + }); + + it('puts the old name back on the renamed group when the save fails late', () => { + const first = makeGroup(set, 1, 'Old name'); + const second = makeGroup(set, 2, 'Second'); + const response: Subject = new Subject(); + update.mockReturnValue(response); + + const component = create({} as UnitRole); + component.newGroupSelected(first); + component.startEditingGroupName(); + first.name = 'New name'; + component.updateGroup(); + + component.newGroupSelected(second); + response.error('Name taken'); + + expect(first.name).toBe('Old name'); + expect(second.name).toBe('Second'); + }); + + it('does not save a blank name', () => { + const first = makeGroup(set, 1, 'Old name'); + + const component = create({} as UnitRole); + component.newGroupSelected(first); + component.startEditingGroupName(); + first.name = ' '; + component.updateGroup(); + + expect(update).not.toHaveBeenCalled(); + expect(component.editingGroupName).toBe(true); + }); + + it('finds students to add by username as well as by name, leaving out members', () => { + const group = makeGroup(set, 1, 'Team'); + group.projectsCache.add(ana); + + const component = create({} as UnitRole); + component.newGroupSelected(group); + + component.control.setValue('bbar'); + expect(component.memberCandidates).toEqual([bo]); + + component.control.setValue('ana'); + expect(component.memberCandidates).toEqual([]); + }); + + it('closes the open group when another group set is picked', () => { + const group = makeGroup(set, 1, 'Team'); + + const component = create({} as UnitRole); + component.newGroupSelected(group); + component.onGroupSetChange(); + + expect(component.selectedGroup).toBeNull(); + }); + + it('opens the student group for the newly selected group set', () => { + const first = makeGroup(set, 1, 'First set group'); + const secondSet = new GroupSet(unit); + secondSet.id = 200; + const second = makeGroup(secondSet, 2, 'Second set group'); + ana.groupCache.add(first); + ana.groupCache.add(second); + const component = create(); + component.project = ana; + component.ngOnChanges({project: new SimpleChange(undefined, ana, true)}); + expect(component.selectedGroup).toBe(first); + + component.onGroupSetChange(secondSet); + + expect(component.selectedGroupSet).toBe(secondSet); + expect(component.selectedGroup).toBe(second); + }); + + it('closes the open group when the page moves to another unit', () => { + const group = makeGroup(set, 1, 'Team'); + + const component = create({} as UnitRole); + component.newGroupSelected(group); + + const otherUnit = {id: 2} as Unit; + component.unit = otherUnit; + component.ngOnChanges({unit: new SimpleChange(unit, otherUnit, false)}); + + expect(component.selectedGroup).toBeNull(); + expect(loadStudents).toHaveBeenLastCalledWith(otherUnit); + }); + + it('fetches the unit students for staff, so the add list is complete', () => { + create({} as UnitRole); + expect(loadStudents).toHaveBeenCalledWith(unit); + }); + + it('does not ask for the student list on a student page', () => { + create(undefined); + expect(loadStudents).not.toHaveBeenCalled(); + }); + + it('hands the selector the same select handler every time', () => { + const component = create({} as UnitRole); + expect(component.groupSelectHandler).toBe(component.groupSelectHandler); + }); +}); + +describe('GroupSetManagerComponent group-name updates', () => { + let component: GroupSetManagerComponent; + let updateResult: Subject; + let originalGroup: Group; + let nextGroup: Group; + + beforeEach(() => { + updateResult = new Subject(); + component = new GroupSetManagerComponent( + {update: vi.fn(() => updateResult)} as never, + {success: vi.fn(), error: vi.fn()} as never, + {} as never, + ); + component.unit = {id: 1, studentsForGroupTypeAhead: () => []} as never; + originalGroup = { + id: 10, + name: 'Original name', + groupSet: {id: 20}, + projects: [], + } as never; + nextGroup = { + id: 11, + name: 'Next group', + groupSet: {id: 20}, + projects: [], + } as never; + component.selectedGroup = originalGroup; + component.startEditingGroupName(); + originalGroup.name = 'Saved name'; + }); + + it('keeps the submitted name when another group is selected before the request resolves', () => { + component.updateGroup(); + + component.newGroupSelected(nextGroup); + updateResult.next(originalGroup); + + expect(originalGroup.name).toBe('Saved name'); + }); + + it('restores the previous name if the pending update fails after selection changes', () => { + component.updateGroup(); + component.newGroupSelected(nextGroup); + + updateResult.error('update failed'); + + expect(originalGroup.name).toBe('Original name'); + }); +}); diff --git a/src/app/groups/group-set-manager/group-set-manager.component.ts b/src/app/groups/group-set-manager/group-set-manager.component.ts index 5d986592e6..fe4dbbc340 100644 --- a/src/app/groups/group-set-manager/group-set-manager.component.ts +++ b/src/app/groups/group-set-manager/group-set-manager.component.ts @@ -1,10 +1,28 @@ -import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, +} from '@angular/core'; import {FormControl} from '@angular/forms'; -import {Observable, map, startWith} from 'rxjs'; -import {Group, GroupSet, Project, Unit, UnitRole} from 'src/app/api/models/doubtfire-model'; +import {Subscription, first} from 'rxjs'; +import { + Group, + GroupSet, + Project, + ProjectService, + Unit, + UnitRole, +} from 'src/app/api/models/doubtfire-model'; import {GroupService} from 'src/app/api/services/group.service'; import {AlertService} from 'src/app/common/services/alert.service'; +// The add-a-student list shows this many matches at most; typing narrows it down. +const MAX_CANDIDATES = 50; + @Component({ selector: 'f-group-set-manager', templateUrl: './group-set-manager.component.html', @@ -12,7 +30,7 @@ import {AlertService} from 'src/app/common/services/alert.service'; changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class GroupSetManagerComponent implements OnInit { +export class GroupSetManagerComponent implements OnInit, OnChanges, OnDestroy { @Input() project: Project; @Input() unit: Unit; @Input() selectedGroupSet: GroupSet; @@ -23,54 +41,110 @@ export class GroupSetManagerComponent implements OnInit { editingGroupName = false; - control = new FormControl(''); - projects: Project[] = []; - filteredProjects: Observable; + control: FormControl = new FormControl(''); + + // One function for the life of the component. A getter handed the selector a new + // function on every check, which counted as a changed input each time. + readonly groupSelectHandler = (group: Group) => this.newGroupSelected(group); + + private originalGroupName: string; + private studentsSub?: Subscription; constructor( private groupService: GroupService, private alertService: AlertService, + private projectService: ProjectService, ) {} ngOnInit(): void { - this.filteredProjects = this.control.valueChanges.pipe( - startWith(''), - map((value) => this._filter(value)), - ); + this.loadStudentsForStaff(); + this.selectCurrentProjectGroup(); } - get groupSelectHandler() { - return (group: Group) => this.newGroupSelected(group); + ngOnChanges(changes: SimpleChanges): void { + const unitChanged = changes['unit'] && !changes['unit'].firstChange; + const setChanged = changes['selectedGroupSet'] && !changes['selectedGroupSet'].firstChange; + + // A group from another unit or another set must not stay open beside the new list. + if ( + this.selectedGroup && + (unitChanged || (setChanged && this.selectedGroup.groupSet !== this.selectedGroupSet)) + ) { + this.newGroupSelected(null); + } + + if (unitChanged || (changes['unitRole'] && !changes['unitRole'].firstChange)) { + this.loadStudentsForStaff(); + } + if (changes['project'] || changes['selectedGroupSet']) { + this.selectCurrentProjectGroup(); + } } - displayFn(project: Project): string { - return project && project.student.name ? project.student.name : ''; + ngOnDestroy(): void { + this.studentsSub?.unsubscribe(); } - newGroupSelected(group: Group) { - if (this.selectedGroup) { - this.selectedGroup.name = this.originalGroupName; + public get canRenameGroup(): boolean { + return ( + !!this.selectedGroup && + (!!this.unitRole || !!this.selectedGroup.groupSet?.allowStudentsToManageGroups) + ); + } + + /** + * Students in the unit who can join the open group and match what has been typed. + * Worked out from the current cache each time, so students who load after the + * group was opened, and members added or removed since, are reflected. + */ + public get memberCandidates(): Project[] { + const group = this.selectedGroup; + if (!group || !this.unit) { + return []; } - this.editingGroupName = false; - this.selectedGroup = group; - const students = this.unit.studentsForGroupTypeAhead(group) || []; - this.projects = students.filter((project) => !group.projects.find((p) => project.id === p.id)); + // The unit's filter reads the group's tutorial when groups stay in one class. + if (group.groupSet?.keepGroupsInSameClass && !group.tutorial) { + return []; + } - this.originalGroupName = group.name; + const value = this.control.value; + const text = typeof value === 'string' ? value.trim().toLowerCase() : ''; + + return this.unit + .studentsForGroupTypeAhead(group) + .filter( + (project) => + !text || + project.student?.name?.toLowerCase().includes(text) || + project.student?.username?.toLowerCase().includes(text), + ) + .slice(0, MAX_CANDIDATES); } - private _filter(value: string | Project): Project[] { - if (typeof value !== 'string') { - return; + displayFn(project: Project): string { + return project && project.student?.name ? project.student.name : ''; + } + + onGroupSetChange(groupSet?: GroupSet): void { + if (groupSet) { + this.selectedGroupSet = groupSet; } + this.newGroupSelected(null); + this.selectCurrentProjectGroup(); + } - const filterValue = value.toLowerCase(); - return this.projects.filter( - (project) => - project.student.name.toLowerCase().includes(filterValue.toLowerCase()) && // Find by name - !this.selectedGroup.projects.find((p) => project.id === p.id), // Not already assigned to the group - ); + newGroupSelected(group: Group | null) { + // Throw away a rename that was never saved. A saved one is kept: this used to put + // the old name back on screen as soon as another group was opened. + if (this.selectedGroup && this.editingGroupName) { + this.selectedGroup.name = this.originalGroupName; + } + + this.editingGroupName = false; + this.selectedGroup = group; + this.originalGroupName = group?.name; + this.control.setValue(''); } addMember(project: Project) { @@ -78,28 +152,36 @@ export class GroupSetManagerComponent implements OnInit { this.control.setValue(''); } - private originalGroupName: string; startEditingGroupName() { this.originalGroupName = this.selectedGroup.name; this.editingGroupName = true; } - stopEditinGroupName() { + stopEditingGroupName() { this.selectedGroup.name = this.originalGroupName; this.editingGroupName = false; } updateGroup() { + const group = this.selectedGroup; + const previousName = this.originalGroupName; + + if (!group?.name?.trim()) { + return; + } + this.editingGroupName = false; + this.originalGroupName = group.name; + this.groupService .update( { unitId: this.unit.id, - groupSetId: this.selectedGroup.groupSet.id, - id: this.selectedGroup.id, + groupSetId: group.groupSet.id, + id: group.id, }, { - entity: this.selectedGroup, + entity: group, }, ) .subscribe({ @@ -107,9 +189,44 @@ export class GroupSetManagerComponent implements OnInit { this.alertService.success('Successfully updated group', 3000); }, error: (error) => { - this.selectedGroup.name = this.originalGroupName; - this.alertService.error(`Failed to update gorup: ${error}`, 6000); + // Put the name back on the group that was renamed, even if another group + // has been opened since. + group.name = previousName; + if (group === this.selectedGroup) { + this.originalGroupName = previousName; + } + this.alertService.error(`Failed to update group: ${error}`, 6000); + }, + }); + } + + /** + * The add-a-student list comes from the unit's students. Opening this page straight + * from a link or a refresh left that list empty, because only the student list page + * fetched them. Staff fetch them here; the request is shared with that page's cache. + */ + private loadStudentsForStaff(): void { + if (!this.unitRole || !this.unit) { + return; + } + + this.studentsSub?.unsubscribe(); + this.studentsSub = this.projectService + .loadStudents(this.unit) + .pipe(first()) + .subscribe({ + error: () => { + this.alertService.error( + 'Students could not be loaded, so the list of students to add may be incomplete.', + 6000, + ); }, }); } + private selectCurrentProjectGroup(): void { + const currentGroup = this.project?.groupForGroupSet(this.selectedGroupSet); + if (currentGroup && currentGroup.id !== this.selectedGroup?.id) { + this.newGroupSelected(currentGroup); + } + } } diff --git a/src/app/home/states/home/home.component.html b/src/app/home/states/home/home.component.html index c74197b4dd..ba3617ae5f 100644 --- a/src/app/home/states/home/home.component.html +++ b/src/app/home/states/home/home.component.html @@ -1,146 +1,318 @@ -
      -
      -

      You are not enrolled in {{ externalName.value }}.

      -

      Contact your unit convenor or tutor to enrol you in a subject.

      -
      + + + + + + Unit Hub + + Your unit announcements, HelpHubs and upcoming classes, together. + + + + - @if (!notEnrolled && projects?.length === 0 && unitRoles?.length === 0) { -
      -

      You are not enrolled in any {{ externalName.value }} units.

      -

      Contact your unit convenor or tutor to enrol you in a subject.

      -
      - } -
      -

      Units you teach

      -
      - + @if (loading) { +
      + Loading your units... +
      -
      - @for (unitRole of unitRoles | isActiveUnitRole; track unitRole) { -
      - @if (!unitRole.unit.teachingPeriod || unitRole.unit.teachingPeriod?.active) { -
      - - - {{ unitRole.unit?.name }} - {{ unitRole.unit?.code }} - - - - - - - {{ unitRole.unit.teachingPeriod?.name || showDate(unitRole.unit.startDate) }} - - - {{ unitRole.role }} - - - + + @if (currentUser.role === 'Student') { + See your previous units + } + + } @else { + @if (isStaff) { + @let summary = staffSummary; +
      +
      +

      + Units you teach +

      +

      + {{ summary.activeLabel }} + @if (summary.previousCount > 0) { + + {{ summary.previousCount }} previous + } + @if (summary.unreadNotes > 0) { + + + + {{ unreadNotesLabel(summary.unreadNotes) }} + + } +

      +
      + + + All units you teach + +
      + + @if (staffUnits.length > 0) { +
        + @for (card of staffUnits; track card.unitRole.id) { +
      • +
        + +
        +
        +

        + + {{ card.code }} + +

        + + {{ card.role }} + +
        +

        + {{ card.name }} +

        +
        + +
        +

        {{ card.periodLabel }}

        +
        +
        + {{ card.progressLabel }} + {{ card.progress }}% through +
        +
        + +
        +
        + @if (card.unitRole.tutorNoteCount > 0) { +

        + + {{ unreadNotesLabel(card.unitRole.tutorNoteCount) }} +

        + } +
        + +
        - - - -
      + + + Open inbox for {{ card.code }} + + + Students in {{ card.code }} + + + + } -
      + + } @else { +
      + + All units you teach +
      } - - -

      You do not teach any active units

      -
      0) { + +
      +
      + @if (isStaff) { +

      + Units you study +

      + } @else { +

      + My units +

      + } +

      {{ studySummary }}

      +
      + +
      - - - -
      -
      -
      - @if (unitRoles.length && projects?.length) { - - } -
      -

      Enrolled units

      -
      - -
      + @for (card of studyUnits; track card.projectId) { +
    • +
      +
      +
      + +

      + + {{ card.code }} + +

      + @if (card.target) { + + Target {{ card.target }} + + } +
      +

      + {{ card.name }} +

      +
      -
      - @for (project of projects; track project) { -
      - @if (project.unit.isActive) { -
      - - - {{ project.unit.name }} - {{ project.unit.code }} - - - - - - - {{ project.unit.teachingPeriod?.name || showDate(project.unit.startDate) }} - - - +

      {{ card.periodLabel }}

      +
      +
      + {{ card.progressLabel }} + {{ card.progress }}% through +
      +
      + +
      +
      +
      + +
      - } -
      - } - - - -
    • -
      -
      -
      + + Open tasks for {{ card.code }} + + + Task Planner for {{ card.code }} + + + + + } + + } @else { +
      + + Previous units +
      + } + } + } + diff --git a/src/app/home/states/home/home.component.spec.ts b/src/app/home/states/home/home.component.spec.ts new file mode 100644 index 0000000000..d81304c6ad --- /dev/null +++ b/src/app/home/states/home/home.component.spec.ts @@ -0,0 +1,262 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +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 {Router, RouterModule, provideRouter} from '@angular/router'; +import {BehaviorSubject} from 'rxjs'; +import {Project, Unit, UnitRole, UserService} from 'src/app/api/models/doubtfire-model'; +import {EmptyStateComponent} from 'src/app/common/empty-state/empty-state.component'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {HomeComponent} from './home.component'; + +function projectIn(myRole: string, id: number): Project { + return {id, unit: {myRole}} as unknown as Project; +} + +function unitRole( + id: number, + role: string, + unit: Partial & {id: number; code: string}, + tutorNoteCount = 0, +): UnitRole { + const result = new UnitRole(); + const model = Object.assign(new Unit(), { + name: `${unit.code} unit`, + active: true, + startDate: new Date(2026, 6, 6), + endDate: new Date(2026, 9, 30), + ...unit, + }); + Object.assign(result, {id, role, unit: model, tutorNoteCount}); + return result; +} + +describe('HomeComponent', () => { + let fixture: ComponentFixture; + let component: HomeComponent; + let loading$: BehaviorSubject; + let unitRoles$: BehaviorSubject; + let projects$: BehaviorSubject; + let currentUser: {role: string}; + + beforeEach(async () => { + // Only the clock is faked, so the teaching week and progress below are stable. + vi.useFakeTimers({toFake: ['Date'], now: new Date(2026, 7, 20, 10)}); + + loading$ = new BehaviorSubject(false); + unitRoles$ = new BehaviorSubject([]); + projects$ = new BehaviorSubject([]); + currentUser = {role: 'Tutor'}; + + await TestBed.configureTestingModule({ + declarations: [HomeComponent], + imports: [EmptyStateComponent, MatButtonModule, MatIconModule, RouterModule], + providers: [ + provideRouter([]), + {provide: DoubtfireConstants, useValue: {ExternalName: {value: 'OnTrack'}}}, + { + provide: GlobalStateService, + useValue: { + showHeader: vi.fn(), + setView: vi.fn(), + isLoadingSubject: loading$, + unitRolesSubject: unitRoles$, + projectsSubject: projects$, + }, + }, + {provide: UserService, useValue: {currentUser}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + vi.spyOn(TestBed.inject(Router), 'navigateByUrl').mockResolvedValue(true); + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function page(): HTMLElement { + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + // A sign out, or a project that is still being mapped, left a project with no unit in + // the cache, and the unguarded filter threw "reading 'myRole'" out of the subscription. + it('skips a project that has no unit instead of throwing', () => { + const studying = projectIn('Student', 1); + + expect(() => + projects$.next([{id: 2} as Project, studying, projectIn('Tutor', 3), null]), + ).not.toThrow(); + + expect(component.projects).toEqual([studying]); + }); + + it('shows a loading state, not the empty state, until the units have loaded', () => { + loading$.next(true); + + const loadingPage = page(); + expect(loadingPage.querySelector('[role="status"]')?.textContent).toContain( + 'Loading your units', + ); + expect(loadingPage.textContent).not.toContain('You are not enrolled'); + + loading$.next(false); + expect(page().textContent).toContain('You are not enrolled in any OnTrack units'); + }); + + it('gives each active unit a card with its colour band, role, teaching week and inbox link', () => { + unitRoles$.next([ + unitRole(11, 'Convenor', {id: 1, code: 'SIT374'}, 2), + unitRole(12, 'Tutor', {id: 2, code: 'SIT111', active: false}), + unitRole(13, 'Tutor', {id: 3, code: 'SIT222'}), + ]); + + const cards = page().querySelectorAll('ul[aria-label="Active units you teach"] > li'); + expect(cards.length).toBe(2); + + const first = cards[0] as HTMLElement; + const band = first.querySelector('section') as HTMLElement; + expect(band.style.getPropertyValue('--unit-accent')).toBe('var(--ot-unit-1)'); + expect( + (cards[1].querySelector('section') as HTMLElement).style.getPropertyValue('--unit-accent'), + ).toBe('var(--ot-unit-2)'); + + expect(first.querySelector('h2 a')?.getAttribute('href')).toBe('/units/1/tasks/inbox'); + expect(first.textContent).toContain('Convenor'); + expect(first.textContent).toContain('Week 7'); + expect(first.textContent).toContain('2 unread moderation notes'); + + const bar = first.querySelector('[role="progressbar"]'); + expect(bar?.getAttribute('aria-valuenow')).toBe('39'); + expect(bar?.getAttribute('aria-valuetext')).toBe('Week 7, 39% through the teaching period'); + + const inbox = Array.from(first.querySelectorAll('footer a')).find((link) => + link.textContent.includes('Open inbox'), + ); + expect(inbox?.getAttribute('href')).toBe('/units/1/tasks/inbox'); + expect(inbox?.textContent).toContain('for SIT374'); + }); + + it('sums the staff summary across the units you teach', () => { + unitRoles$.next([ + unitRole(11, 'Convenor', {id: 1, code: 'SIT374'}, 2), + unitRole(12, 'Tutor', {id: 2, code: 'SIT111', active: false}), + unitRole(13, 'Tutor', {id: 3, code: 'SIT222'}, 1), + ]); + + const header = page().querySelector('header'); + expect(header?.querySelector('h1')?.textContent).toContain('Units you teach'); + expect(header?.textContent).toContain('2 active units'); + expect(header?.textContent).toContain('1 previous'); + expect(header?.textContent).toContain('3 unread moderation notes'); + expect(header?.querySelector('a')?.getAttribute('href')).toBe('/view-all-units'); + }); + + it('labels a unit that has not started yet with its start date', () => { + unitRoles$.next([ + unitRole(11, 'Tutor', { + id: 1, + code: 'SIT374', + startDate: new Date(2026, 8, 1), + endDate: new Date(2026, 11, 1), + }), + ]); + + const card = page().querySelector('ul[aria-label="Active units you teach"] > li'); + expect(card?.textContent).toContain('Starts 1 Sep'); + expect(card?.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow')).toBe('0'); + }); + + it('points a tutor with no active units at the full list', () => { + unitRoles$.next([unitRole(12, 'Tutor', {id: 2, code: 'SIT111', active: false})]); + + const content = page(); + expect(content.querySelector('ul[aria-label="Active units you teach"]')).toBeNull(); + expect(content.textContent).toContain('You are not teaching any active units'); + expect(content.querySelector('section a')?.getAttribute('href')).toBe('/view-all-units'); + }); + + it('gives a student the same unit cards, linked to their tasks and planner', () => { + currentUser.role = 'Student'; + projects$.next([ + Object.assign(projectIn('Student', 1), { + unit: Object.assign(new Unit(), { + myRole: 'Student', + id: 1, + code: 'SIT374', + name: 'Capstone', + active: true, + startDate: new Date(2026, 6, 6), + endDate: new Date(2026, 9, 30), + }), + }), + ]); + + const content = page(); + expect(content.querySelector('h1')?.textContent).toContain('My units'); + expect(content.textContent).not.toContain('Units you teach'); + + const cards = content.querySelectorAll('ul[aria-label="Active units you study"] > li'); + expect(cards).toHaveLength(1); + expect(cards[0].textContent).toContain('SIT374'); + expect(cards[0].textContent).toContain('Capstone'); + expect(cards[0].querySelector('h3 a')?.getAttribute('href')).toBe('/projects/1/dashboard'); + // Straight under the page's h1, so the card title reads as a level 2 heading. + expect(cards[0].querySelector('h3')?.getAttribute('aria-level')).toBe('2'); + expect( + cards[0].querySelector('[role="progressbar"]')?.getAttribute('aria-valuetext'), + ).toContain('Week'); + const links = Array.from(cards[0].querySelectorAll('footer a')).map((link) => + link.getAttribute('href'), + ); + expect(links).toEqual(['/projects/1/dashboard', '/projects/1/plan']); + }); + + // The unit caches start out empty, so the message has to wait for the first load to + // finish before it can say the user has no units. + describe('not enrolled message', () => { + function notEnrolledShown(): boolean { + return page().textContent.includes('You are not enrolled'); + } + + it('stays hidden while units and projects are still loading', () => { + loading$.next(true); + + expect(component.hasNoUnits).toBe(false); + expect(notEnrolledShown()).toBe(false); + }); + + it('shows once loading finishes with no projects and no unit roles', () => { + loading$.next(true); + loading$.next(false); + + expect(component.hasNoUnits).toBe(true); + expect(notEnrolledShown()).toBe(true); + }); + + it('stays hidden for a student with a project', () => { + currentUser.role = 'Student'; + loading$.next(true); + projects$.next([projectIn('Student', 1)]); + loading$.next(false); + + expect(component.hasNoUnits).toBe(false); + }); + + it('stays hidden for staff with a unit role', () => { + loading$.next(true); + unitRoles$.next([unitRole(11, 'Tutor', {id: 1, code: 'SIT374'})]); + loading$.next(false); + + expect(component.hasNoUnits).toBe(false); + expect(notEnrolledShown()).toBe(false); + }); + }); +}); diff --git a/src/app/home/states/home/home.component.ts b/src/app/home/states/home/home.component.ts index 5fa8f2e9f8..299ab786fb 100644 --- a/src/app/home/states/home/home.component.ts +++ b/src/app/home/states/home/home.component.ts @@ -1,11 +1,56 @@ -import {ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit} from '@angular/core'; +import {formatDate} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + Inject, + LOCALE_ID, + OnInit, + inject, +} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {Router} from '@angular/router'; -import {Subscription} from 'rxjs'; -import {Project, UnitRole, User, UserService} from 'src/app/api/models/doubtfire-model'; +import {Project, Unit, UnitRole, User, UserService} from 'src/app/api/models/doubtfire-model'; import {DateService} from 'src/app/common/services/date.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; +// The six unit accents from the theme tokens. Cards take them in order so that +// neighbouring cards never share a colour, the same way the student My units page does. +const UNIT_ACCENT_COUNT = 6; + +export interface StaffUnitCard { + unitRole: UnitRole; + unitId: number; + code: string; + name: string; + role: string; + periodLabel: string; + accent: string; + progress: number; + progressLabel: string; + progressValueText: string; +} + +// A unit the user studies, drawn the same way as a unit they teach. +export interface StudyUnitCard { + projectId: number; + code: string; + name: string; + target: string | null; + periodLabel: string; + accent: string; + progress: number; + progressLabel: string; + progressValueText: string; +} + +export interface StaffSummary { + activeLabel: string; + previousCount: number; + unreadNotes: number; +} + @Component({ selector: 'home', templateUrl: 'home.component.html', @@ -13,16 +58,16 @@ import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class HomeComponent implements OnInit, OnDestroy { - projects: Project[]; - unitRoles: UnitRole[]; - showSpinner: boolean; - dataLoaded: boolean; - notEnrolled: boolean; - ifAdmin: boolean; - ifConvenor: boolean; - loadingUnitRoles: boolean; - loadingProjects: boolean; +export class HomeComponent implements OnInit { + projects: Project[] = []; + unitRoles: UnitRole[] = []; + staffUnits: StaffUnitCard[] = []; + studyUnits: StudyUnitCard[] = []; + + /** True until the global state has finished loading the user's units and projects. */ + loading = true; + + private readonly destroyRef = inject(DestroyRef); constructor( private constants: DoubtfireConstants, @@ -30,74 +75,147 @@ export class HomeComponent implements OnInit, OnDestroy { private userService: UserService, @Inject(DateService) private DateService: DateService, private router: Router, + @Inject(LOCALE_ID) private locale: string, ) { // projects and units are loaded as part of global state service at login } public externalName = this.constants.ExternalName; - public userFirstName = this.currentUser.nickname || this.currentUser.firstName; - - private subscriptions: Subscription[] = []; - - ngOnDestroy(): void { - this.subscriptions.forEach((sub) => sub.unsubscribe()); - } ngOnInit(): void { this.globalState.showHeader(); this.globalState.setView(ViewType.OTHER); - this.loadingUnitRoles = true; - this.loadingProjects = true; - - this.subscriptions.push( - this.globalState.unitRolesSubject.subscribe({ - next: (unitRoles) => this.unitRolesLoaded(unitRoles), - }), - ); - - this.subscriptions.push( - this.globalState.projectsSubject.subscribe({ - next: (projects) => { - projects = projects.filter((project) => project.unit.myRole === 'Student'); - this.projectsLoaded(projects); - }, - }), - ); - - this.notEnrolled = this.checkEnrolled(); + // The unit role and project caches are behaviour subjects that start out empty, so + // they emit before the data arrives. Waiting on the loading flag stops the page from + // flashing "you are not enrolled" while the first request is still in flight. + this.globalState.isLoadingSubject.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (loading) => (this.loading = loading), + }); + + this.globalState.unitRolesSubject.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (unitRoles) => this.unitRolesLoaded(unitRoles ?? []), + }); + + this.globalState.projectsSubject.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (projects) => { + // The cache can briefly hold a project whose unit has not been mapped yet, and + // it is cleared on sign out. The header guards the same filter the same way. + this.projectsLoaded( + (projects ?? []).filter((project) => project?.unit?.myRole === 'Student'), + ); + }, + }); if (this.currentUser.role === 'Auditor') { this.router.navigateByUrl('/admin/units'); } - - this.ifAdmin = this.currentUser.role === 'Admin'; - this.ifConvenor = this.currentUser.role === 'Convenor'; } get currentUser(): User { return this.userService.currentUser; } - unitRolesLoaded(unitRoles: UnitRole[]): void { - this.unitRoles = unitRoles; - this.loadingUnitRoles = false; + get isStaff(): boolean { + return this.unitRoles.length > 0; + } + + get hasNoUnits(): boolean { + return !this.loading && this.unitRoles.length === 0 && this.projects.length === 0; + } + + get staffSummary(): StaffSummary { + const active = this.staffUnits.length; + return { + activeLabel: `${active} active ${active === 1 ? 'unit' : 'units'}`, + previousCount: this.unitRoles.length - active, + unreadNotes: this.staffUnits.reduce( + (total, card) => total + Math.max(card.unitRole.tutorNoteCount ?? 0, 0), + 0, + ), + }; + } + + unitRolesLoaded(unitRoles: readonly UnitRole[]): void { + this.unitRoles = unitRoles.filter((unitRole) => unitRole?.unit); + this.staffUnits = this.unitRoles + .filter((unitRole) => unitRole.unit.isActive) + .map((unitRole, index) => this.buildStaffCard(unitRole, index)); } projectsLoaded(projects: Project[]): void { this.projects = projects; - this.loadingProjects = false; + this.studyUnits = projects + .filter((project) => project.unit.isActive) + .map((project, index) => this.buildStudyCard(project, index)); + } + + get studySummary(): string { + const active = this.studyUnits.length; + const previous = this.projects.length - active; + const activeLabel = `${active} active ${active === 1 ? 'unit' : 'units'}`; + return previous > 0 ? `${activeLabel} · ${previous} previous` : activeLabel; + } + + unreadNotesLabel(count: number): string { + return `${count} unread moderation ${count === 1 ? 'note' : 'notes'}`; + } + + private buildStaffCard(unitRole: UnitRole, index: number): StaffUnitCard { + const unit = unitRole.unit; + const progress = this.teachingProgress(unit); + + return { + unitRole, + unitId: unit.id, + code: unit.code, + name: unit.name, + role: unitRole.role, + periodLabel: unit.teachingPeriod?.name || this.showDate(unit.startDate), + accent: `var(--ot-unit-${(index % UNIT_ACCENT_COUNT) + 1})`, + progress: progress.value, + progressLabel: progress.label, + progressValueText: `${progress.label}, ${progress.value}% through the teaching period`, + }; + } + + private buildStudyCard(project: Project, index: number): StudyUnitCard { + const unit = project.unit; + const progress = this.teachingProgress(unit); + + return { + projectId: project.id, + code: unit.code, + name: unit.name, + target: project.targetGrade != null ? project.targetGradeWord : null, + periodLabel: unit.teachingPeriod?.name || this.showDate(unit.startDate), + accent: `var(--ot-unit-${(index % UNIT_ACCENT_COUNT) + 1})`, + progress: progress.value, + progressLabel: progress.label, + progressValueText: `${progress.label}, ${progress.value}% through the teaching period`, + }; } - checkEnrolled(): boolean { - if (this.unitRoles != null || this.projects != null) { - return false; + private teachingProgress(unit: Unit): {value: number; label: string} { + const start = unit.startDate; + const end = unit.endDate; + if (!(start instanceof Date) || !(end instanceof Date)) { + return {value: 0, label: 'No teaching dates set'}; + } + + const now = new Date(); + if (now < start) { + return {value: 0, label: `Starts ${formatDate(start, 'd MMM', this.locale)}`}; + } + if (now >= end) { + return {value: 100, label: 'Teaching finished'}; } - return ( - (this.unitRoles?.length === 0 && this.currentUser.role === 'Tutor') || - (this.projects?.length === 0 && this.currentUser.role === 'Student') - ); + const week = unit.weekNumber(now); + return { + value: unit.teachingPeriodProgress, + label: week != null && week > 0 ? `Week ${week}` : 'Teaching now', + }; } showDate = this.DateService.showDate; diff --git a/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.html b/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.html index a0929e54e4..7e51c236e8 100644 --- a/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.html +++ b/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.html @@ -6,7 +6,10 @@

      - {{ project.student.nickname || project.student.firstName }}'s {{ project.unit.name }} + @if (project.student?.displayName; as studentName) { + {{ studentName }}'s + } + {{ project.unit.name }}

      {{ project.unit.description }}

      diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.html b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.html index dfc62a5318..40f2fa8e47 100644 --- a/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.html +++ b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.html @@ -1,138 +1,122 @@ -
      -
      -

      +
      +
      +

      Progress Dashboard - @if (viewingOtherStudentProject) { - for {{ project?.student?.name }} + @if (viewingOtherStudentProject && project?.student?.name; as studentName) { + for {{ studentName }} }

      -
      - -
      - -
      - - -
      - - - Target Grade - -

      Your target grade changes which tasks you need to complete.

      -
      -
      - - -
      - - Select Target Grade - - @for (grade of grades.values; track grade) { - - {{ grades.names[grade] }} - - } - - -
      - To change your target grade, use the - - Task Planner - -
      -
      -
      -
      -
      - - @if (showSubmittedGrade) { - -
      - - - Submitted Grade - -

      The grade you are submitting your portfolio for.

      -
      -
      - - -
      - - Select Submitted Grade - - @for (grade of grades.values; track grade) { - - {{ grades.names[grade] }} - - } - - -
      -
      -
      -
      - } - - -
      - -
      +
      + +
      + + + Target grade + + @for (grade of grades.values; track grade) { + + {{ grades.names[grade] }} + + } + + {{ targetProgressText }} + - -
      - + @if (showSubmittedGrade) { + + } +
      -
      - - + +
      + + Progress Burndown - The burndown chart shows how much work remains for you to achieve your target grade. + How much work you have left to reach {{ targetGradeName }}. - + -
      - Aim to keep your - Complete - line close to or ahead of the - Target - line to keep on track. -
      +

      + Try to keep the + To Complete + line at or below the + Target + line. If it goes above, you are falling behind. +

      - - + + Task Statuses - Breakdown summary of each of your task statuses + + The tasks you need for {{ targetGradeName }}, grouped by status. + - +
      + + +
      + +
      + + +
      + +
      + + @if (showDemoPeerProgress) { +
      + +
      + }
      diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.spec.ts b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.spec.ts new file mode 100644 index 0000000000..90bc256319 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.spec.ts @@ -0,0 +1,316 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {NO_ERRORS_SCHEMA, SimpleChange} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatCardModule} from '@angular/material/card'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {MatSelectHarness} from '@angular/material/select/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {of, throwError} from 'rxjs'; +import {Project} from 'src/app/api/models/project'; +import {PeerProgressIndicatorService} from 'src/app/api/services/peer-progress-indicator.service'; +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 {DemoModeStore} from 'src/app/demo/demo-mode.store'; +import {ProgressDashboardComponent} from './progress-dashboard.component'; + +describe('ProgressDashboardComponent', () => { + let component: ProgressDashboardComponent; + let fixture: ComponentFixture; + let project: Project; + let projectServiceUpdate: ReturnType; + let alertSuccess: ReturnType; + let alertError: ReturnType; + let getScenarioUnitSummary: ReturnType; + + beforeEach(async () => { + project = { + id: 18, + targetGrade: 0, + unit: { + id: 11, + myRole: 'Student', + gradeDefinitions: [ + {value: 0, label: 'Pass'}, + {value: 1, label: 'Credit'}, + ], + }, + activeTasks: vi + .fn() + .mockReturnValue([{status: 'complete'}, {status: 'not_started'}, {status: 'not_started'}]), + refreshBurndownChartData: vi.fn(), + } as unknown as Project; + + projectServiceUpdate = vi.fn().mockReturnValue(of(project)); + alertSuccess = vi.fn(); + alertError = vi.fn(); + getScenarioUnitSummary = vi.fn().mockReturnValue( + of({ + unitId: 11, + targetGrade: 0, + studentPercentage: 33, + submittedPercentage: 60, + isSuppressed: false, + isStale: false, + isFeatureEnabled: true, + lastUpdatedAt: '2026-08-31T00:00:00Z', + unavailableMessage: '', + }), + ); + + await TestBed.configureTestingModule({ + declarations: [ProgressDashboardComponent], + imports: [MatCardModule, MatFormFieldModule, MatSelectModule, NoopAnimationsModule], + providers: [ + { + provide: GradeService, + useValue: { + grades: {0: 'Pass', 1: 'Credit'}, + gradeValues: [0, 1], + gradeValuesFor: () => [0, 1], + }, + }, + { + provide: PeerProgressIndicatorService, + useValue: {getScenarioUnitSummary}, + }, + {provide: DemoModeStore, useValue: {enabled: true}}, + {provide: ProjectService, useValue: {update: projectServiceUpdate}}, + {provide: AlertService, useValue: {success: alertSuccess, error: alertError}}, + {provide: UserService, useValue: {currentUser: {id: 25}}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ProgressDashboardComponent); + component = fixture.componentInstance; + component.project = project; + fixture.detectChanges(); + await fixture.whenStable(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('lets the student select a target grade directly from the dashboard', async () => { + const loader = TestbedHarnessEnvironment.loader(fixture); + const select = await loader.getHarness( + MatSelectHarness.with({selector: '[aria-label="Target grade"]'}), + ); + + expect(await select.isDisabled()).toBe(false); + expect(await select.getValueText()).toBe('Pass'); + + await select.open(); + await select.clickOptions({text: 'Credit'}); + + expect(project.targetGrade).toBe(1); + expect(projectServiceUpdate).toHaveBeenCalledWith(project); + expect(alertSuccess).toHaveBeenCalledWith('Updated target grade successfully', 2000); + expect(component.numberOfTasks).toEqual({completed: 1, remaining: 2}); + }); + + it('locks the selector when staff are viewing another student project', async () => { + project.unit.myRole = 'Tutor'; + project.student = {id: 99} as Project['student']; + fixture.detectChanges(); + await fixture.whenStable(); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const select = await loader.getHarness( + MatSelectHarness.with({selector: '[aria-label="Target grade"]'}), + ); + + expect(component.viewingOtherStudentProject).toBe(true); + expect(await select.isDisabled()).toBe(true); + }); + + it('blocks staff from changing the target grade in code, not only in the template', () => { + project.unit.myRole = 'Tutor'; + project.student = {id: 99} as Project['student']; + + component.updateTargetGrade(1); + + expect(projectServiceUpdate).not.toHaveBeenCalled(); + expect(project.targetGrade).toBe(0); + }); + + it('says how many target grade tasks are complete under the grade field', () => { + const hint: HTMLElement = fixture.nativeElement.querySelector('.target-grade-field mat-hint'); + + expect(hint.textContent.trim()).toBe('1 of 3 tasks complete for Pass'); + }); + + it('explains the field instead of showing 0 of 0 before tasks load', () => { + (project.activeTasks as ReturnType).mockReturnValue([]); + fixture.detectChanges(); + + const hint: HTMLElement = fixture.nativeElement.querySelector('.target-grade-field mat-hint'); + + expect(hint.textContent.trim()).toBe('This sets which tasks you need to do.'); + }); + + it('names the target grade in the chart subtitles', () => { + const subtitles = Array.from( + fixture.nativeElement.querySelectorAll('mat-card-subtitle'), + ).map((subtitle) => subtitle.textContent.trim()); + + expect(subtitles).toContain('How much work you have left to reach Pass.'); + expect(subtitles).toContain('The tasks you need for Pass, grouped by status.'); + }); + + it('shows the submitted grade as a locked field beside the target in the portfolio view', async () => { + component.showSubmittedGrade = true; + fixture.detectChanges(); + await fixture.whenStable(); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const submitted = await loader.getHarness( + MatSelectHarness.with({selector: '[aria-label="Submitted grade"]'}), + ); + + expect(await submitted.isDisabled()).toBe(true); + }); + + it('turns the planning tips off when staff view another student', () => { + const planner = () => + fixture.nativeElement.querySelector('f-task-planner-card') as HTMLElement & { + showTips: boolean; + }; + + expect(planner().showTips).toBe(true); + + project.unit.myRole = 'Tutor'; + project.student = {id: 99} as Project['student']; + fixture.detectChanges(); + + expect(planner().showTips).toBe(false); + }); + + it('restores the previous target grade when the update fails', () => { + projectServiceUpdate.mockReturnValueOnce(throwError(() => new Error('update failed'))); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + component.updateTargetGrade(1); + + expect(project.targetGrade).toBe(0); + expect(component.isUpdatingTargetGrade).toBe(false); + expect(alertError).toHaveBeenCalledWith('Failed to update target grade', 4000); + }); + + it('uses the target-scoped task collection for completion totals', () => { + component.ngOnInit(); + + expect(component.numberOfTasks).toEqual({completed: 1, remaining: 2}); + }); + + it('does not cap or clip the burndown and status cards', () => { + const host = fixture.nativeElement as HTMLElement; + const cardTitles = Array.from(host.querySelectorAll('mat-card-title')); + const burndownCard = cardTitles + .find((title) => title.textContent.trim() === 'Progress Burndown') + ?.closest('mat-card') as HTMLElement; + const statusCard = cardTitles + .find((title) => title.textContent.trim() === 'Task Statuses') + ?.closest('mat-card') as HTMLElement; + const burndownContent = burndownCard?.querySelector('mat-card-content') as HTMLElement; + + // jsdom loads no stylesheets and lays nothing out, so every element measures 0 high + // and a utility class never reaches getComputedStyle. The computed pair catches a cap + // set inline or in the component styles, and the class scan covers the utility route + // the regression actually took, for any height cap rather than the one literal value. + const clipping = (element: HTMLElement) => { + const computed = getComputedStyle(element); + return { + maxHeight: computed.maxHeight, + overflowY: computed.overflowY, + clippingClasses: Array.from(element.classList).filter((name) => + /^max-h-|^overflow-(y-)?hidden$/.test(name), + ), + }; + }; + const uncapped = {maxHeight: 'none', overflowY: 'visible', clippingClasses: []}; + + expect(burndownCard).toBeTruthy(); + expect(statusCard).toBeTruthy(); + expect(burndownContent).toBeTruthy(); + expect(clipping(burndownCard)).toEqual(uncapped); + expect(clipping(statusCard)).toEqual(uncapped); + expect(clipping(burndownContent)).toEqual(uncapped); + }); + + it('renders fabricated unit peer progress only while demo mode is enabled', () => { + expect(fixture.nativeElement.querySelector('f-peer-progress-unit-summary')).toBeTruthy(); + + (component.demoMode as {enabled: boolean}).enabled = false; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('f-peer-progress-unit-summary')).toBeNull(); + }); + + it('loads the current unit hook and derives student progress from the same project', () => { + component.ngOnInit(); + + expect(getScenarioUnitSummary).toHaveBeenCalledWith(18, 11, 0, 33); + expect(component.peerProgressView.data).toMatchObject({ + studentPercentage: 33, + submittedPercentage: 60, + }); + }); +}); + +describe('ProgressDashboardComponent route reuse', () => { + it('refreshes summary data when the active project changes', () => { + const gradeValuesFor = vi.fn(() => [0]); + const component = new ProgressDashboardComponent( + { + grades: {0: 'Pass'}, + gradeValues: [0], + gradeValuesFor, + } as unknown as GradeService, + { + getScenarioUnitSummary: vi.fn().mockReturnValue(of(null)), + } as unknown as PeerProgressIndicatorService, + {} as ProjectService, + {} as AlertService, + {} as UserService, + {enabled: true} as DemoModeStore, + ); + const firstProject = { + id: 2, + targetGrade: 0, + unit: {gradeDefinitions: [{value: 0, label: 'Pass'}]}, + numberTasks: vi.fn(() => 1), + activeTasks: vi.fn(() => [{}, {}, {}]), + refreshBurndownChartData: vi.fn(), + } as unknown as Project; + const nextProject = { + id: 18, + targetGrade: 0, + unit: {gradeDefinitions: [{value: 0, label: 'Pass'}]}, + numberTasks: vi.fn(() => 2), + activeTasks: vi.fn(() => [ + {status: 'complete'}, + {status: 'complete'}, + {status: 'not_started'}, + {status: 'not_started'}, + ]), + refreshBurndownChartData: vi.fn(), + } as unknown as Project; + component.project = firstProject; + component.ngOnInit(); + vi.clearAllMocks(); + + component.project = nextProject; + component.ngOnChanges({project: new SimpleChange(firstProject, nextProject, false)}); + + expect(gradeValuesFor).toHaveBeenCalledWith(nextProject.unit); + expect(component.numberOfTasks).toEqual({completed: 2, remaining: 2}); + expect(nextProject.refreshBurndownChartData).toHaveBeenCalled(); + }); +}); diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.ts b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.ts index 7cb2445c90..a26868a958 100644 --- a/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.ts +++ b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.ts @@ -3,14 +3,24 @@ import { Component, EventEmitter, Input, + OnChanges, OnInit, Output, + SimpleChanges, } from '@angular/core'; +import {take} from 'rxjs/operators'; +import { + PeerProgressUnitSummaryViewModel, + resolvePeerProgressUnitSummaryState, +} from 'src/app/api/models/peer-progress-unit-summary-state'; import {Project} from 'src/app/api/models/project'; +import {PeerProgressIndicatorService} from 'src/app/api/services/peer-progress-indicator.service'; 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 {calculateCompletionPercentage} from 'src/app/common/services/ppi-progress-calculation.service'; +import {DemoModeStore} from 'src/app/demo/demo-mode.store'; @Component({ selector: 'f-progress-dashboard', @@ -19,7 +29,7 @@ import {GradeService} from 'src/app/common/services/grade.service'; changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class ProgressDashboardComponent implements OnInit { +export class ProgressDashboardComponent implements OnChanges, OnInit { @Input() project: Project; @Input() showSubmittedGrade?: boolean = false; @Output() doUpdateTargetGrade: EventEmitter = new EventEmitter(); @@ -32,20 +42,44 @@ export class ProgressDashboardComponent implements OnInit { completed: 0, remaining: 0, }; + isUpdatingTargetGrade = false; + + peerProgressView: PeerProgressUnitSummaryViewModel = resolvePeerProgressUnitSummaryState( + true, + null, + null, + ); constructor( private gradeService: GradeService, + private peerProgressService: PeerProgressIndicatorService, private projectService: ProjectService, private alertService: AlertService, private userService: UserService, + readonly demoMode: DemoModeStore, ) {} ngOnInit(): void { + this.refreshProjectSummary(); + } + + ngOnChanges(changes: SimpleChanges): void { + if ('project' in changes && !changes.project.firstChange) { + this.refreshProjectSummary(); + } + } + + private refreshProjectSummary(): void { + if (!this.project?.unit) { + return; + } + this.grades.values = this.gradeService.gradeValuesFor(this.project.unit); this.grades.names = Object.fromEntries( this.project.unit.gradeDefinitions.map((definition) => [definition.value, definition.label]), ); this.updateTaskCompletionValues(); + this.loadPeerProgressUnitSummary(); this.project?.refreshBurndownChartData(); } @@ -56,27 +90,104 @@ export class ProgressDashboardComponent implements OnInit { return !!role && role !== 'Student' && this.project?.student?.id !== currentUser?.id; } + public get showDemoPeerProgress(): boolean { + return this.demoMode.enabled && !this.viewingOtherStudentProject; + } + + public get targetGradeName(): string { + return this.grades.names[this.project?.targetGrade] ?? 'your target grade'; + } + + /** + * Counted on every render, not cached, because the dashboard route resolves tasks + * progressively and a cached count would stay at zero after they arrive. + */ + public get targetProgressText(): string { + const tasks = this.project?.activeTasks?.() ?? []; + if (tasks.length === 0) { + return 'This sets which tasks you need to do.'; + } + + const completed = tasks.filter((task) => task.status === 'complete').length; + return `${completed} of ${tasks.length} tasks complete for ${this.targetGradeName}`; + } + updateTargetGrade(newGrade: number): void { + if ( + this.viewingOtherStudentProject || + this.isUpdatingTargetGrade || + newGrade === undefined || + newGrade === null || + newGrade === this.project.targetGrade + ) { + return; + } + + const previousTargetGrade = this.project.targetGrade; this.project.targetGrade = newGrade; - this.projectService.update(this.project).subscribe( - (project) => { + this.isUpdatingTargetGrade = true; + + this.projectService.update(this.project).subscribe({ + next: (project) => { + this.isUpdatingTargetGrade = false; project.refreshBurndownChartData(); this.updateTaskCompletionValues(); + this.loadPeerProgressUnitSummary(); this.doUpdateTargetGrade.emit(); this.alertService.success('Updated target grade successfully', 2000); }, - (error) => { + error: (error) => { + this.isUpdatingTargetGrade = false; + this.project.targetGrade = previousTargetGrade; + this.updateTaskCompletionValues(); + this.loadPeerProgressUnitSummary(); + this.doUpdateTargetGrade.emit(); console.error('Error updating target grade:', error); this.alertService.error('Failed to update target grade', 4000); }, + }); + } + + private loadPeerProgressUnitSummary(): void { + if (!this.showDemoPeerProgress) { + return; + } + + const availableTasks = this.numberOfTasks.completed + this.numberOfTasks.remaining; + + const studentPercentage = calculateCompletionPercentage( + this.numberOfTasks.completed, + availableTasks, ); + + this.peerProgressView = resolvePeerProgressUnitSummaryState(true, null, null); + + // Demo-only unit summaries consume the guarded Batch 09 contract. A live + // authorised unit-level API remains future work. + this.peerProgressService + .getScenarioUnitSummary( + this.project.id, + this.project.unit.id, + this.project.targetGrade, + studentPercentage, + ) + .pipe(take(1)) + .subscribe({ + next: (data) => { + this.peerProgressView = resolvePeerProgressUnitSummaryState(false, null, data); + }, + error: (error) => { + this.peerProgressView = resolvePeerProgressUnitSummaryState(false, error, null); + }, + }); } private updateTaskCompletionValues(): void { - const completedTasks = this.project.numberTasks('complete'); + const activeTasks = this.project.activeTasks(); + const completedTasks = activeTasks.filter((task) => task.status === 'complete').length; this.numberOfTasks = { completed: completedTasks, - remaining: this.project.activeTasks().length - completedTasks, + remaining: activeTasks.length - completedTasks, }; } } diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.html b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.html index c11ab4c597..73e2920a56 100644 --- a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.html +++ b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.html @@ -1,28 +1,91 @@ - - - Plan Your Tasks - -

      - The Task Planner shows a timeline of your tasks, their due dates, and prerequisite - relationships. Use it to plan when to start and submit tasks, ensuring prerequisites are - completed early so you have time for feedback. -

      - @if (unit.allowFlexibleDates) { -

      - You can set your own start and target dates. Be mindful of each task’s - Feedback Deadline — submissions after this date will not be checked. Aim - to finish at least one week before the deadline, especially if you are targeting a higher - grade. -

      - } -
      -
      - - + + +
      + + + +
      + + + View Task Planner + + + +
      +
      + + @if (showTips) { +
      + + New here? How this page works + +
      +
      +
      Target grade
      +
      + This is the grade you want to get. {{ lowestGradeLabel }} tasks are for everyone. Each + higher grade adds more tasks. This page only counts the tasks you need for your target + grade. +
      +
      +
      +
      Target date and feedback deadline
      +
      + The target date is the day to aim to submit a task. The feedback deadline is the last + day to submit it and still get feedback from your tutor. + @if (unit?.allowFlexibleDates) { + In this unit you can change your own target dates in the Task Planner. + } +
      +
      +
      +
      Submit early
      +
      + Your tutor may ask you to fix something and submit again. Try to submit at least one + week before the feedback deadline, so you have time to do this. +
      +
      +
      +
      Task Planner
      +
      + It shows the tasks for your target grade on a timeline. Turn on "Show tasks beyond + target grade" to see the rest. Click a task to see which tasks you must finish before + it. +
      +
      +
      +
      Download calendar
      +
      + Pick a grade to save the target dates of its tasks as a calendar file. Open the file + with a calendar app, like Outlook or Apple Calendar. If your dates change, download it + again. +
      +
      +
      +
      The two charts
      +
      + Progress Burndown shows how much work you have left. Task Statuses shows how many of + your tasks are at each step. +
      +
      +
      +
      + }
      diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.spec.ts b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.spec.ts new file mode 100644 index 0000000000..c6369d1d02 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component.spec.ts @@ -0,0 +1,651 @@ +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'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatDialog} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; +import {MatSelectModule} from '@angular/material/select'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {Subject, of} 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 {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; +import {Unit} from 'src/app/api/models/unit'; +import {buildIcsCalendar} from 'src/app/api/services/ics-calendar-builder'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {DownloadFilterSelection} from './download-filter-dialog/download-filter-dialog.component'; +import {TaskPlannerCardComponent} from './task-planner-card.component'; + +function buildProjectWithTasks( + tasks: {dueDate?: Date; targetGrade?: number; status?: TaskStatusEnum}[], + projectTargetGrade: number | undefined = 0, +): Project { + const unit = new Unit(); + unit.id = 7; + unit.code = 'COS10001'; + unit.allowFlexibleDates = false; + + const project = new Project(unit); + project.targetGrade = projectTargetGrade; + + tasks.forEach(({dueDate, targetGrade, status}, index) => { + const definition = new TaskDefinition(unit); + definition.id = index + 1; + definition.abbreviation = `${index + 1}.1P`; + definition.name = `Task ${index + 1}`; + definition.targetGrade = targetGrade ?? 0; + definition.targetDate = dueDate; + + const task = new Task(unit); + task.id = index + 1; + task.definition = definition; + task.dueDate = dueDate; + task.project = project; + if (status) { + task.status = status; + } + + project.taskCache.add(task); + }); + + return project; +} + +describe('TaskPlannerCardComponent', () => { + let component: TaskPlannerCardComponent; + let fixture: ComponentFixture; + let fileDownloaderStub: { + downloadBlobToFileWithFeedback: ReturnType; + releaseBlob: ReturnType; + }; + let matDialogStub: {open: ReturnType}; + + beforeEach(async () => { + fileDownloaderStub = { + downloadBlobToFileWithFeedback: vi.fn(), + releaseBlob: vi.fn(), + }; + matDialogStub = {open: vi.fn()}; + + await TestBed.configureTestingModule({ + declarations: [TaskPlannerCardComponent], + imports: [ + MatButtonModule, + MatCheckboxModule, + MatIconModule, + MatSelectModule, + FormsModule, + NoopAnimationsModule, + ], + providers: [ + {provide: FileDownloaderService, useValue: fileDownloaderStub}, + {provide: MatDialog, useValue: matDialogStub}, + GradeService, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskPlannerCardComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('disables the download button when there are no active tasks yet', () => { + // Simulates the dashboard's progressive resolution (project.resolver.ts), where + // project.tasks can still be empty on first render. + component.project = buildProjectWithTasks([]); + fixture.detectChanges(); + + const downloadButton: HTMLButtonElement = + fixture.nativeElement.querySelector('.download-ics-link'); + + expect(downloadButton).not.toBeNull(); + expect(downloadButton.disabled).toBe(true); + }); + + it('enables the download button once tasks at the selected grade are present', () => { + component.project = buildProjectWithTasks([{dueDate: new Date(2026, 8, 15, 23, 59, 59, 999)}]); + fixture.detectChanges(); + + const downloadButton: HTMLButtonElement = + fixture.nativeElement.querySelector('.download-ics-link'); + + expect(downloadButton.disabled).toBe(false); + }); + + it('lets the planner controls wrap with space between them on phones', () => { + component.project = buildProjectWithTasks([]); + fixture.detectChanges(); + + const controls: HTMLElement = fixture.nativeElement.querySelector('.task-planner-actions'); + const plannerLink = controls.querySelector('a'); + const downloadButton = controls.querySelector('.download-ics-link'); + + expect(controls.classList.contains('flex-wrap')).toBe(true); + expect(controls.classList.contains('gap-2')).toBe(true); + expect(plannerLink?.nextElementSibling).toBe(downloadButton); + // The grade field and the exclude-completed choice now live in the download dialog. + expect(controls.querySelector('mat-form-field')).toBeNull(); + expect(controls.querySelector('mat-checkbox')).toBeNull(); + }); + + it('does not call the file downloader when there are no active tasks, guarding against an empty file', () => { + component.project = buildProjectWithTasks([]); + fixture.detectChanges(); + + component.downloadIcs(); + + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).not.toHaveBeenCalled(); + expect(fileDownloaderStub.releaseBlob).not.toHaveBeenCalled(); + }); + + it('downloads a blob named after the unit code and the selected grade abbreviation', () => { + component.project = buildProjectWithTasks([{dueDate: new Date(2026, 8, 15, 23, 59, 59, 999)}]); + fixture.detectChanges(); + // Pinned false so this test's filename expectation is decoupled from the excludeCompleted + // default (true) - this test is about the grade/unit code portion of the filename only. + component.excludeCompleted = false; + + const createObjectURLSpy = vi + .spyOn(window.URL, 'createObjectURL') + .mockReturnValue('blob:mock-url'); + + expect(component.hasDownloadableTasks).toBe(true); + component.downloadIcs(); + + expect(createObjectURLSpy).toHaveBeenCalledOnce(); + const [blobArg] = createObjectURLSpy.mock.calls[0]; + expect((blobArg as Blob).type).toBe('text/calendar;charset=utf-8'); + // Default project.targetGrade is 0 (Pass, abbreviation 'P'). + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-P.ics', + ); + expect(fileDownloaderStub.releaseBlob).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('defaults selectedDownloadGrade to project.targetGrade', () => { + component.project = buildProjectWithTasks([], 2); + fixture.detectChanges(); + + expect(component.selectedDownloadGrade).toBe(2); + }); + + it('opens the download at the current target grade after it changes on the dashboard', async () => { + const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999); + component.project = buildProjectWithTasks([ + {dueDate, targetGrade: 0}, + {dueDate, targetGrade: 3}, + ]); + fixture.detectChanges(); + component.project.targetGrade = 3; + fixture.detectChanges(); + + matDialogStub.open.mockImplementation((_dialog, {data}) => ({ + afterClosed: () => of({grade: data.initialGrade, direction: 'upTo', excludeCompleted: true}), + })); + const createObjectURLSpy = vi + .spyOn(window.URL, 'createObjectURL') + .mockReturnValue('blob:mock-url'); + + component.openDownloadDialog(); + + expect(matDialogStub.open.mock.calls[0][1].data.initialGrade).toBe(3); + const downloaded = await (createObjectURLSpy.mock.calls[0][0] as Blob).text(); + expect(downloaded).toContain('UID:E-1'); + expect(downloaded).toContain('UID:E-2'); + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-HD-outstanding.ics', + ); + }); + + it('keeps following the target grade after accepting the default download grade', () => { + component.project = buildProjectWithTasks([{dueDate: new Date(2026, 8, 15)}]); + fixture.detectChanges(); + matDialogStub.open.mockReturnValue({ + afterClosed: () => of({grade: 0, direction: 'upTo', excludeCompleted: true}), + }); + vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + + component.openDownloadDialog(); + component.project.targetGrade = 2; + matDialogStub.open.mockReturnValue({afterClosed: () => of(undefined)}); + component.openDownloadDialog(); + + expect(matDialogStub.open.mock.calls[1][1].data.initialGrade).toBe(2); + }); + + it('preserves an explicit download grade and filters when the saved target changes', () => { + component.project = buildProjectWithTasks([{dueDate: new Date(2026, 8, 15), targetGrade: 2}]); + fixture.detectChanges(); + matDialogStub.open.mockReturnValue({ + afterClosed: () => of({grade: 2, direction: 'andAbove', excludeCompleted: false}), + }); + vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + + component.openDownloadDialog(); + component.project.targetGrade = 3; + matDialogStub.open.mockReturnValue({afterClosed: () => of(undefined)}); + component.openDownloadDialog(); + + expect(matDialogStub.open.mock.calls[1][1].data).toMatchObject({ + initialGrade: 2, + initialDirection: 'andAbove', + initialExcludeCompleted: false, + }); + expect(component.project.targetGrade).toBe(3); + }); + + it('resets a local download grade when the dashboard switches to another project', () => { + const firstProject = buildProjectWithTasks([{dueDate: new Date(2026, 8, 15)}]); + fixture.componentRef.setInput('project', firstProject); + fixture.detectChanges(); + matDialogStub.open.mockReturnValue({ + afterClosed: () => of({grade: 3, direction: 'upTo', excludeCompleted: true}), + }); + vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + component.openDownloadDialog(); + + const nextProject = buildProjectWithTasks([{dueDate: new Date(2026, 8, 20)}], 1); + nextProject.unit.gradeDefinitions = nextProject.unit.gradeDefinitions.filter( + (grade) => grade.value <= 1, + ); + fixture.componentRef.setInput('project', nextProject); + fixture.detectChanges(); + matDialogStub.open.mockReturnValue({afterClosed: () => of(undefined)}); + component.openDownloadDialog(); + + expect(matDialogStub.open.mock.calls[1][1].data).toMatchObject({ + initialGrade: 1, + gradeValues: [0, 1], + }); + expect(nextProject.targetGrade).toBe(1); + expect(firstProject.targetGrade).toBe(0); + }); + + it('does not apply an old project dialog selection after the project input changes', () => { + fixture.componentRef.setInput( + 'project', + buildProjectWithTasks([{dueDate: new Date(2026, 8, 15)}]), + ); + fixture.detectChanges(); + const closed: Subject = new Subject(); + matDialogStub.open.mockReturnValue({afterClosed: () => closed}); + component.openDownloadDialog(); + + fixture.componentRef.setInput( + 'project', + buildProjectWithTasks([{dueDate: new Date(2026, 8, 20)}], 1), + ); + fixture.detectChanges(); + closed.next({grade: 3, direction: 'andAbove', excludeCompleted: false}); + closed.complete(); + + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).not.toHaveBeenCalled(); + expect(component.selectedDownloadGrade).toBe(1); + expect(component.downloadDirection).toBe('upTo'); + expect(component.excludeCompleted).toBe(true); + }); + + it('falls back to the highest grade value when project.targetGrade is not set', () => { + const project = buildProjectWithTasks([]); + project.targetGrade = undefined; + component.project = project; + fixture.detectChanges(); + + // GradeService.gradeValues is [0, 1, 2, 3], the highest is 3 (High Distinction). + expect(component.selectedDownloadGrade).toBe(3); + }); + + it('does not persist the selection: does not write project.targetGrade', () => { + component.project = buildProjectWithTasks([], 1); + fixture.detectChanges(); + + component.selectedDownloadGrade = 3; + + expect(component.project.targetGrade).toBe(1); + }); + + it('only includes tasks at or below the selected grade in the generated calendar', () => { + // Two tasks: one at grade 0 (Pass), one at grade 2 (Distinction). If the grade filter + // were ignored, both event UIDs would be present in the generated calendar. + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 0}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), targetGrade: 2}, + ]); + fixture.detectChanges(); + component.selectedDownloadGrade = 0; + + const selectedTasks = component['tasksForSelectedGrade'](); + const ics = buildIcsCalendar(selectedTasks, new Date('2026-08-24T00:00:00Z')); + expect(selectedTasks.map((task) => task.definition.id)).toEqual([1]); + expect(ics).toContain('UID:E-1'); + expect(ics).not.toContain('UID:E-2'); + }); + + it('hasDownloadableTasks reflects whether any tasks are loaded, regardless of the filter selection', () => { + // The filter selection now lives in the dialog, opened after this gate. A selection that + // would yield zero results (e.g. grade 0 with a grade-2 task) is guarded inside the dialog + // instead, so this gate must stay true as long as the project has any tasks at all. + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 2}, + ]); + fixture.detectChanges(); + + component.selectedDownloadGrade = 0; + expect(component.hasDownloadableTasks).toBe(true); + + component.excludeCompleted = true; + expect(component.hasDownloadableTasks).toBe(true); + }); + + it('includes the selected grade abbreviation in the filename, not the default', () => { + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 0}, + ]); + fixture.detectChanges(); + component.selectedDownloadGrade = 1; // Credit, abbreviation 'C'. + // Pinned false so this test's filename expectation is decoupled from the excludeCompleted + // default (true) - this test is about the grade override portion of the filename only. + component.excludeCompleted = false; + + vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + + component.downloadIcs(); + + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-C.ics', + ); + }); + + it('excludes tasks in a final state from the generated calendar when excludeCompleted is on', () => { + // Task 1 is complete (a final status), Task 2 is still in progress. If the completed + // filter were ignored, both event UIDs would be present in the generated calendar. + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), status: 'complete'}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), status: 'working_on_it'}, + ]); + fixture.detectChanges(); + component.excludeCompleted = true; + + const ics = buildIcsCalendar(component['tasksForDownload'](), new Date('2026-08-24T00:00:00Z')); + expect(ics).not.toContain('UID:E-1'); + expect(ics).toContain('UID:E-2'); + }); + + it('includes both completed and outstanding tasks when excludeCompleted is unticked', () => { + // CAL-F09 flipped the default to true (see the excludeCompleted field). This test now + // covers the unticked path explicitly rather than relying on it being the default. + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), status: 'complete'}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), status: 'working_on_it'}, + ]); + fixture.detectChanges(); + component.excludeCompleted = false; + + const ics = buildIcsCalendar(component['tasksForDownload'](), new Date('2026-08-24T00:00:00Z')); + expect(ics).toContain('UID:E-1'); + expect(ics).toContain('UID:E-2'); + }); + + it('defaults excludeCompleted to true, so an untouched download excludes completed tasks and carries the -outstanding suffix', async () => { + // Discriminating for the CAL-F09 default flip: if excludeCompleted silently reverted to + // false, the boolean check below would fail, the completed task's UID would leak into the + // ICS output, and the filename would lose its -outstanding suffix. + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), status: 'complete'}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), status: 'working_on_it'}, + ]); + fixture.detectChanges(); + + expect(component.excludeCompleted).toBe(true); + + const ics = buildIcsCalendar(component['tasksForDownload'](), new Date('2026-08-24T00:00:00Z')); + expect(ics).not.toContain('UID:E-1'); + expect(ics).toContain('UID:E-2'); + + const createObjectURLSpy = vi + .spyOn(window.URL, 'createObjectURL') + .mockReturnValue('blob:mock-url'); + component.downloadIcs(); + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-P-outstanding.ics', + ); + + // Read the file the download actually built, so a downloadIcs() that skipped the + // completed filter would fail here even though the helper above is still correct. + const [blobArg] = createObjectURLSpy.mock.calls[0]; + const downloaded = await (blobArg as Blob).text(); + expect(downloaded).not.toContain('UID:E-1'); + expect(downloaded).toContain('UID:E-2'); + }); + + it('composes the completed filter with the grade filter', () => { + // Task 1: grade 0, complete. Task 2: grade 2, outstanding. Task 3: grade 0, outstanding. + // With selectedDownloadGrade 0 and excludeCompleted true, only Task 3 should remain. + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 0, status: 'complete'}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), targetGrade: 2, status: 'working_on_it'}, + {dueDate: new Date(2026, 8, 22, 23, 59, 59, 999), targetGrade: 0, status: 'working_on_it'}, + ]); + fixture.detectChanges(); + component.selectedDownloadGrade = 0; + component.excludeCompleted = true; + + const tasks = component['tasksForDownload'](); + expect(tasks.map((task) => task.definition.id)).toEqual([3]); + }); + + it('this grade and above with HD selected yields only targetGrade 3 tasks', () => { + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 0}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), targetGrade: 2}, + {dueDate: new Date(2026, 8, 22, 23, 59, 59, 999), targetGrade: 3}, + ]); + fixture.detectChanges(); + component.selectedDownloadGrade = 3; + component.downloadDirection = 'andAbove'; + + const tasks = component['tasksForSelectedGrade'](); + expect(tasks.map((task) => task.definition.id)).toEqual([3]); + }); + + it('this grade and above with Distinction selected yields targetGrade 2 and 3 only, excluding a Pass task', () => { + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 0}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), targetGrade: 2}, + {dueDate: new Date(2026, 8, 22, 23, 59, 59, 999), targetGrade: 3}, + ]); + fixture.detectChanges(); + component.selectedDownloadGrade = 2; + component.downloadDirection = 'andAbove'; + + const tasks = component['tasksForSelectedGrade'](); + expect(tasks.map((task) => task.definition.id)).toEqual([2, 3]); + expect(tasks.map((task) => task.definition.id)).not.toContain(1); + }); + + it('composes this grade and above with exclude-completed', () => { + // Task 1: grade 0, complete. Task 2: grade 2, outstanding. Task 3: grade 3, complete. + // Task 4: grade 3, outstanding. With direction andAbove, selectedDownloadGrade 2 and + // excludeCompleted true, only Task 2 and Task 4 should remain. + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 0, status: 'complete'}, + {dueDate: new Date(2026, 8, 20, 23, 59, 59, 999), targetGrade: 2, status: 'working_on_it'}, + {dueDate: new Date(2026, 8, 22, 23, 59, 59, 999), targetGrade: 3, status: 'complete'}, + {dueDate: new Date(2026, 8, 23, 23, 59, 59, 999), targetGrade: 3, status: 'working_on_it'}, + ]); + fixture.detectChanges(); + component.selectedDownloadGrade = 2; + component.downloadDirection = 'andAbove'; + component.excludeCompleted = true; + + const tasks = component['tasksForDownload'](); + expect(tasks.map((task) => task.definition.id)).toEqual([2, 4]); + }); + + it.each([0, 1, 2, 3])('exports exactly the tasks up to grade %i', async (grade) => { + component.project = buildProjectWithTasks( + [0, 1, 2, 3].map((targetGrade) => ({targetGrade, dueDate: new Date(2026, 8, 20)})), + ); + fixture.detectChanges(); + component.selectedDownloadGrade = grade; + const createUrl = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:grade'); + + component.downloadIcs(); + + const calendar = await (createUrl.mock.calls[0][0] as Blob).text(); + expect(calendar.match(/^UID:.+$/gm)?.map((uid) => uid.trim())).toEqual( + [0, 1, 2, 3].filter((value) => value <= grade).map((value) => `UID:E-${value + 1}`), + ); + expect(component.project.targetGrade).toBe(0); + }); + + it.each([0, 1, 2, 3])('exports exactly the tasks from grade %i and above', async (grade) => { + component.project = buildProjectWithTasks( + [0, 1, 2, 3].map((targetGrade) => ({targetGrade, dueDate: new Date(2026, 8, 20)})), + ); + fixture.detectChanges(); + component.selectedDownloadGrade = grade; + component.downloadDirection = 'andAbove'; + const createUrl = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:grade'); + + component.downloadIcs(); + + const calendar = await (createUrl.mock.calls[0][0] as Blob).text(); + expect(calendar.match(/^UID:.+$/gm)?.map((uid) => uid.trim())).toEqual( + [0, 1, 2, 3].filter((value) => value >= grade).map((value) => `UID:E-${value + 1}`), + ); + }); + + it('excludes every submitted/final state but keeps work that needs resubmission', () => { + component.project = buildProjectWithTasks( + TaskStatus.STATUS_KEYS.map((status) => ({status, dueDate: new Date(2026, 8, 20)})), + ); + fixture.detectChanges(); + + expect(component['tasksForDownload']().map((task) => task.status)).toEqual([ + 'not_started', + 'redo', + 'need_help', + 'working_on_it', + 'fix_and_resubmit', + ]); + component.excludeCompleted = false; + expect(component['tasksForDownload']().map((task) => task.status)).toEqual( + TaskStatus.STATUS_KEYS, + ); + }); + + it('counts only resolvable dates and blocks a file when the selection has none', () => { + component.project = buildProjectWithTasks([ + {targetGrade: 0}, + {targetGrade: 0, dueDate: new Date('invalid')}, + {targetGrade: 2, dueDate: new Date(2026, 8, 20)}, + ]); + fixture.detectChanges(); + matDialogStub.open.mockReturnValue({afterClosed: () => of(undefined)}); + + component.openDownloadDialog(); + + const options = matDialogStub.open.mock.calls[0][1]; + expect(options.autoFocus).toBe('first-tabbable'); + expect(options.data.matchingTaskCount(0, 'upTo', true)).toBe(0); + expect(options.data.matchingTaskCount(2, 'upTo', true)).toBe(1); + component.downloadIcs(); + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).not.toHaveBeenCalled(); + }); + + it('inserts -and-above into the filename for the and-above direction, leaving up-to filenames unchanged', () => { + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 2}, + ]); + fixture.detectChanges(); + component.selectedDownloadGrade = 2; + // Pinned false so the filenames below isolate the direction suffix from the + // excludeCompleted default (true), which appends its own suffix. + component.excludeCompleted = false; + + vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + + component.downloadDirection = 'upTo'; + component.downloadIcs(); + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-D.ics', + ); + + fileDownloaderStub.downloadBlobToFileWithFeedback.mockClear(); + component.downloadDirection = 'andAbove'; + component.downloadIcs(); + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-D-and-above.ics', + ); + }); + + it('applies the values returned by the download dialog and downloads accordingly', () => { + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), targetGrade: 3}, + ]); + fixture.detectChanges(); + + const selection: DownloadFilterSelection = { + grade: 3, + direction: 'andAbove', + excludeCompleted: false, + }; + matDialogStub.open.mockReturnValue({afterClosed: () => of(selection)}); + vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + + component.openDownloadDialog(); + + expect(matDialogStub.open).toHaveBeenCalledOnce(); + expect(component.selectedDownloadGrade).toBe(3); + expect(component.downloadDirection).toBe('andAbove'); + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-HD-and-above.ics', + ); + }); + + it('does not download when the dialog is cancelled', () => { + // If afterClosed's result were ignored (or a cancel treated as a selection), this would + // still call the file downloader. + component.project = buildProjectWithTasks([{dueDate: new Date(2026, 8, 15, 23, 59, 59, 999)}]); + fixture.detectChanges(); + + matDialogStub.open.mockReturnValue({afterClosed: () => of(undefined)}); + + component.openDownloadDialog(); + + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).not.toHaveBeenCalled(); + }); + + it('appends -outstanding to the filename when excludeCompleted is on', () => { + component.project = buildProjectWithTasks([ + {dueDate: new Date(2026, 8, 15, 23, 59, 59, 999), status: 'working_on_it'}, + ]); + fixture.detectChanges(); + component.excludeCompleted = true; + + vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + + component.downloadIcs(); + + expect(fileDownloaderStub.downloadBlobToFileWithFeedback).toHaveBeenCalledWith( + 'blob:mock-url', + 'COS10001-tasks-P-outstanding.ics', + ); + }); +}); diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.html index e5f1ce9ae5..4333e0aa58 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.html @@ -1,69 +1,105 @@ - -
      - - {{ taskDef?.name }} -
      + + {{ taskDef?.name }} + + + {{ taskDef?.abbreviation }} + + {{ grades.names[taskDef?.targetGrade] }} task +
      - @if (task) { - + + + @let dates = keyDates; + @if (dates.length) { +
        + @for (step of dates; track step.key) { +
      1. + + {{ step.label }} + {{ step.date | date: 'EEE d MMM' }} + @if (step.when) { + {{ step.when }} + } + @if (step.note) { + {{ step.note }} + } + @if (step.warning) { + + + {{ step.warning }} + + } +
      2. + } +
      + } + @if (task?.effectiveDeadlineDate) { +

      + Effective submission date: {{ task.effectiveDeadlineDate | date: 'fullDate' }}. + {{ task.effectiveDeadlineExplanation }} + @if (task.effectiveDeadlineReason !== 'flexible_date') { + The deadline is the end of this day anywhere on earth. + } +

      } -
        -
      • {{ grades.names[taskDef?.targetGrade] }} Task
      • + - @if (unit?.allowFlexibleDates ?? taskDef?.unit?.allowFlexibleDates) { -
      • I'm planning to start this task by {{ startDate() | date: 'EEE d MMM' }}.
      • -
      • - The due date has been extended by {{ task?.extensions }} week{{ - task?.extensions > 1 ? 's' : '' - }}. -
      • -
      • I'm aiming to submit this task by {{ dueDate() | date: 'EEE d MMM' }}.
      • -
      • - To receive feedback, I must submit this task by - {{ feedbackDate() | date: 'EEE d MMM' }}. -
      • - } @else { -
      • - Start Date — I'm aiming to start this task by - {{ startDate() | date: 'EEE d MMM' }}. -
      • -
      • - Extension — The due date has been extended by - {{ task?.extensions }} week{{ task?.extensions > 1 ? 's' : '' }}. -
      • -
      • - Due Date — I'm aiming to submit this task by - {{ dueDate() | date: 'EEE d MMM' }}. -
      • + @let calendarUrl = googleCalendarUrl; + @let canEditPlan = allowsFlexibleDates && !!task?.project; + @if (taskDef?.hasTaskSheet || taskDef?.hasTaskResources || calendarUrl || canEditPlan) { + + @if (taskDef?.hasTaskSheet) { + } -
      -
      - - - - - + @if (taskDef?.hasTaskResources) { + + } + @if (calendarUrl) { + + + Add to Google Calendar + + } + @if (canEditPlan) { + + + Edit in Task Planner + + } + + }
      diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.spec.ts new file mode 100644 index 0000000000..32e578a6bb --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.spec.ts @@ -0,0 +1,509 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +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 {NoopAnimationsModule} from '@angular/platform-browser/animations'; +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 {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {MarkedPipe} from 'src/app/common/pipes/marked.pipe'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {TaskDescriptionCardComponent} from './task-description-card.component'; + +function buildTask(overrides: { + unitCode?: string; + abbreviation?: string; + name?: string; + taskDefId?: number; + dueDate?: Date; + hasTaskSheet?: boolean; + hasTaskResources?: boolean; + taskSheetFilename?: string; +}): Task { + const unit = new Unit(); + unit.code = overrides.unitCode ?? 'COS10001'; + unit.allowFlexibleDates = false; + + const definition = new TaskDefinition(unit); + definition.id = overrides.taskDefId ?? 42; + definition.abbreviation = overrides.abbreviation ?? '1.1P'; + definition.name = overrides.name ?? 'Hello World'; + definition.description = ''; + definition.targetDate = undefined; + definition.startDate = new Date(2026, 8, 1); + definition.targetGrade = 0; + definition.hasTaskSheet = overrides.hasTaskSheet ?? true; + definition.taskSheetFilename = overrides.taskSheetFilename; + definition.hasTaskResources = overrides.hasTaskResources ?? false; + + const task = new Task(unit); + task.definition = definition; + task.dueDate = overrides.dueDate; + task.project = new Project(unit); + task.project.targetGrade = 0; + vi.spyOn(task, 'localDeadlineDate').mockReturnValue(overrides.dueDate); + + return task; +} + +describe('TaskDescriptionCardComponent', () => { + let component: TaskDescriptionCardComponent; + let fixture: ComponentFixture; + const downloadFile = vi.fn(); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TaskDescriptionCardComponent, MarkedPipe], + imports: [NoopAnimationsModule, MatButtonModule, MatIconModule], + providers: [GradeService, {provide: FileDownloaderService, useValue: {downloadFile}}], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskDescriptionCardComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + // window.open is a real global. vi.spyOn replaces it in place and, without this, + // the replacement and its call history persist across tests, since neither + // restoreMocks nor clearMocks is configured globally for this project (checked + // angular.json's test builder options and src/vitest-setup.ts, neither sets + // either). vi.restoreAllMocks() restores the original window.open and clears the + // spy's history after every test, not just the ones that create it directly. + vi.restoreAllMocks(); + downloadFile.mockReset(); + }); + + it('uses the API-authoritative task-sheet filename with a rolling-safe fallback', () => { + const task = buildTask({ + unitCode: 'COS10001', + abbreviation: '1.1P', + taskSheetFilename: 'COS10001-1.1P-TaskSheet.pdf', + }); + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + vi.spyOn(task.definition, 'getTaskPDFUrl').mockReturnValue('/task-sheet'); + + component.downloadTaskSheet(); + + expect(downloadFile).toHaveBeenCalledWith('/task-sheet', 'COS10001-1.1P-TaskSheet.pdf'); + + task.definition.taskSheetFilename = undefined; + component.downloadTaskSheet(); + expect(downloadFile).toHaveBeenLastCalledWith('/task-sheet', 'COS10001-1.1P-TaskSheet.pdf'); + }); + + it('renders the Add to Google Calendar button when the task has a due date, with a correct href, target and rel', () => { + // 23:59:59.999 local, the only time-of-day Task.dueDate actually carries. It maps + // through MappingFunctions.mapDateToEndOfDay in the real app (task.service.ts), a + // bare local-midnight fixture is not a value the app ever produces. + const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999); + const task = buildTask({dueDate}); + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + fixture.detectChanges(); + + const link: HTMLAnchorElement = fixture.nativeElement.querySelector( + 'a.add-to-google-calendar-link', + ); + + expect(link).not.toBeNull(); + expect(link.getAttribute('href')).toBe(component.googleCalendarUrl); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('is absent from the DOM when the task has no resolvable due date', () => { + const task = buildTask({dueDate: undefined}); + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a.add-to-google-calendar-link'); + + expect(link).toBeNull(); + }); + + it('renders the button when the task has a due date but neither a task sheet nor resources', () => { + // The button's own container used to be mat-card-actions, hidden unless + // hasTaskSheet or hasTaskResources is true. The calendar button now has its own + // container gated only by whether a due date resolves, this proves it is no longer + // tied to that unrelated condition. + const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999); + const task = buildTask({dueDate, hasTaskSheet: false, hasTaskResources: false}); + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + fixture.detectChanges(); + + const link = fixture.nativeElement.querySelector('a.add-to-google-calendar-link'); + + expect(link).not.toBeNull(); + }); + + it('builds the exact Google Calendar URL, with an exclusive end date one day after the due date', () => { + const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999); + const task = buildTask({ + unitCode: 'COS10001', + abbreviation: '1.1P', + name: 'Hello World', + taskDefId: 42, + dueDate, + }); + component.task = task; + + const url = component.googleCalendarUrl; + + expect(url).toBe( + 'https://calendar.google.com/calendar/render?' + + 'action=TEMPLATE&' + + 'text=COS10001%3A%201.1P%3A%20Hello%20World&' + + 'dates=20260915%2F20260916', + ); + }); + + it('keeps the rendered and activated calendar URL current when the same task is mutated', () => { + const task = buildTask({dueDate: new Date(2026, 8, 15, 23, 59, 59, 999)}); + task.project = new Project(task.unit); + task.project.targetGrade = 0; + task.definition.dueDate = new Date(2026, 11, 1, 23, 59, 59, 999); + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + fixture.detectChanges(); + + const link: HTMLAnchorElement = fixture.nativeElement.querySelector( + 'a.add-to-google-calendar-link', + ); + expect(link.getAttribute('href')).toContain('dates=20260915%2F20260916'); + + task.dueDate = new Date(2026, 8, 22, 23, 59, 59, 999); + fixture.detectChanges(); + + expect(link.getAttribute('href')).toContain('dates=20260922%2F20260923'); + + task.unit.allowFlexibleDates = true; + task.targetDueDate = new Date(2026, 8, 29, 23, 59, 59, 999); + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + link.dispatchEvent(new KeyboardEvent('keydown', {key: ' ', cancelable: true})); + + expect(windowOpenSpy).toHaveBeenCalledWith( + expect.stringContaining('dates=20260929%2F20260930'), + '_blank', + 'noopener,noreferrer', + ); + + fixture.detectChanges(); + expect(link.getAttribute('href')).toContain('dates=20260929%2F20260930'); + }); + + it('produces the correct dates range for a due date the day before Melbourne DST starts, which the old MappingFunctions.addDays approach got wrong', () => { + // Confirmed against the ICU timezone database: 2026-10-03 is AEST (UTC+10), + // 2026-10-04 is AEDT (UTC+11). Melbourne's clocks spring forward overnight + // between these two dates. + // + // The old approach (before this fix) held event.date as a Date instant and + // called MappingFunctions.addDays, which adds a fixed 24 hours of real + // elapsed time, then read the result back via local calendar getters. For a + // task due 2026-10-03, Task.localDueDate() resolved to 23:59:59.999 local + // time, 13:59:59.999 UTC while still on AEST. Reconstructed here exactly, to + // prove what that approach actually produced, since the buggy code itself no + // longer exists in this file to call directly. + const dueDateInstantUtc = Date.UTC(2026, 9, 3, 13, 59, 59, 999); + const oldBuggyEndInstant = dueDateInstantUtc + 24 * 60 * 60 * 1000; + const melbourneCivilDate = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Australia/Melbourne', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + + // Adding a fixed 24 hours crosses the DST transition, so the wall-clock + // result lands an hour later than a plain "next day" would, which is enough + // to push it past midnight into a second day. The old approach's end date is + // 2026-10-05, not 2026-10-04, confirmed both by this direct reconstruction + // and empirically against the real ICU timezone database before writing this + // test. This assertion is the proof this test would fail against the old + // approach, not a description of it. + expect(melbourneCivilDate.format(oldBuggyEndInstant)).toBe('2026-10-05'); + + // The test container itself runs in UTC (confirmed separately), which has no + // DST, so exercising the real component pipeline in this environment cannot + // by itself distinguish old from new behaviour, UTC never crosses a DST + // transition regardless of which calendar date is chosen. The assertion + // above is what makes this test meaningful, the assertion below confirms + // the current, fixed production code produces the correct civil-date + // arithmetic (2026-10-03 to 2026-10-04) that the old approach failed to. + // Reuses dueDateInstantUtc, the same 23:59:59.999-Melbourne-local instant + // established above, as the realistic Task.dueDate fixture value. + const task = buildTask({dueDate: new Date(dueDateInstantUtc)}); + component.task = task; + + const url = component.googleCalendarUrl; + + expect(url).toContain('dates=20261003%2F20261004'); + }); + + it('opens the calendar URL in a new tab, with noopener and noreferrer, when Space is pressed on the link', () => { + // jsdom cannot assert on actual navigation, so this spies on window.open, the + // browser API handleCalendarLinkKeydown calls directly, and dispatches a real + // keydown so the (keydown) template binding itself is exercised, not just the + // handler method in isolation. ' ' is the real browser event.key value for the + // space bar, not code: 'Space'. + const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999); + const task = buildTask({dueDate}); + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + fixture.detectChanges(); + + const link: HTMLAnchorElement = fixture.nativeElement.querySelector( + 'a.add-to-google-calendar-link', + ); + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + const keyboardEvent = new KeyboardEvent('keydown', {key: ' ', cancelable: true}); + const preventDefaultSpy = vi.spyOn(keyboardEvent, 'preventDefault'); + + link.dispatchEvent(keyboardEvent); + + expect(preventDefaultSpy).toHaveBeenCalledOnce(); + expect(windowOpenSpy).toHaveBeenCalledWith( + component.googleCalendarUrl, + '_blank', + 'noopener,noreferrer', + ); + }); + + it('does not call window.open for a keydown that is not Space', () => { + // The handler now receives every keydown on the link, not just Space, since the + // template binding is plain (keydown) rather than Angular's (keydown.space) + // modifier. This proves the handler's own key check actually filters, it is not + // relying on Angular to only call it for the space key. + const dueDate = new Date(2026, 8, 15, 23, 59, 59, 999); + const task = buildTask({dueDate}); + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + fixture.detectChanges(); + + const link: HTMLAnchorElement = fixture.nativeElement.querySelector( + 'a.add-to-google-calendar-link', + ); + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + link.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', cancelable: true})); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + + describe('key dates timeline', () => { + function timelineTask(status: Task['status'] = 'not_started'): Task { + const task = buildTask({dueDate: new Date(2026, 8, 15, 23, 59, 59, 999)}); + task.project = new Project(task.unit); + task.project.targetGrade = 0; + task.project.specConDays = 0; + task.definition.dueDate = new Date(2026, 8, 29, 23, 59, 59, 999); + // Exercise the real feedback deadline, including special consideration, + // instead of the calendar-link fixture's due-date stub. + vi.mocked(task.localDeadlineDate).mockRestore(); + task.status = status; + return task; + } + + function render(task: Task): HTMLElement[] { + component.task = task; + component.taskDef = task.definition; + component.unit = task.unit; + fixture.detectChanges(); + return Array.from(fixture.nativeElement.querySelectorAll('.task-dates__step')); + } + + afterEach(() => { + vi.useRealTimers(); + }); + + it('lists the start, due and feedback dates in order', () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 10, 9, 0)); + + const steps = render(timelineTask()); + + expect( + steps.map((step) => step.querySelector('.task-dates__label').textContent.trim()), + ).toEqual(['Start', 'Due', 'Feedback by']); + expect( + steps.map((step) => step.querySelector('.task-dates__date').textContent.trim()), + ).toEqual(['Tue 1 Sep', 'Tue 15 Sep', 'Tue 29 Sep']); + }); + + it('includes special consideration in the feedback date without moving the due date', () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 10, 9, 0)); + const task = timelineTask(); + task.project.specConDays = 2; + + const steps = render(task); + + expect( + steps.map((step) => step.querySelector('.task-dates__date').textContent.trim()), + ).toEqual(['Tue 1 Sep', 'Tue 15 Sep', 'Thu 1 Oct']); + }); + + it('marks the dates that have passed and says when the next one is', () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 10, 9, 0)); + + const [start, due, feedback] = render(timelineTask()); + + expect(start.classList).toContain('task-dates__step--passed'); + expect(due.classList).toContain('task-dates__step--next'); + expect(due.querySelector('.task-dates__when').textContent.trim()).toBe('In 5 days'); + expect(feedback.classList).not.toContain('task-dates__step--next'); + expect(feedback.querySelector('.task-dates__when')).toBeNull(); + }); + + it('says Today for a date later the same day', () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 15, 9, 0)); + + const [, due] = render(timelineTask()); + + expect(due.querySelector('.task-dates__when').textContent.trim()).toBe('Today'); + }); + + it('fills the line between two dates by how much of the time has gone', () => { + vi.useFakeTimers({toFake: ['Date']}); + // Halfway between the start (1 Sept, midnight) and the due date (15 Sept, 23:59). + const start = new Date(2026, 8, 1).getTime(); + const due = new Date(2026, 8, 15, 23, 59, 59, 999).getTime(); + vi.setSystemTime(new Date(start + (due - start) / 2)); + + render(timelineTask()); + const [first, second] = component.keyDates; + + expect(first.progress).toBeCloseTo(0.5, 2); + expect(second.progress).toBe(0); + }); + + it('keeps the dates but drops the countdown once the task is submitted', () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 10, 9, 0)); + + const steps = render(timelineTask('ready_for_feedback')); + + expect(steps).toHaveLength(3); + expect(fixture.nativeElement.querySelector('.task-dates__step--next')).toBeNull(); + expect(fixture.nativeElement.querySelector('.task-dates__when')).toBeNull(); + }); + + it('puts a planned submit date after the feedback date in date order, and flags it', () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 10, 9, 0)); + const task = timelineTask(); + task.unit.allowFlexibleDates = true; + task.targetDueDate = new Date(2026, 8, 30, 23, 59, 59, 999); + task.definition.dueDate = new Date(2026, 8, 15, 23, 59, 59, 999); + + const steps = render(task); + + expect( + steps.map((step) => step.querySelector('.task-dates__label').textContent.trim()), + ).toEqual(['Planned start', 'Feedback by', 'Planned submit']); + // The nearer feedback date is the one to count down to. + expect(steps[1].classList).toContain('task-dates__step--next'); + expect(steps[1].querySelector('.task-dates__when').textContent.trim()).toBe('In 5 days'); + expect(steps[2].querySelector('.task-dates__warning').textContent).toContain( + 'After the feedback date', + ); + }); + + it('draws each change detection pass for one moment, so the development check agrees', () => { + let clock = new Date(2026, 8, 10, 9, 0).getTime(); + // Every read of the clock moves it on a minute and a bit, which would change the + // countdown and the line between Angular's check and its development re-check. + vi.spyOn(Date, 'now').mockImplementation(() => (clock += 61_000)); + + expect(() => render(timelineTask())).not.toThrow(); + }); + + it('notes an extension on the due date', () => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 10, 9, 0)); + const task = timelineTask(); + task.extensions = 2; + + const [, due] = render(task); + + expect(due.querySelector('.task-dates__note').textContent.trim()).toBe('Extended 2 weeks'); + }); + }); + + it('does not call window.open on Space when there is no resolvable due date', () => { + // Defensive coverage for handleCalendarLinkKeydown's own null guard. The anchor + // only renders inside @if (googleCalendarUrl), so this branch is unreachable through + // the DOM, called directly here rather than left untested. + const task = buildTask({dueDate: undefined}); + component.task = task; + + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + const keyboardEvent = new KeyboardEvent('keydown', {key: ' ', cancelable: true}); + + component.handleCalendarLinkKeydown(keyboardEvent); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); +}); + +function bareCard(): TaskDescriptionCardComponent { + return Object.create(TaskDescriptionCardComponent.prototype) as TaskDescriptionCardComponent; +} + +describe('TaskDescriptionCardComponent feedback deadline', () => { + const deadline = new Date('2026-09-01T00:00:00Z'); + + it('reads the task deadline when a task is present', () => { + const component = bareCard(); + component.task = {localDeadlineDate: () => deadline} as unknown as Task; + + expect(component.feedbackDate()).toBe(deadline); + }); + + it('falls back to the task-definition deadline when there is no task', () => { + const component = bareCard(); + component.task = undefined; + component.taskDef = {localDeadlineDate: () => deadline} as unknown as TaskDefinition; + + expect(component.feedbackDate()).toBe(deadline); + }); + + it('returns no deadline when neither model has one', () => { + const component = bareCard(); + component.task = undefined; + component.taskDef = undefined; + + expect(component.feedbackDate()).toBeUndefined(); + }); + + it('emphasises only task deadlines within fourteen days', () => { + const near = bareCard(); + near.task = {daysUntilDeadlineDate: () => 10} as unknown as Task; + expect(near.shouldShowDeadline()).toBe(true); + + const far = bareCard(); + far.task = {daysUntilDeadlineDate: () => 21} as unknown as Task; + expect(far.shouldShowDeadline()).toBe(false); + }); + + it('does not emphasise a deadline when there is no task', () => { + const component = bareCard(); + component.task = undefined; + + expect(component.shouldShowDeadline()).toBeFalsy(); + }); +}); diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.ts index 87696430e1..a6a31fdee8 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.ts @@ -1,15 +1,120 @@ import { ChangeDetectionStrategy, Component, + DoCheck, EventEmitter, Inject, Input, Output, } from '@angular/core'; import {Task, TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; +import {WebCalEvent, buildCalendarEvent} from 'src/app/api/services/calendar-event-builder'; import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; import {GradeService} from 'src/app/common/services/grade.service'; +function formatGoogleCalendarDate(civilDate: string): string { + return civilDate.replaceAll('-', ''); +} + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function daysInMonth(year: number, month: number): number { + const daysByMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return daysByMonth[month - 1]; +} + +/** + * Adds one day to a 'YYYY-MM-DD' civil date using plain integer arithmetic, with no Date + * object and no timezone involved anywhere. A civil date has no time-of-day, so it needs + * no DST awareness, this sidesteps the DST bug entirely rather than working around it. + * MappingFunctions.addDays previously did this step by adding a fixed 86,400,000 ms to a + * Date instant, which lands on the wrong calendar day across a DST transition, confirmed + * for a task due the day before Melbourne's October transition, see the spec for CAL-F01. + */ +function addOneCivilDay(civilDate: string): string { + let [year, month, day] = civilDate.split('-').map(Number); + + day += 1; + if (day > daysInMonth(year, month)) { + day = 1; + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** + * Builds a Google Calendar "add event" link from a calendar event snapshot. The dates + * range end is exclusive, one day after the event's date, since that is what Google's + * all-day date range expects. This is a different convention to webcal.rb's ICS output, + * which sets DTEND == DTSTART for the same single day, see calendar-event-builder.ts for + * why those two conventions cannot share one date pair. formatGoogleCalendarDate is + * calibrated for this URL only, CAL-F02's ICS output must not reuse it. + */ +function buildGoogleCalendarUrl(event: WebCalEvent): string { + const start = formatGoogleCalendarDate(event.date); + const end = formatGoogleCalendarDate(addOneCivilDay(event.date)); + + const params = [ + ['action', 'TEMPLATE'], + ['text', event.title], + ['dates', `${start}/${end}`], + ] + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&'); + + return `https://calendar.google.com/calendar/render?${params}`; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** One date on the description card's timeline. */ +export interface TaskKeyDate { + key: 'start' | 'due' | 'feedback'; + label: string; + date: Date; + passed: boolean; + /** The first date still to come, while the task is still the student's to work on. */ + next: boolean; + /** When the next date is, in words, such as "In 3 days". Only set on the next date. */ + when?: string; + /** An extension on the due date, in words. */ + note?: string; + /** A planned submit date after the feedback date, which means no feedback. */ + warning?: string; + /** How much of the time between this date and the following one has gone, 0 to 1. */ + progress: number; +} + +function isValidDate(value: Date | undefined | null): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} + +function startOfDay(value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +} + +/** Calendar days from today to the date, so a date later today is "Today". */ +function describeWhen(date: Date, now: Date): string { + const days = Math.round((startOfDay(date) - startOfDay(now)) / DAY_MS); + if (days <= 0) { + return 'Today'; + } + if (days === 1) { + return 'Tomorrow'; + } + if (days < 14) { + return `In ${days} days`; + } + return `In ${Math.floor(days / 7)} weeks`; +} + @Component({ selector: 'f-task-description-card', templateUrl: 'task-description-card.component.html', @@ -17,7 +122,7 @@ import {GradeService} from 'src/app/common/services/grade.service'; changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class TaskDescriptionCardComponent { +export class TaskDescriptionCardComponent implements DoCheck { @Output() switchView$: EventEmitter = new EventEmitter(); @Input() task: Task; @@ -29,6 +134,13 @@ export class TaskDescriptionCardComponent { acronyms: GradeService['gradeAcronyms']; }; + /** + * The time the timeline is drawn for. Read once per change detection pass, in + * ngDoCheck, so the development-mode second check sees the same value and the + * countdown and the line never change between the two checks. + */ + private now = Date.now(); + constructor( private GradeService: GradeService, @Inject(FileDownloaderService) private fileDownloader: FileDownloaderService, @@ -39,10 +151,14 @@ export class TaskDescriptionCardComponent { }; } + ngDoCheck(): void { + this.now = Date.now(); + } + public downloadTaskSheet() { this.fileDownloader.downloadFile( this.taskDef.getTaskPDFUrl(true), - `${this.unit.code}-${this.taskDef.abbreviation}-TaskSheet.pdf`, + this.taskDef.effectiveTaskSheetFilename, ); } @@ -72,13 +188,133 @@ export class TaskDescriptionCardComponent { } public feedbackDate(): Date { + // The task's deadline adds the project's special consideration days, so it needs + // the project; without one, fall back to the definition's own deadline. if (this.task) { - return this.task.localDeadlineDate(); + try { + return this.task.localDeadlineDate(); + } catch { + // An incompletely mapped task can still use its definition's deadline. + } + } + try { + return this.taskDef?.localDeadlineDate(); + } catch { + return undefined; } - return this.taskDef?.localDeadlineDate(); + } + + public get allowsFlexibleDates(): boolean { + return !!(this.unit?.allowFlexibleDates ?? this.taskDef?.unit?.allowFlexibleDates); + } + + /** + * Start, due and feedback dates in order, for the timeline on the card. Dates the + * task does not have are left out. Once the task is submitted or finished the + * dates stay as a record, without a "next" date or a countdown. + */ + public get keyDates(): TaskKeyDate[] { + const flexible = this.allowsFlexibleDates; + const extensions = this.task?.extensions ?? 0; + const dueDate = this.dueDate(); + const feedbackDate = this.feedbackDate(); + const submitsTooLate = + flexible && + isValidDate(dueDate) && + isValidDate(feedbackDate) && + dueDate.getTime() > feedbackDate.getTime(); + + const candidates: Pick[] = [ + {key: 'start', label: flexible ? 'Planned start' : 'Start', date: this.startDate()}, + { + key: 'due', + label: flexible ? 'Planned submit' : 'Due', + date: dueDate, + note: + extensions > 0 ? `Extended ${extensions} week${extensions > 1 ? 's' : ''}` : undefined, + warning: submitsTooLate ? 'After the feedback date' : undefined, + }, + {key: 'feedback', label: 'Feedback by', date: feedbackDate}, + ]; + + // In date order, so the line reads left to right in time. A planned submit + // date can fall after the feedback date, and then it is drawn after it. + const entries = candidates + .filter((entry) => isValidDate(entry.date)) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + const nowMs = this.now; + const now = new Date(nowMs); + const settled = !this.task || this.task.inSubmittedState() || this.task.inFinalState(); + let nextFound = settled; + + return entries.map((entry, index) => { + const time = entry.date.getTime(); + const passed = time < nowMs; + const next = !passed && !nextFound; + nextFound ||= next; + + const following = entries[index + 1]?.date.getTime(); + let progress = 0; + if (following !== undefined) { + progress = + following > time + ? Math.min(Math.max((nowMs - time) / (following - time), 0), 1) + : Number(nowMs >= following); + } + + return { + ...entry, + passed, + next, + when: next ? describeWhen(entry.date, now) : undefined, + progress, + }; + }); } public shouldShowDeadline(): boolean { return this.task && this.task.daysUntilDeadlineDate() <= 14; } + + /** + * Google Calendar's TEMPLATE link has no identifier parameter, so this always opens a + * fresh "add event" dialog rather than updating an existing calendar entry. The date is + * a snapshot of the due date at click time and will not track later changes. That is + * inherent to this mechanism, not a defect. + * + * Task entities are updated in place when planned dates or extensions change, so this + * derives the URL from the current task state instead of caching it by object identity. + * The template aliases this getter in its @if block, so it is evaluated only once per + * change detection pass for both visibility and href. + */ + public get googleCalendarUrl(): string | null { + if (!this.task) { + return null; + } + + const event = buildCalendarEvent(this.task); + return event ? buildGoogleCalendarUrl(event) : null; + } + + /** + * An responds natively to Enter but not Space. Binds plain (keydown) rather than + * Angular's (keydown.space) modifier syntax, since the template type checker cannot + * infer KeyboardEvent through that modifier and falls back to the plain Event type, + * this way the parameter is genuinely typed as KeyboardEvent with no cast needed. + * Navigates directly with window.open rather than simulating a click on the anchor, + * since googleCalendarUrl is already a typed string here, no DOM element reference or + * type narrowing is needed to use it. The noopener,noreferrer window features match + * the anchor's own rel attribute. + */ + public handleCalendarLinkKeydown(event: KeyboardEvent): void { + if (event.key !== ' ') { + return; + } + + event.preventDefault(); + if (this.googleCalendarUrl) { + window.open(this.googleCalendarUrl, '_blank', 'noopener,noreferrer'); + } + } } diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.ts index 33c87996d2..4a15f0d512 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-similarity-view/task-similarity-view.component.ts @@ -4,10 +4,13 @@ import { Component, Input, OnChanges, + QueryList, SimpleChanges, ViewChild, + ViewChildren, } from '@angular/core'; -import {MatAccordion} from '@angular/material/expansion'; +import {MatExpansionPanelHeader} from '@angular/material/expansion'; +import {Subscription} from 'rxjs'; import {Task} from 'src/app/api/models/task'; import {TaskSimilarity} from 'src/app/api/models/task-similarity'; import {TaskSimilarityService} from 'src/app/api/services/task-similarity.service'; @@ -20,15 +23,22 @@ import {SelectedTaskService} from '../../../../selected-task.service'; selector: 'f-task-similarity-view', templateUrl: './task-similarity-view.component.html', styleUrls: ['./task-similarity-view.component.scss'], + // The tab owns its scroll: the header stays put and only the list moves. + host: {class: 'flex h-full min-h-0 flex-col'}, changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) export class TaskSimilarityViewComponent implements OnChanges { @Input() task: Task; - @ViewChild(MatAccordion) accordion: MatAccordion; @ViewChild('jplagViewer') jplagViewer!: JplagReportViewerComponent; + @ViewChildren(MatExpansionPanelHeader) panelHeaders?: QueryList; panelOpenState = false; jplagOpenState = false; + loading = false; + /** The last fetch failed, so the tab offers a retry instead of saying there are none. */ + loadError = false; + + private similaritiesSub?: Subscription; constructor( private taskSimilarityService: TaskSimilarityService, @@ -40,11 +50,55 @@ export class TaskSimilarityViewComponent implements OnChanges { ngOnChanges(changes: SimpleChanges) { if (changes.task && changes.task.currentValue && this.task?.id) { this.jplagOpenState = false; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - this.task?.fetchSimilarities().subscribe((_) => { - console.log('similarities fetched'); - }); + this.loadSimilarities(); + } + } + + loadSimilarities(): void { + // A task change can start a fetch before the last one returns, so drop the older one. + this.similaritiesSub?.unsubscribe(); + this.loading = true; + this.loadError = false; + + this.similaritiesSub = this.task.fetchSimilarities().subscribe({ + next: () => { + this.loading = false; + }, + error: () => { + this.loading = false; + this.loadError = true; + }, + complete: () => { + this.loading = false; + }, + }); + } + + /** Collapse all is only worth showing while there is something open to collapse. */ + hasOpenPanel(similarities: readonly TaskSimilarity[]): boolean { + return similarities.some((similarity) => similarity.parts?.some((part) => part.panelOpenState)); + } + + // The panels bind their expanded state to the part, so clearing it closes them. + collapseAll(): void { + for (const similarity of this.task?.similarityCache.currentValues ?? []) { + for (const part of similarity.parts ?? []) { + part.panelOpenState = false; + } } + this.focusFirstPanel(); + } + + closeJplagReport(): void { + this.jplagOpenState = false; + this.focusFirstPanel(); + } + + // Collapse all and Back to summary both leave the page once used, which would drop + // keyboard focus to the page body. Hand it to the first similarity instead, once the + // list has rendered. + private focusFirstPanel(): void { + setTimeout(() => this.panelHeaders?.first?.focus()); } toggleFlag(e: Event, similarity: TaskSimilarity) { diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html index 7cfe442732..d69f2d1b64 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html @@ -1,20 +1,26 @@ -
      -
      - comment -

      Moderation Notes for {{ unitRole?.user?.name }}

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

      + Moderation notes +

      + } +

      {{ description }}

      @if (task) { -

      - {{ task.definition.abbreviation }} {{ task.definition.name }} ({{ - task.project.student.name - }}) +

      + {{ task.definition.abbreviation }} {{ task.definition.name }} + @if (task.project?.student?.displayName; as studentName) { + ({{ studentName }}) + }

      } -

      - Notes left here are for staff-only discussion about the feedback given to the student. Use this - space to question, clarify, or discuss the tutor’s feedback. These notes are visible to - {{ unitRole?.user?.name }} and other convenors. -

      +
      - -
      + diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html index 71f90d18ee..1abcd6036a 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.html @@ -1,6 +1,6 @@
      -
      +
      -
      +
      @for (row of skeletonRows; track row) {
      -@if (project$ | async; as project) { -
      -
      - @if (subs$ | async) { +@if (projectLoadFailed) { +
      +
      + +
      + + Go to your units +
      +
      +
      +} @else if (project$ | async; as project) { + @let selectedTaskDefinition = selectedTaskDefinition$ | async; +
      + @if (isPhoneLayout) { + @if (selectedTaskDefinition && mobilePane !== 'overview') { +
      +
      +
      + {{ selectedTaskDefinition.abbreviation }} +
      +
      + {{ selectedTaskDefinition.name }} +
      +
      +
      + } + + + +
      @if (isProjectTaskListReady(project)) { } @else { -
      +
      } -
      -
      -
      - } - @if (selectedTaskDefinition$ | async; as selectedTaskDefinition) { -
      - -
      - @if (isCommentsNarrow && commentsCollapsed) { - +
      + @if (isProjectTaskListReady(project)) { + + } @else { + + } +
      + + } @else if (selectedTaskDefinition) { +
      +
      + @if (mobilePane === 'task') { + + } @else { + + } +
      +
      } -
      + } @else { + + + - @if (isCommentsNarrow && !commentsCollapsed) { - + @if (isProjectTaskListReady(project)) { + + } @else { + } - - -
      - } @else if (!isProjectTaskListReady(project)) { -
      -
      - - - -
      -
      - } @else { -
      - -
      - } -
      -
      -} + - + + @if (selectedTaskDefinition) { + + } @else if (!isProjectTaskListReady(project)) { +
      + +
      + } @else { +
      + +
      + } +
      - + diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.scss b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.scss index 133f8df83f..c9e7305f40 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.scss +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.scss @@ -1,73 +1,212 @@ :host { display: block; + color: var(--ot-color-text); } -.comments-floating-toggle { - position: fixed; - top: 50%; - right: -20px; - z-index: 300; - opacity: 0.55; +.project-dashboard-shell { + // The demo banner, when shown, sits above the header and publishes its height. + height: calc((var(--vh, 1vh) * 100) - 100px - var(--ot-demo-banner-height, 0px)); + overflow: hidden; +} + +.desktop-task-dashboard { + display: block; + height: 100%; +} + +.mobile-task-view, +.mobile-overview-view { + display: flex; + height: 100%; + width: 100%; + flex-direction: column; + overflow: hidden; + border-radius: 0.5rem; +} + +.mobile-task-heading { + display: flex; + min-height: 48px; + align-items: center; + gap: 0.25rem; + padding-right: 0.75rem; + border-bottom: 1px solid var(--ot-color-divider); + background: var(--ot-color-surface); +} + +.mobile-task-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-top: 1px solid var(--ot-color-divider); + border-bottom: 1px solid var(--ot-color-divider); + background: var(--ot-color-surface); +} + +.mobile-task-tab { + display: flex; + min-width: 0; + min-height: 52px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.125rem; + padding: 0.25rem 0.125rem; + border-bottom: 3px solid transparent; + color: var(--ot-color-text-muted); + font-size: 0.75rem; + font-weight: 600; + line-height: 1; transition: - opacity 150ms ease, - transform 150ms ease; - transform: translateY(-50%); + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; } -.comments-floating-toggle mat-icon { - position: absolute; - inset: 0; - margin: auto; - font-size: 20px; - height: 20px; +.mobile-task-tab mat-icon { width: 20px; - transition: opacity 150ms ease; + height: 20px; + font-size: 20px; +} + +.mobile-task-tab:hover, +.mobile-task-tab:focus-visible { + background: var(--ot-color-hover); +} + +.mobile-task-tab:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: -2px; +} + +// The 3px underline is the selected-state indicator (3:1). Primary on the selected fill +// measures 2.18:1 in dark, link gives 4.55:1 dark and 5.39:1 light. +.mobile-task-tab--active { + border-bottom-color: var(--ot-color-link); + background: var(--ot-color-selected); + color: var(--ot-color-selected-text); +} + +.mobile-task-tab:disabled { + cursor: default; + background: var(--ot-color-surface); + color: var(--ot-color-disabled-text); } -.comments-floating-toggle:hover, -.comments-floating-toggle:focus-visible { - opacity: 1; - transform: translate(-20px, -50%); +.mobile-task-pane { + min-width: 0; + min-height: 0; + flex: 1; + overflow: hidden; + background: var(--ot-color-surface); } -.comments-floating-toggle__edge-icon { - opacity: 1; - transform: translateX(-6px); +.mobile-task-pane > f-task-dashboard, +.mobile-task-pane > task-comments-viewer { + display: block; + width: 100%; + height: 100%; + min-width: 0; + overflow: hidden; } -.comments-floating-toggle__hover-icon { - opacity: 0; +.mobile-overview-pane { + min-width: 0; + min-height: 0; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + background: var(--ot-color-surface); } -.comments-floating-toggle:hover .comments-floating-toggle__edge-icon, -.comments-floating-toggle:focus-visible .comments-floating-toggle__edge-icon { - opacity: 0; +.mobile-overview-pane > f-progress-dashboard { + min-width: 0; } -.comments-floating-toggle:hover .comments-floating-toggle__hover-icon, -.comments-floating-toggle:focus-visible .comments-floating-toggle__hover-icon { - opacity: 1; +@media (max-width: 639.98px) { + .project-dashboard-shell { + display: block; + height: auto; + min-height: 0; + overflow: visible; + padding-bottom: var(--mobile-page-bottom-padding); + } + + // Only the compact task navigation stays in view. The selected-task heading, + // application header, and demo banner remain part of the document flow. + .mobile-task-tabs { + position: sticky; + top: 0; + z-index: 20; + min-height: var(--mobile-task-tabs-height); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + } + + .project-dashboard-layout { + display: block; + height: auto !important; + min-height: 0; + width: 100%; + overflow: visible; + } + + .project-task-list-panel { + display: block; + height: auto !important; + width: 100% !important; + min-width: 0; + padding-right: 0.25rem !important; + } + + .mobile-task-view, + .mobile-overview-view, + .mobile-task-pane, + .mobile-overview-pane { + display: block; + height: auto; + min-height: 0; + width: 100%; + overflow: visible; + } + + .mobile-task-pane > f-task-dashboard, + .mobile-task-pane > task-comments-viewer { + display: block; + height: auto; + min-height: 0; + width: 100%; + overflow: visible; + } + + .mobile-overview-pane { + overscroll-behavior-y: auto; + -webkit-overflow-scrolling: auto; + } + + .project-task-list-panel--phone-hidden { + display: none !important; + } } -.comments-sidebar { - position: relative; +// The comments panel: the viewer fills it, and full screen gives it a centred column +// wide enough for longer messages without a line running edge to edge. +.project-comments__viewer { + display: block; + min-height: 0; + flex: 1; } -.comments-sidebar--narrow { - flex-basis: 375px !important; - min-width: 375px; - width: 375px !important; - border-radius: 0 !important; - box-shadow: -2px 0 6px rgba(15, 23, 42, 0.08); - z-index: 250; +.project-comments__task { + margin-left: 0.375rem; + color: var(--ot-color-text-muted); + font-weight: 400; } -.comments-sidebar__collapse { - position: absolute; - top: 50%; - left: -20px; - z-index: 260; - transform: translateY(-50%); - background: white; - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18); +.project-comments.app-panel--fullscreen .project-comments__viewer { + --comment-bubble-max-width: min(42rem, 75%); + --comments-gutter: 1.5rem; + width: 100%; + max-width: 72rem; + margin-inline: auto; } diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.spec.ts b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.spec.ts new file mode 100644 index 0000000000..d12eadcc8f --- /dev/null +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.spec.ts @@ -0,0 +1,550 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {BreakpointObserver} from '@angular/cdk/layout'; +import {CommonModule} from '@angular/common'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ActivatedRoute, Router, convertToParamMap} from '@angular/router'; +import {BehaviorSubject, EMPTY, Subject, of, tap, throwError} from 'rxjs'; +import {Project, Task, TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UnitService} from 'src/app/api/services/unit.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {GlobalStateService, ViewType} from '../../index/global-state.service'; +import {ProjectDashboardComponent} from './project-dashboard.component'; + +describe('ProjectDashboardComponent task selection', () => { + let component: ProjectDashboardComponent; + let fixture: ComponentFixture; + let taskStatusUpdated$: Subject; + let taskSubmissionCompleted$: Subject; + let phoneLayout$: BehaviorSubject<{matches: boolean; breakpoints: object}>; + + // findTaskForDefinition is read when the phone layout opens the selected task. + const project = {id: 7, findTaskForDefinition: () => undefined} as unknown as Project; + const selectedTaskDefinition = {id: 42} as TaskDefinition; + + beforeEach(async () => { + taskStatusUpdated$ = new Subject(); + taskSubmissionCompleted$ = new Subject(); + phoneLayout$ = new BehaviorSubject({matches: false, breakpoints: {}}); + + await TestBed.configureTestingModule({ + declarations: [ProjectDashboardComponent], + providers: [ + {provide: UserService, useValue: {}}, + {provide: ProjectService, useValue: {get: () => EMPTY}}, + { + provide: TaskService, + useValue: {taskStatusUpdated$, taskSubmissionCompleted$}, + }, + {provide: UnitService, useValue: {}}, + {provide: GlobalStateService, useValue: {setView: vi.fn()}}, + { + provide: ActivatedRoute, + useValue: { + parent: { + snapshot: { + data: {project}, + paramMap: {get: () => String(project.id)}, + }, + }, + }, + }, + { + provide: BreakpointObserver, + // ngOnInit observes the phone breakpoint. The panel layout owns the rest. + useValue: {observe: vi.fn().mockReturnValue(phoneLayout$)}, + }, + {provide: Router, useValue: {navigate: vi.fn().mockResolvedValue(true)}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(ProjectDashboardComponent, {set: {template: ''}}) + .compileComponents(); + + fixture = TestBed.createComponent(ProjectDashboardComponent); + component = fixture.componentInstance; + component.project$ = of(project); + fixture.detectChanges(); + component.selectedTaskDefinition$.next(selectedTaskDefinition); + }); + + it('keeps the selected task when an unrelated status-change event is emitted', () => { + taskStatusUpdated$.next({project, definition: selectedTaskDefinition} as Task); + + expect(component.selectedTaskDefinition$.value).toBe(selectedTaskDefinition); + }); + + it('keeps the submitted task selected after its upload completes', () => { + taskSubmissionCompleted$.next({project, definition: selectedTaskDefinition} as Task); + + expect(component.selectedTaskDefinition$.value).toBe(selectedTaskDefinition); + expect(component.mobilePane).toBe('task'); + }); + + it('ignores submission events from another project or task definition', () => { + taskSubmissionCompleted$.next({ + project: {id: project.id + 1}, + definition: selectedTaskDefinition, + } as Task); + expect(component.selectedTaskDefinition$.value).toBe(selectedTaskDefinition); + + taskSubmissionCompleted$.next({ + project, + definition: {id: selectedTaskDefinition.id + 1}, + } as Task); + expect(component.selectedTaskDefinition$.value).toBe(selectedTaskDefinition); + }); + + it('drops the full-screen chat when the task closes', () => { + component.fullscreenPanel = 'comments'; + + // A full-screen chat with no task behind it would leave nothing to exit back to. + component.selectedTaskDefinition$.next(null); + + expect(component.fullscreenPanel).toBeNull(); + }); + + it('leaves full screen for the phone layout', () => { + component.fullscreenPanel = 'comments'; + + phoneLayout$.next({matches: true, breakpoints: {}}); + + expect(component.fullscreenPanel).toBeNull(); + }); +}); + +describe('ProjectDashboardComponent route reuse', () => { + let firstProject: Project; + let secondProject: Project; + let firstUnit: Unit; + let secondUnit: Unit; + + beforeEach(() => { + firstUnit = { + id: 101, + taskDefinitions: [], + studentCache: {add: vi.fn()}, + } as unknown as Unit; + secondUnit = { + id: 202, + taskDefinitions: [], + studentCache: {add: vi.fn()}, + } as unknown as Unit; + firstProject = {id: 2, targetGrade: 0, unit: firstUnit} as Project; + secondProject = {id: 18, targetGrade: 0, unit: secondUnit} as Project; + }); + + it('loads and displays each project emitted by a reused parent route', () => { + const routeProjects: BehaviorSubject = new BehaviorSubject(firstProject); + const loadedProjects = new Map([ + [firstProject.id, firstProject], + [secondProject.id, secondProject], + ]); + const loadedUnits = new Map([ + [firstUnit.id, firstUnit], + [secondUnit.id, secondUnit], + ]); + const projectGet = vi.fn( + (params: {id: number}, options: {mappingCompleteCallback: (project: Project) => void}) => { + const project = loadedProjects.get(params.id); + options.mappingCompleteCallback(project); + return of(project); + }, + ); + const unitGet = vi.fn((id: number) => of(loadedUnits.get(id))); + const setView = vi.fn(); + const component = new ProjectDashboardComponent( + {} as UserService, + {get: projectGet} as unknown as ProjectService, + {taskSubmissionCompleted$: new Subject()} as unknown as TaskService, + {get: unitGet} as unknown as UnitService, + {setView} as unknown as GlobalStateService, + { + parent: { + data: of({project: firstProject}), + snapshot: {paramMap: convertToParamMap({projectId: firstProject.id})}, + }, + } as unknown as ActivatedRoute, + {observe: () => of({matches: false, breakpoints: {}})} as unknown as BreakpointObserver, + {navigate: vi.fn().mockResolvedValue(true)} as unknown as Router, + ); + component.project$ = routeProjects.asObservable(); + + component.ngOnInit(); + component.selectedTaskDefinition$.next({id: 999} as TaskDefinition); + routeProjects.next(secondProject); + + let displayedProject: Project; + component.project$.subscribe((project) => (displayedProject = project)); + + expect(projectGet.mock.calls.map(([params]) => params.id)).toEqual([2, 18]); + expect(unitGet.mock.calls.map(([id]) => id)).toEqual([101, 202]); + expect(displayedProject.id).toBe(18); + expect(component.selectedTaskDefinition$.value).toBeNull(); + expect(setView).toHaveBeenLastCalledWith(ViewType.PROJECT, secondProject); + + component.ngOnDestroy(); + }); + + describe('signed off check', () => { + const build = (options: {view?: boolean; taskSelectionUrlBase?: unknown[] | null} = {}) => { + const navigate = vi.fn().mockResolvedValue(true); + const checkProject = vi.fn().mockResolvedValue(options.view ? 'view' : null); + const component = new ProjectDashboardComponent( + {} as UserService, + { + get: ( + params: {id: number}, + callbacks: {mappingCompleteCallback: (project: Project) => void}, + ) => { + callbacks.mappingCompleteCallback(firstProject); + return of(firstProject); + }, + } as unknown as ProjectService, + {taskSubmissionCompleted$: new Subject()} as unknown as TaskService, + {get: () => of(firstUnit)} as unknown as UnitService, + {setView: vi.fn()} as unknown as GlobalStateService, + { + parent: { + data: of({project: firstProject}), + snapshot: {paramMap: convertToParamMap({projectId: firstProject.id})}, + }, + } as unknown as ActivatedRoute, + {observe: () => of({matches: false, breakpoints: {}})} as unknown as BreakpointObserver, + {navigate} as unknown as Router, + undefined, + undefined, + {checkProject} as never, + ); + component.taskSelectionUrlBase = options.taskSelectionUrlBase ?? null; + component.project$ = of(firstProject); + return {component, checkProject, navigate}; + }; + + it('checks once when the project has loaded, without a preview by default', () => { + const {component, checkProject} = build(); + + component.ngOnInit(); + component.retryProjectLoad(); + + expect(checkProject).toHaveBeenCalledTimes(1); + expect(checkProject).toHaveBeenCalledWith(firstProject, {preview: false}); + component.ngOnDestroy(); + }); + + it('never checks inside the staff portfolio view', () => { + const {component, checkProject} = build({taskSelectionUrlBase: ['/units', 1]}); + + component.ngOnInit(); + + expect(checkProject).not.toHaveBeenCalled(); + component.ngOnDestroy(); + }); + + it('filters the task list to completed tasks when asked to view them', async () => { + const {component, navigate} = build({view: true}); + + component.ngOnInit(); + await Promise.resolve(); + await Promise.resolve(); + + expect(navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: {taskStatus: 'complete', taskView: 'tasks'}, + queryParamsHandling: 'merge', + }), + ); + component.ngOnDestroy(); + }); + + it('opens a collapsed task list when a status card filters it', () => { + vi.useFakeTimers(); + const {component} = build({view: true}); + const expandFromRail = vi.fn(); + (component as unknown as {taskListPanel: {expandFromRail: () => void}}).taskListPanel = { + expandFromRail, + }; + + (component as unknown as {revealTaskList: () => void}).revealTaskList(); + vi.runAllTimers(); + + expect(expandFromRail).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + }); + + // A wrong or stale link, or no access, left the page on its skeleton forever. + it('shows a failed load instead of the skeleton, and loads again on retry', () => { + const projectGet = vi + .fn() + .mockReturnValueOnce(throwError(() => new Error('404'))) + .mockImplementation( + (params: {id: number}, options: {mappingCompleteCallback: (project: Project) => void}) => { + options.mappingCompleteCallback(firstProject); + return of(firstProject); + }, + ); + const component = new ProjectDashboardComponent( + {} as UserService, + {get: projectGet} as unknown as ProjectService, + {taskSubmissionCompleted$: new Subject()} as unknown as TaskService, + {get: vi.fn(() => of(firstUnit))} as unknown as UnitService, + {setView: vi.fn()} as unknown as GlobalStateService, + { + parent: { + data: of({project: firstProject}), + snapshot: {paramMap: convertToParamMap({projectId: firstProject.id})}, + }, + } as unknown as ActivatedRoute, + {observe: () => of({matches: false, breakpoints: {}})} as unknown as BreakpointObserver, + {navigate: vi.fn().mockResolvedValue(true)} as unknown as Router, + ); + component.project$ = new BehaviorSubject(firstProject).asObservable(); + + component.ngOnInit(); + expect(component.projectLoadFailed).toBe(true); + + component.retryProjectLoad(); + expect(component.projectLoadFailed).toBe(false); + expect(projectGet).toHaveBeenCalledTimes(2); + + component.ngOnDestroy(); + }); + + it('ignores a delayed response from an earlier visit to the same project', () => { + const routeProjects: BehaviorSubject = new BehaviorSubject(firstProject); + const projectResponses: Subject[] = []; + const projectGet = vi.fn( + (_params: {id: number}, options: {mappingCompleteCallback: (project: Project) => void}) => { + const response: Subject = new Subject(); + projectResponses.push(response); + return response.pipe(tap((project) => options.mappingCompleteCallback(project))); + }, + ); + const oldUnit = {...firstUnit, id: 301} as Unit; + const latestUnit = {...firstUnit, id: 303} as Unit; + const oldProject = {id: firstProject.id, targetGrade: 0, unit: oldUnit} as Project; + const latestProject = {id: firstProject.id, targetGrade: 0, unit: latestUnit} as Project; + const unitGet = vi.fn((id: number) => of(id === latestUnit.id ? latestUnit : oldUnit)); + const component = new ProjectDashboardComponent( + {} as UserService, + {get: projectGet} as unknown as ProjectService, + {taskSubmissionCompleted$: new Subject()} as unknown as TaskService, + {get: unitGet} as unknown as UnitService, + {setView: vi.fn()} as unknown as GlobalStateService, + { + parent: { + data: of({project: firstProject}), + snapshot: {paramMap: convertToParamMap({projectId: firstProject.id})}, + }, + } as unknown as ActivatedRoute, + {observe: () => of({matches: false, breakpoints: {}})} as unknown as BreakpointObserver, + {navigate: vi.fn().mockResolvedValue(true)} as unknown as Router, + ); + component.project$ = routeProjects.asObservable(); + + component.ngOnInit(); + routeProjects.next(secondProject); + routeProjects.next({...firstProject} as Project); + projectResponses[2].next(latestProject); + projectResponses[0].next(oldProject); + + let displayedProject: Project; + component.project$.subscribe((project) => (displayedProject = project)); + + expect(displayedProject).toBe(latestProject); + expect(unitGet).toHaveBeenCalledTimes(1); + expect(unitGet).toHaveBeenCalledWith(latestUnit.id); + + component.ngOnDestroy(); + }); + + it('cancels a pending unit load when a reused route changes projects', () => { + const routeProjects: BehaviorSubject = new BehaviorSubject(firstProject); + const oldUnitResponse: Subject = new Subject(); + const delayedOldUnit = { + ...firstUnit, + studentCache: {add: vi.fn()}, + } as unknown as Unit; + const latestUnit = {...firstUnit, id: 303} as Unit; + const oldProject = {id: firstProject.id, targetGrade: 0, unit: firstUnit} as Project; + const latestProject = {id: firstProject.id, targetGrade: 0, unit: latestUnit} as Project; + const loadedProjects = [oldProject, secondProject, latestProject]; + let projectLoad = 0; + const projectGet = vi.fn( + (_params: {id: number}, options: {mappingCompleteCallback: (project: Project) => void}) => { + const project = loadedProjects[projectLoad++]; + options.mappingCompleteCallback(project); + return of(project); + }, + ); + const unitGet = vi.fn((id: number) => { + if (id === firstUnit.id) { + return oldUnitResponse; + } + return of(id === secondUnit.id ? secondUnit : latestUnit); + }); + const component = new ProjectDashboardComponent( + {} as UserService, + {get: projectGet} as unknown as ProjectService, + {taskSubmissionCompleted$: new Subject()} as unknown as TaskService, + {get: unitGet} as unknown as UnitService, + {setView: vi.fn()} as unknown as GlobalStateService, + { + parent: { + data: of({project: firstProject}), + snapshot: {paramMap: convertToParamMap({projectId: firstProject.id})}, + }, + } as unknown as ActivatedRoute, + {observe: () => of({matches: false, breakpoints: {}})} as unknown as BreakpointObserver, + {navigate: vi.fn().mockResolvedValue(true)} as unknown as Router, + ); + component.project$ = routeProjects.asObservable(); + + component.ngOnInit(); + routeProjects.next(secondProject); + routeProjects.next({...firstProject} as Project); + oldUnitResponse.next(delayedOldUnit); + + let displayedProject: Project; + component.project$.subscribe((project) => (displayedProject = project)); + + expect(displayedProject).toBe(latestProject); + expect(oldProject.unit).toBe(firstUnit); + expect(delayedOldUnit.studentCache.add).not.toHaveBeenCalled(); + + component.ngOnDestroy(); + }); +}); + +describe('ProjectDashboardComponent rendered by its host', () => { + let fixture: ComponentFixture; + let component: ProjectDashboardComponent; + let hostProject$: BehaviorSubject; + let project: Project; + let unit: Unit; + let projectResponses: Subject[]; + let projectGet: ReturnType; + let unitGet: ReturnType; + let routerNavigate: ReturnType; + + // The portfolios progress tab hands its dashboard a new task selection url every check, because + // the host builds that array in a getter. Each one re-runs the host's ngOnChanges, which pushes + // the project it is already showing back down the same subject. + const rebindHost = (): void => { + fixture.componentRef.setInput('taskSelectionUrlBase', [ + '/units', + 1, + 'students', + 'portfolios', + project.id, + 'progress', + ]); + hostProject$.next(project); + fixture.detectChanges(); + }; + + beforeEach(async () => { + unit = { + id: 101, + taskDefinitions: [], + studentCache: {add: vi.fn()}, + } as unknown as Unit; + project = { + id: 2, + targetGrade: 0, + unit, + tasks: [], + findTaskForDefinition: () => null, + } as unknown as Project; + + projectResponses = []; + projectGet = vi.fn( + (_params: {id: number}, options: {mappingCompleteCallback: (project: Project) => void}) => { + const response: Subject = new Subject(); + projectResponses.push(response); + return response.pipe(tap((loaded) => options.mappingCompleteCallback(loaded))); + }, + ); + unitGet = vi.fn(() => of(unit)); + routerNavigate = vi.fn().mockResolvedValue(true); + + await TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [ProjectDashboardComponent], + providers: [ + {provide: UserService, useValue: {}}, + {provide: ProjectService, useValue: {get: projectGet}}, + { + provide: TaskService, + useValue: {taskSubmissionCompleted$: new Subject()}, + }, + {provide: UnitService, useValue: {get: unitGet}}, + {provide: GlobalStateService, useValue: {setView: vi.fn()}}, + { + provide: ActivatedRoute, + useValue: {parent: {data: of({}), snapshot: {paramMap: convertToParamMap({})}}}, + }, + { + provide: BreakpointObserver, + useValue: {observe: () => of({matches: false, breakpoints: {}})}, + }, + {provide: Router, useValue: {navigate: routerNavigate}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectDashboardComponent); + component = fixture.componentInstance; + hostProject$ = new BehaviorSubject(project); + fixture.componentRef.setInput('project$', hostProject$); + fixture.detectChanges(); + }); + + it('keeps the loaded project and the open task when the host re-emits the same project', () => { + projectResponses[0].next(project); + fixture.detectChanges(); + + const selectedTaskDefinition = {id: 7} as TaskDefinition; + component.selectedTaskDefinition$.next(selectedTaskDefinition); + fixture.detectChanges(); + + rebindHost(); + rebindHost(); + + expect(component.selectedTaskDefinition$.value).toBe(selectedTaskDefinition); + expect(component.isProjectTaskListReady(project)).toBe(true); + expect(projectGet).toHaveBeenCalledTimes(1); + expect(unitGet).toHaveBeenCalledTimes(1); + + fixture.destroy(); + }); + + it('does not cancel a load that is still in flight when the same project comes back', () => { + rebindHost(); + + expect(projectGet).toHaveBeenCalledTimes(1); + + projectResponses[0].next(project); + fixture.detectChanges(); + + expect(component.isProjectTaskListReady(project)).toBe(true); + + fixture.destroy(); + }); + + it('does not navigate out of an embedded staff portfolio when its phone pane changes', () => { + rebindHost(); + component.selectedTaskDefinition$.next({id: 7, abbreviation: '1.1P'} as TaskDefinition); + + component.showMobilePane('feedback'); + + expect(component.mobilePane).toBe('feedback'); + expect(routerNavigate).not.toHaveBeenCalled(); + + fixture.destroy(); + }); +}); diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts index 2a1fd23b73..008704237a 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import {CdkDragEnd, CdkDragMove, CdkDragStart} from '@angular/cdk/drag-drop'; +import {environment} from 'src/environments/environment'; import {BreakpointObserver} from '@angular/cdk/layout'; import { ChangeDetectionStrategy, @@ -9,13 +8,26 @@ import { OnDestroy, OnInit, Output, + ViewChild, + isDevMode, } from '@angular/core'; -import {ActivatedRoute} from '@angular/router'; -import {BehaviorSubject, Observable, Subject, first, of, takeUntil} from 'rxjs'; -import {Project, TaskDefinition} from 'src/app/api/models/doubtfire-model'; +import {ActivatedRoute, Router} from '@angular/router'; +import {BehaviorSubject, Observable, Subject, filter, map, takeUntil} from 'rxjs'; +import { + Project, + TaskDefinition, + TaskStatus, + TaskStatusEnum, +} from 'src/app/api/models/doubtfire-model'; +import {NotificationFeedbackRouteIntentService} from 'src/app/api/services/notification-feedback-route-intent.service'; import {ProjectService} from 'src/app/api/services/project.service'; +import {TaskService} from 'src/app/api/services/task.service'; import {UnitService} from 'src/app/api/services/unit.service'; import {UserService} from 'src/app/api/services/user.service'; +import {MilestoneCelebrationService} from 'src/app/common/celebrate/milestone-celebration.service'; +import {PanelComponent} from 'src/app/common/panel-layout/panel.component'; +import {ConversationLandingService} from 'src/app/tasks/task-comments-viewer/conversation-landing.service'; +import {FUnitTaskListComponent} from 'src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component'; import {GlobalStateService, ViewType} from '../../index/global-state.service'; @Component({ @@ -26,6 +38,9 @@ import {GlobalStateService, ViewType} from '../../index/global-state.service'; standalone: false, }) export class ProjectDashboardComponent implements OnInit, OnDestroy { + @ViewChild('leftPanel') private leftPanel?: FUnitTaskListComponent; + @ViewChild('taskListPanel') private taskListPanel?: PanelComponent; + @Input() public project$: Observable; @Input() public defaultTaskListCollapsed = false; @Input() public taskSelectionUrlBase: unknown[] | null = null; @@ -45,39 +60,61 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { public selectedTaskDefinition$: BehaviorSubject = new BehaviorSubject(null); - subs$: Observable = of(true); readonly skeletonRows = Array.from({length: 10}, (_, index) => index); private readonly projectSubject: BehaviorSubject = new BehaviorSubject(null); private readonly destroy$: Subject = new Subject(); + private readonly projectLoadCancel$: Subject = new Subject(); private projectReady = false; + // Set when the project itself could not be loaded (a wrong or stale link, no + // access, or no connection). Without it the page stayed on its skeleton forever. + public projectLoadFailed = false; + private activeProjectId: number | null = null; + private projectActivation = 0; projectTasks = []; + private milestoneCheckedProjectId: number | null = null; + private destroyed = false; + constructor( private currentUser: UserService, private projectService: ProjectService, + private taskService: TaskService, private unitService: UnitService, private globalStateService: GlobalStateService, private route: ActivatedRoute, private breakpointObserver: BreakpointObserver, + private angularRouter: Router, + private notificationFeedbackIntents?: NotificationFeedbackRouteIntentService, + private conversationLanding?: ConversationLandingService, + private milestones?: MilestoneCelebrationService, ) {} + /** + * The narrowest each desktop panel goes. The task column needs room for its tab bar, + * a four-date timeline and a row of action buttons; below these the layout rails the + * list, then comments, instead of squeezing. + */ + public readonly panelMinWidths = {list: 280, task: 420, comments: 320} as const; public readonly taskListCollapsedWidth = 75; public readonly taskListExpandedWidth = 400; public readonly taskListCollapseThreshold = 125; private _leftWidth = this.taskListExpandedWidth; - public lastX; - public startWidth = 0; - - public startLeftX = 0; - public isCommentsNarrow = false; - public commentsCollapsed = false; - - private readonly commentsBreakpoint = '(max-width: 999.98px)'; - - public get commentsPanelCollapsed(): boolean { - return this.isCommentsNarrow && this.commentsCollapsed; + // Desktop only: the panel shown full screen (the chat, so a long conversation has + // room), and the one shown when the panels stack. Esc or the same button puts it back. + public fullscreenPanel: string | null = null; + public activePanel: string | null = 'list'; + public isPhoneLayout = false; + public mobilePane: 'overview' | 'task' | 'feedback' = 'task'; + public activeTaskStatusFilter: TaskStatusEnum | null = null; + private taskFilterNavigationActive = false; + + private readonly phoneBreakpoint = '(max-width: 639.98px)'; + + /** The staff portfolio view embeds this page, and remembers its panels apart. */ + public get panelPage(): string { + return this.taskSelectionUrlBase ? 'portfolio-progress' : 'project-dashboard'; } public get taskListCollapsed(): boolean { @@ -111,73 +148,265 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { return project.unit.taskDefinitions; } - startedDragging(event: CdkDragStart, boundary: HTMLElement) { - document.body.classList.add('split-pane-resizing'); - event.source.element.nativeElement.classList.add('hovering'); - const rect = boundary.getBoundingClientRect(); - // x relative to the container - this.startLeftX = (event.event as MouseEvent).clientX - rect.left; - this.startWidth = this.leftWidth; - } - - dragging(event: CdkDragMove, boundary: HTMLElement) { - const rect = boundary.getBoundingClientRect(); - const x = (event.event as MouseEvent).clientX - rect.left; - - const delta = x - this.startLeftX; - const newWidth = this.startWidth + delta; - - this.leftWidth = Math.max(this.taskListCollapsedWidth, Math.min(500, newWidth)); - - // keep the handle visually glued to the divider - event.source.reset(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - stoppedDragging(event: CdkDragEnd, _div: HTMLDivElement) { - document.body.classList.remove('split-pane-resizing'); - event.source.element.nativeElement.classList.remove('hovering'); - } - ngOnInit(): void { this.breakpointObserver - .observe(this.commentsBreakpoint) + .observe(this.phoneBreakpoint) .pipe(takeUntil(this.destroy$)) .subscribe(({matches}) => { - this.isCommentsNarrow = matches; - this.commentsCollapsed = matches; + this.isPhoneLayout = matches; + if (matches) { + // The phone layout has its own feedback pane and no full-screen mode. + this.fullscreenPanel = null; + } + if (matches && this.selectedTaskDefinition$.value) { + this.mobilePane = this.shouldOpenFeedback(this.selectedTaskDefinition$.value) + ? 'feedback' + : 'task'; + } window.dispatchEvent(new Event('resize')); }); - if (this.defaultTaskListCollapsed) { - this.leftWidth = this.taskListCollapsedWidth; - } + this.selectedTaskDefinition$.pipe(takeUntil(this.destroy$)).subscribe((taskDefinition) => { + if (!taskDefinition) { + this.mobilePane = 'task'; + this.activePanel = 'list'; + this.fullscreenPanel = null; + return; + } + + this.activePanel = 'task'; + + if (this.isPhoneLayout) { + // A task opened from a notification/deep link should expose its feedback immediately. + // A task chosen from the list is selected before its route is updated, so it starts on + // the task pane instead. + this.mobilePane = this.shouldOpenFeedback(taskDefinition) ? 'feedback' : 'task'; + } + + this.forwardNotificationLanding(taskDefinition); + }); + + this.notificationFeedbackIntents?.requests$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.forwardNotificationLanding(this.selectedTaskDefinition$.value)); + + this.route.paramMap?.pipe(takeUntil(this.destroy$)).subscribe((params) => { + if ( + this.isPhoneLayout && + params.get('mobilePane') === 'feedback' && + this.selectedTaskDefinition$.value + ) { + this.mobilePane = 'feedback'; + } + }); + + this.route.queryParamMap?.pipe(takeUntil(this.destroy$)).subscribe((params) => { + const requestedStatus = params.get('taskStatus'); + const nextStatus = TaskStatus.isStatus(requestedStatus) ? requestedStatus : null; + const nextTaskFilterNavigation = params.get('taskView') === 'tasks' || !!nextStatus; + if ( + nextStatus === this.activeTaskStatusFilter && + nextTaskFilterNavigation === this.taskFilterNavigationActive + ) { + return; + } + + const wasTaskFilterNavigation = this.taskFilterNavigationActive; + this.activeTaskStatusFilter = nextStatus; + this.taskFilterNavigationActive = nextTaskFilterNavigation; + if (nextTaskFilterNavigation) { + // Overview cards navigate to the canonical dashboard route with this query + // parameter. Query-only route reuse must still expose the task list rather + // than leaving the phone on the overview or stale task pane. + this.selectedTaskDefinition$.next(null); + this.mobilePane = 'task'; + this.revealTaskList(); + } else if (wasTaskFilterNavigation) { + // The companion view marker lets browser Back restore the Overview pane, + // while a user-cleared status retains taskView=tasks and stays in Tasks. + this.mobilePane = 'overview'; + } + }); + + this.taskService.taskSubmissionCompleted$.pipe(takeUntil(this.destroy$)).subscribe((task) => { + const activeProject = this.projectSubject.value; + const selectedTaskDefinition = this.selectedTaskDefinition$.value; + if ( + activeProject?.id === task.project?.id && + selectedTaskDefinition?.id === task.definition?.id + ) { + // A successful upload belongs to the task the student was working on. + // Keep that exact selection while TaskDashboard exposes its queued or + // ready submission state; returning to the generic list loses context. + this.mobilePane = 'task'; + } + }); const initialProject$ = - this.project$ ?? of(this.route.parent?.snapshot.data.project as Project); + this.project$ ?? + this.route.parent.data.pipe( + map((data) => data.project as Project), + filter((project): project is Project => !!project), + ); this.project$ = this.projectSubject.asObservable(); - initialProject$.pipe(first()).subscribe((project) => { - this.projectSubject.next(project); - this.loadProject( - project?.id ?? Number(this.route.parent?.snapshot.paramMap.get('projectId')), + initialProject$ + .pipe(takeUntil(this.destroy$)) + .subscribe((project) => + this.activateProject( + project, + project?.id ?? Number(this.route.parent?.snapshot.paramMap.get('projectId')), + ), ); - }); window.dispatchEvent(new Event('resize')); } + /** + * A status card filters the task list, so open that list if it is collapsed or + * railed for space. Deferred a tick so it also works on first load, before the + * panel exists. + */ + private revealTaskList(): void { + setTimeout(() => this.taskListPanel?.expandFromRail()); + } + ngOnDestroy(): void { - document.body.classList.remove('split-pane-resizing'); + this.destroyed = true; + this.projectLoadCancel$.next(); + this.projectLoadCancel$.complete(); this.destroy$.next(); this.destroy$.complete(); } - public toggleCommentsPanel(): void { - this.commentsCollapsed = !this.commentsCollapsed; - window.dispatchEvent(new Event('resize')); + public showMobilePane(pane: 'overview' | 'task' | 'feedback'): void { + this.mobilePane = pane; + if (pane === 'overview' && this.taskFilterNavigationActive) { + void this.angularRouter.navigate([], { + relativeTo: this.route, + queryParams: {taskStatus: null, taskView: null}, + queryParamsHandling: 'merge', + }); + } + this.syncSelectedTaskRoute(pane); + } + + public clearTaskSelection(): void { + const selectedTaskDefinition = this.selectedTaskDefinition$.value; + if (selectedTaskDefinition && this.leftPanel) { + // Let the task list clear both its shared selection and the task segment in the URL. + this.leftPanel.setSelectedTaskDefinition(selectedTaskDefinition); + } else { + this.selectedTaskDefinition$.next(null); + } + this.mobilePane = 'task'; + } + + private get hasTaskRouteSelection(): boolean { + return !!this.route.snapshot?.paramMap?.get('taskAbbreviation'); } - private loadProject(projectId: number): void { + private get hasFeedbackRouteSelection(): boolean { + return this.route.snapshot?.paramMap?.get('mobilePane') === 'feedback'; + } + + private syncSelectedTaskRoute(pane: 'overview' | 'task' | 'feedback'): void { + const selectedTaskDefinition = this.selectedTaskDefinition$.value; + // The dashboard is also embedded in the staff portfolio progress screen, whose task + // destination is supplied by its host. That route has no feedback suffix and must not be + // replaced with the student's standalone project dashboard route. + if (this.taskSelectionUrlBase) { + return; + } + + const projectId = + this.activeProjectId ?? Number(this.route.parent?.snapshot.paramMap.get('projectId')); + if (!selectedTaskDefinition || !projectId) { + return; + } + + const feedbackRoute = pane === 'feedback'; + if (feedbackRoute === this.hasFeedbackRouteSelection) { + return; + } + + const commands: Array = [ + '/projects', + projectId, + 'dashboard', + selectedTaskDefinition.abbreviation, + ]; + if (feedbackRoute) { + commands.push('feedback'); + } + + void this.angularRouter.navigate(commands, { + replaceUrl: true, + queryParamsHandling: 'preserve', + }); + } + + private shouldOpenFeedback(taskDefinition: TaskDefinition): boolean { + const task = this.projectSubject.value?.findTaskForDefinition(taskDefinition.id); + return ( + this.hasFeedbackRouteSelection || + (this.hasTaskRouteSelection && (task?.numNewComments ?? 0) > 0) + ); + } + + private forwardNotificationLanding(taskDefinition: TaskDefinition | null): void { + if (!taskDefinition || !this.notificationFeedbackIntents || !this.conversationLanding) { + return; + } + + const projectId = + this.activeProjectId ?? Number(this.route.parent?.snapshot.paramMap.get('projectId')); + if (!projectId) { + return; + } + + const intent = this.notificationFeedbackIntents.consume({ + projectId, + taskAbbreviation: taskDefinition.abbreviation, + }); + if (!intent) { + return; + } + + this.conversationLanding.requestLatestMessages({ + projectId, + taskDefinitionId: taskDefinition.id, + }); + } + + private activateProject(project: Project, projectId: number): void { + if (!projectId) { + return; + } + + // Hosts re-emit the project they are already showing. The portfolios progress tab does it on + // every change detection pass, and the reset below would drop the loaded project back to its + // skeleton and close the open task each time. Activating is only for a project we are not + // already showing or already fetching, so a repeat of the active one is left alone. + if (projectId === this.activeProjectId) { + return; + } + + this.projectLoadCancel$.next(); + const activation = ++this.projectActivation; + this.activeProjectId = projectId; + this.projectReady = false; + this.projectLoadFailed = false; + this.selectedTaskDefinition$.next(null); + this.projectSubject.next(project); + + if (project?.unit?.id) { + this.globalStateService.setView(ViewType.PROJECT, project); + } + + this.loadProject(projectId, activation); + } + + private loadProject(projectId: number, activation: number): void { if (!projectId) { return; } @@ -187,34 +416,107 @@ export class ProjectDashboardComponent implements OnInit, OnDestroy { {id: projectId}, { cacheBehaviourOnGet: 'cacheQuery', - mappingCompleteCallback: (project: Project) => this.loadUnit(project), + mappingCompleteCallback: (project: Project) => { + if (activation === this.projectActivation && project.id === this.activeProjectId) { + this.loadUnit(project, activation); + } + }, }, ) - .subscribe(); + .pipe(takeUntil(this.projectLoadCancel$), takeUntil(this.destroy$)) + .subscribe({ + error: () => { + if (activation === this.projectActivation) { + this.projectLoadFailed = true; + } + }, + }); + } + + public retryProjectLoad(): void { + if (!this.activeProjectId) { + return; + } + + this.projectLoadCancel$.next(); + this.projectLoadFailed = false; + this.loadProject(this.activeProjectId, ++this.projectActivation); } - private loadUnit(project: Project): void { + private loadUnit(project: Project, activation: number): void { const unitId = project.unit?.id; if (!unitId) { - this.showLoadedProject(project); + this.showLoadedProject(project, activation); return; } - this.unitService.get(unitId).subscribe({ - next: (unit) => { - project.unit = unit; - unit.studentCache.add(project); - this.showLoadedProject(project); - }, - error: () => { - this.showLoadedProject(project); - }, - }); + this.unitService + .get(unitId) + .pipe(takeUntil(this.projectLoadCancel$), takeUntil(this.destroy$)) + .subscribe({ + next: (unit) => { + if (activation !== this.projectActivation || project.id !== this.activeProjectId) { + return; + } + + project.unit = unit; + unit.studentCache.add(project); + this.showLoadedProject(project, activation); + }, + error: () => { + this.showLoadedProject(project, activation); + }, + }); } - private showLoadedProject(project: Project): void { + private showLoadedProject(project: Project, activation: number): void { + if (activation !== this.projectActivation || project.id !== this.activeProjectId) { + return; + } + this.projectReady = true; this.globalStateService.setView(ViewType.PROJECT, project); this.projectSubject.next(project); + this.checkMilestones(project); + } + + /** + * Once per opened project, shows the signed off dialog when tasks became complete + * since the student last looked. The staff portfolio view embeds this page and + * never checks. The service itself also refuses anyone who is not the student. + */ + private checkMilestones(project: Project): void { + if ( + !this.milestones || + this.taskSelectionUrlBase || + this.milestoneCheckedProjectId === project.id + ) { + return; + } + this.milestoneCheckedProjectId = project.id; + + void this.milestones + .checkProject(project, {preview: this.milestonePreviewRequested()}) + .then((result) => { + if (result !== 'view' || this.destroyed || this.activeProjectId !== project.id) { + return; + } + + void this.angularRouter.navigate([], { + relativeTo: this.route, + queryParams: {taskStatus: 'complete', taskView: 'tasks'}, + queryParamsHandling: 'merge', + }); + }) + .catch(() => undefined); + } + + /** `?celebrate=preview` replays the dialog locally. A production build ignores it. */ + private milestonePreviewRequested(): boolean { + return ( + isDevMode() && + environment.production === false && + this.route.snapshot?.queryParamMap?.get('celebrate') === 'preview' + ); } } diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.mobile.spec.ts b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.mobile.spec.ts new file mode 100644 index 0000000000..6d23afc68f --- /dev/null +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.mobile.spec.ts @@ -0,0 +1,399 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {BreakpointObserver, BreakpointState} from '@angular/cdk/layout'; +import {CommonModule} from '@angular/common'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ActivatedRoute, Router, convertToParamMap} from '@angular/router'; +import {BehaviorSubject, Subject, of, tap} from 'rxjs'; +import {Project, Task, TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; +import {NotificationFeedbackRouteIntentService} from 'src/app/api/services/notification-feedback-route-intent.service'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {TaskService} from 'src/app/api/services/task.service'; +import {UnitService} from 'src/app/api/services/unit.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {ConversationLandingService} from 'src/app/tasks/task-comments-viewer/conversation-landing.service'; +import {GlobalStateService} from '../../index/global-state.service'; +import {ProjectDashboardComponent} from './project-dashboard.component'; + +describe('ProjectDashboardComponent phone task workspace', () => { + let fixture: ComponentFixture; + let component: ProjectDashboardComponent; + let phoneState$: BehaviorSubject; + let observeBreakpoint: ReturnType; + let routeTaskAbbreviation: string | null; + let routeMobilePane: string | null; + let routeParams$: BehaviorSubject>; + let routeQueryParams$: BehaviorSubject>; + let routerNavigate: ReturnType; + + const taskDefinition = { + id: 42, + abbreviation: '1.1P', + name: 'First task', + } as TaskDefinition; + const unit = { + id: 101, + taskDefinitions: [taskDefinition], + studentCache: {add: vi.fn()}, + } as unknown as Unit; + const task = {definition: taskDefinition, numNewComments: 1} as Task; + const project = { + id: 2, + targetGrade: 0, + unit, + tasks: [task], + findTaskForDefinition: (id: number) => (id === taskDefinition.id ? task : null), + } as unknown as Project; + + const renderSelectedTask = (): void => { + component.selectedTaskDefinition$.next(taskDefinition); + fixture.detectChanges(); + }; + + const query = (selector: string): T | null => + fixture.nativeElement.querySelector(selector) as T | null; + + beforeEach(async () => { + routeTaskAbbreviation = null; + routeMobilePane = null; + routeParams$ = new BehaviorSubject(convertToParamMap({})); + routeQueryParams$ = new BehaviorSubject(convertToParamMap({})); + routerNavigate = vi.fn().mockResolvedValue(true); + task.numNewComments = 1; + phoneState$ = new BehaviorSubject({ + matches: true, + breakpoints: {}, + }); + observeBreakpoint = vi.fn(() => phoneState$); + + await TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [ProjectDashboardComponent], + providers: [ + {provide: UserService, useValue: {}}, + { + provide: ProjectService, + useValue: { + get: ( + _params: {id: number}, + options: {mappingCompleteCallback: (loaded: Project) => void}, + ) => of(project).pipe(tap((loaded) => options.mappingCompleteCallback(loaded))), + }, + }, + { + provide: TaskService, + useValue: {taskSubmissionCompleted$: new Subject()}, + }, + {provide: UnitService, useValue: {get: () => of(unit)}}, + {provide: GlobalStateService, useValue: {setView: vi.fn()}}, + { + provide: ActivatedRoute, + useFactory: () => ({ + paramMap: routeParams$.asObservable(), + queryParamMap: routeQueryParams$.asObservable(), + snapshot: { + get paramMap() { + return convertToParamMap({ + taskAbbreviation: routeTaskAbbreviation, + mobilePane: routeMobilePane, + }); + }, + }, + parent: { + data: of({project}), + snapshot: { + data: {project}, + paramMap: convertToParamMap({projectId: project.id}), + }, + }, + }), + }, + {provide: BreakpointObserver, useValue: {observe: observeBreakpoint}}, + {provide: Router, useValue: {navigate: routerNavigate}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + const createComponent = (): void => { + routeParams$.next( + convertToParamMap({ + taskAbbreviation: routeTaskAbbreviation, + mobilePane: routeMobilePane, + }), + ); + fixture = TestBed.createComponent(ProjectDashboardComponent); + component = fixture.componentInstance; + component.project$ = of(project); + fixture.detectChanges(); + }; + + it('uses a single full-width feedback pane for a task opened from a phone deep link', () => { + routeTaskAbbreviation = taskDefinition.abbreviation; + createComponent(); + renderSelectedTask(); + + expect(observeBreakpoint).toHaveBeenCalledWith('(max-width: 639.98px)'); + expect(component.isPhoneLayout).toBe(true); + expect(component.mobilePane).toBe('feedback'); + expect(query('.project-task-list-panel')?.classList).toContain( + 'project-task-list-panel--phone-hidden', + ); + expect(query('.mobile-task-view')).not.toBeNull(); + expect(query('.mobile-task-pane task-comments-viewer')).not.toBeNull(); + expect(query('.mobile-task-pane f-task-dashboard')).toBeNull(); + expect(query('app-panel-layout')).toBeNull(); + }); + + it('converts a validated notification route intent into the Batch 02 landing hook', () => { + routeTaskAbbreviation = taskDefinition.abbreviation; + routeMobilePane = 'feedback'; + const routeIntents = TestBed.inject(NotificationFeedbackRouteIntentService); + const landing = TestBed.inject(ConversationLandingService); + const requestLatest = vi.spyOn(landing, 'requestLatestMessages'); + + routeIntents.request({ + projectId: project.id, + taskAbbreviation: taskDefinition.abbreviation, + }); + createComponent(); + renderSelectedTask(); + + expect(requestLatest).toHaveBeenCalledWith({ + projectId: project.id, + taskDefinitionId: taskDefinition.id, + }); + }); + + it('keeps project navigation visible while the phone task list is open', () => { + createComponent(); + + const shell = query('.project-dashboard-shell'); + const navigation = query('nav[aria-label="Project dashboard navigation"]'); + const overviewButton = query('button[aria-label="Show project overview"]'); + const taskListButton = query('button[aria-label="Show task list"]'); + const detailsButton = query('button[aria-label="Show task details"]'); + const feedbackButton = query('button[aria-label="Show feedback"]'); + + expect(navigation).not.toBeNull(); + expect(navigation?.parentElement).toBe(shell); + expect(shell?.classList).not.toContain('overflow-hidden'); + expect(overviewButton?.getAttribute('aria-pressed')).toBe('false'); + expect(taskListButton?.getAttribute('aria-pressed')).toBe('true'); + expect(taskListButton?.classList).toContain('mobile-task-tab--active'); + expect(detailsButton?.disabled).toBe(true); + expect(feedbackButton?.disabled).toBe(true); + expect(query('.project-task-list-panel')?.classList).not.toContain( + 'project-task-list-panel--phone-hidden', + ); + }); + + it('exposes the complete progress dashboard from the phone overview tab', () => { + createComponent(); + + query('button[aria-label="Show project overview"]')?.click(); + fixture.detectChanges(); + + const overviewButton = query('button[aria-label="Show project overview"]'); + expect(component.mobilePane).toBe('overview'); + expect(overviewButton?.getAttribute('aria-pressed')).toBe('true'); + expect(overviewButton?.classList).toContain('mobile-task-tab--active'); + expect(query('section[aria-label="Project overview"]')).not.toBeNull(); + expect(query('.mobile-overview-pane f-progress-dashboard')).not.toBeNull(); + expect(query('.project-task-list-panel')?.classList).toContain( + 'project-task-list-panel--phone-hidden', + ); + expect(query('.mobile-task-view')).toBeNull(); + }); + + it('keeps the selected task available while viewing progress on a phone', () => { + createComponent(); + renderSelectedTask(); + + query('button[aria-label="Show project overview"]')?.click(); + fixture.detectChanges(); + + expect(component.selectedTaskDefinition$.value).toBe(taskDefinition); + expect(query('.mobile-overview-pane f-progress-dashboard')).not.toBeNull(); + + query('button[aria-label="Show feedback"]')?.click(); + fixture.detectChanges(); + + expect(component.selectedTaskDefinition$.value).toBe(taskDefinition); + expect(component.mobilePane).toBe('feedback'); + expect(query('.mobile-task-pane task-comments-viewer')).not.toBeNull(); + expect(query('.mobile-overview-pane')).toBeNull(); + }); + + it('opens task details first when a student selects a task from the phone list', () => { + createComponent(); + renderSelectedTask(); + + const shell = query('.project-dashboard-shell'); + const heading = query('.mobile-task-heading'); + const navigation = query('nav[aria-label="Project dashboard navigation"]'); + + expect(component.mobilePane).toBe('task'); + expect(heading?.parentElement).toBe(shell); + expect(navigation?.parentElement).toBe(shell); + expect(heading?.nextElementSibling).toBe(navigation); + expect(query('.mobile-task-pane f-task-dashboard')).not.toBeNull(); + expect(query('.mobile-task-pane task-comments-viewer')).toBeNull(); + }); + + it('switches exclusively between the task and feedback panes', () => { + createComponent(); + renderSelectedTask(); + + const feedbackButton = query('button[aria-label="Show feedback"]'); + feedbackButton?.click(); + fixture.detectChanges(); + + expect( + query('button[aria-label="Show feedback"]')?.getAttribute('aria-pressed'), + ).toBe('true'); + expect(query('.mobile-task-pane task-comments-viewer')).not.toBeNull(); + expect(query('.mobile-task-pane f-task-dashboard')).toBeNull(); + + const detailsButton = query('button[aria-label="Show task details"]'); + detailsButton?.click(); + fixture.detectChanges(); + + expect( + query('button[aria-label="Show task details"]')?.getAttribute( + 'aria-pressed', + ), + ).toBe('true'); + expect(query('.mobile-task-pane f-task-dashboard')).not.toBeNull(); + expect(query('.mobile-task-pane task-comments-viewer')).toBeNull(); + }); + + it('keeps a non-feedback deep link on task details when there are no unread comments', () => { + routeTaskAbbreviation = taskDefinition.abbreviation; + task.numNewComments = 0; + createComponent(); + renderSelectedTask(); + + expect(component.mobilePane).toBe('task'); + expect(query('.mobile-task-pane f-task-dashboard')).not.toBeNull(); + expect(query('.mobile-task-pane task-comments-viewer')).toBeNull(); + }); + + it('opens an explicit feedback destination even after its comments were already read', () => { + routeTaskAbbreviation = taskDefinition.abbreviation; + routeMobilePane = 'feedback'; + task.numNewComments = 0; + createComponent(); + renderSelectedTask(); + + expect(component.mobilePane).toBe('feedback'); + expect(query('.mobile-task-pane task-comments-viewer')).not.toBeNull(); + expect(query('.mobile-task-pane f-task-dashboard')).toBeNull(); + }); + + it('reopens feedback when the same task receives another notification after changing panes', () => { + routeTaskAbbreviation = taskDefinition.abbreviation; + routeMobilePane = 'feedback'; + task.numNewComments = 0; + createComponent(); + renderSelectedTask(); + + query('button[aria-label="Show project overview"]')?.click(); + fixture.detectChanges(); + + expect(component.mobilePane).toBe('overview'); + expect(routerNavigate).toHaveBeenLastCalledWith( + ['/projects', project.id, 'dashboard', taskDefinition.abbreviation], + {replaceUrl: true, queryParamsHandling: 'preserve'}, + ); + + routeMobilePane = null; + routeParams$.next(convertToParamMap({taskAbbreviation: taskDefinition.abbreviation})); + routeMobilePane = 'feedback'; + routeParams$.next( + convertToParamMap({ + taskAbbreviation: taskDefinition.abbreviation, + mobilePane: routeMobilePane, + }), + ); + fixture.detectChanges(); + + expect(component.mobilePane).toBe('feedback'); + expect(query('.mobile-task-pane task-comments-viewer')).not.toBeNull(); + expect(query('.mobile-overview-pane')).toBeNull(); + }); + + it('opens the Tasks pane for a canonical Overview status query and ignores unknown values', () => { + createComponent(); + renderSelectedTask(); + query('button[aria-label="Show project overview"]')?.click(); + fixture.detectChanges(); + + expect(component.mobilePane).toBe('overview'); + expect(component.selectedTaskDefinition$.value).toBe(taskDefinition); + + routeQueryParams$.next( + convertToParamMap({taskStatus: 'ready_for_feedback', taskView: 'tasks'}), + ); + fixture.detectChanges(); + + expect(component.activeTaskStatusFilter).toBe('ready_for_feedback'); + expect(component.mobilePane).toBe('task'); + expect(component.selectedTaskDefinition$.value).toBeNull(); + expect(query('.project-task-list-panel')?.classList).not.toContain( + 'project-task-list-panel--phone-hidden', + ); + + routeQueryParams$.next(convertToParamMap({taskView: 'tasks'})); + expect(component.activeTaskStatusFilter).toBeNull(); + expect(component.mobilePane).toBe('task'); + + routeQueryParams$.next(convertToParamMap({})); + expect(component.mobilePane).toBe('overview'); + }); + + it('returns to the full-width task list from the selected phone workspace', () => { + createComponent(); + renderSelectedTask(); + + const clearThroughTaskList = vi.fn(() => component.selectedTaskDefinition$.next(null)); + Reflect.set(component, 'leftPanel', {setSelectedTaskDefinition: clearThroughTaskList}); + + query('button[aria-label="Show task list"]')?.click(); + fixture.detectChanges(); + + expect(clearThroughTaskList).toHaveBeenCalledWith(taskDefinition); + expect(component.selectedTaskDefinition$.value).toBeNull(); + expect(component.mobilePane).toBe('task'); + expect(query('.mobile-task-view')).toBeNull(); + expect(query('nav[aria-label="Project dashboard navigation"]')).not.toBeNull(); + expect( + query('button[aria-label="Show task list"]')?.getAttribute('aria-pressed'), + ).toBe('true'); + expect(query('.project-task-list-panel')?.classList).not.toContain( + 'project-task-list-panel--phone-hidden', + ); + }); + + it('preserves the three-pane desktop layout above the phone breakpoint', () => { + phoneState$.next({matches: false, breakpoints: {}}); + createComponent(); + renderSelectedTask(); + + expect(component.isPhoneLayout).toBe(false); + expect(query('.mobile-task-view')).toBeNull(); + expect(query('app-panel-layout f-task-dashboard.desktop-task-dashboard')).not.toBeNull(); + expect(query('app-panel.project-comments task-comments-viewer')).not.toBeNull(); + expect(component.leftWidth).toBe(400); + + const styles = ( + ProjectDashboardComponent as unknown as { + ɵcmp: {styles: string[]}; + } + ).ɵcmp.styles.join('\n'); + expect(styles).toMatch(/\.project-dashboard-shell[^{]*\{[^}]*overflow:\s*hidden/); + expect(styles).toMatch( + /@media\s*\(max-width:\s*639\.98px\)[\s\S]*?\.project-dashboard-shell[^{]*\{[^}]*overflow:\s*visible/, + ); + }); +}); diff --git a/src/app/projects/states/discussion-prompts/discussion-prompts.component.html b/src/app/projects/states/discussion-prompts/discussion-prompts.component.html index e792873915..633191b215 100644 --- a/src/app/projects/states/discussion-prompts/discussion-prompts.component.html +++ b/src/app/projects/states/discussion-prompts/discussion-prompts.component.html @@ -1,23 +1,36 @@
      - @for (prompt of discussionPrompts; track prompt) { - - -
      - {{ prompt.taskDefinition.abbreviation }} -
      -
      - {{ prompt.content }} -
      -
      - {{ prompt.priorityLabel }} -
      -
      -
      - } @empty { -
      No discussion prompts for this task
      + @if (loadingPrompts) { +
      + Loading discussion prompts... + +
      + } @else if (promptsLoadFailed) { +

      Unable to load discussion prompts. Please try again later.

      + } @else { + @for (prompt of discussionPrompts; track prompt) { + + +
      + {{ prompt.taskDefinition.abbreviation }} +
      +
      + {{ prompt.content }} +
      +
      + {{ prompt.priorityLabel }} +
      +
      +
      + } @empty { + + } }
      diff --git a/src/app/projects/states/discussion-prompts/discussion-prompts.component.ts b/src/app/projects/states/discussion-prompts/discussion-prompts.component.ts index cc81e95a94..642869c9c0 100644 --- a/src/app/projects/states/discussion-prompts/discussion-prompts.component.ts +++ b/src/app/projects/states/discussion-prompts/discussion-prompts.component.ts @@ -27,7 +27,8 @@ export class DiscussionPromptsComponent implements OnInit { @Input() project: Project; @Input() taskDefinition: TaskDefinition; - loadingStaffNotes: boolean = true; + loadingPrompts = true; + promptsLoadFailed = false; noteText: string = ''; @@ -47,13 +48,15 @@ export class DiscussionPromptsComponent implements OnInit { private confirmationModalService: ConfirmationModalService, ) {} ngOnInit(): void { - console.log('task def?', this.taskDefinition); - this.loadingStaffNotes = true; - this.discussionPromptService - .loadDiscussionPromptsForPoject(this.project) - .subscribe((prompts) => { - console.log(prompts); + this.discussionPromptService.loadDiscussionPromptsForPoject(this.project).subscribe({ + next: (prompts) => { this.discussionPrompts = prompts; - }); + this.loadingPrompts = false; + }, + error: () => { + this.promptsLoadFailed = true; + this.loadingPrompts = false; + }, + }); } } diff --git a/src/app/projects/states/groups/project-groups/project-groups.component.html b/src/app/projects/states/groups/project-groups/project-groups.component.html index 7eca48477b..1ea9d2ac31 100644 --- a/src/app/projects/states/groups/project-groups/project-groups.component.html +++ b/src/app/projects/states/groups/project-groups/project-groups.component.html @@ -1,12 +1,33 @@ -
      - @if (unit.hasGroupwork()) { + + @if (demoGroupHook) { + + } + + @if (hasConfiguredGroupWork && (hasPublishedGroups || studentsCanCreateGroups)) { + } @else if (hasConfiguredGroupWork) { +
      + +

      No groups are available yet

      +

      + Group work is configured for {{ unit.code }}, but no groups have been published in + {{ selectedGroupSet?.name || 'the current group set' }}. +

      +

      If you expected a group, contact your unit teaching team.

      +
      } @else { -
      - groups -

      No Group Work

      -

      There is no group work enabled for this unit.

      -
      +
      + +

      Group work is not configured

      +

      This unit has not provided a group set or any groups.

      +

      If you expected group work for {{ unit.code }}, contact your unit teaching team.

      +
      } -
      + diff --git a/src/app/projects/states/index/global-state.service.spec.ts b/src/app/projects/states/index/global-state.service.spec.ts new file mode 100644 index 0000000000..fdf32353ca --- /dev/null +++ b/src/app/projects/states/index/global-state.service.spec.ts @@ -0,0 +1,320 @@ +import {EntityCache} from 'ngx-entity-service'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {BreakpointObserver} from '@angular/cdk/layout'; +import {TestBed} from '@angular/core/testing'; +import {Router} from '@angular/router'; +import {Subject, of} from 'rxjs'; +import { + CampusService, + LearningOutcomeService, + Project, + ProjectService, + TeachingPeriodService, + Unit, + UnitRole, + UnitRoleService, + UnitService, + UserService, +} from 'src/app/api/models/doubtfire-model'; +import {AuthenticationService} from 'src/app/api/services/authentication.service'; +import {FeedbackTemplateService} from 'src/app/api/services/feedback-template.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {AuthReturnUrlService} from 'src/app/security/auth-return-url.service'; +import {GlobalStateService, STARTUP_TIMEOUT_MS} from './global-state.service'; + +type RefreshCallback = Parameters[0]; + +describe('GlobalStateService startup', () => { + let service: GlobalStateService; + let createService: () => GlobalStateService; + let originalUrl: string; + let authCallback: RefreshCallback; + let authentication: { + attemptLoginUsingRefreshToken: ReturnType; + isAuthenticated: ReturnType; + signOut: ReturnType; + }; + let campuses: Subject; + let teachingPeriods: Subject; + let unitRoles: Subject; + let projects: Subject; + let campusService: {query: ReturnType}; + let teachingPeriodService: {query: ReturnType}; + let unitRoleService: {cache: object; query: ReturnType}; + let projectService: {cache: object; query: ReturnType}; + let router: {navigateByUrl: ReturnType}; + + beforeEach(() => { + originalUrl = window.location.href; + window.history.replaceState(null, '', '/home'); + vi.spyOn(console, 'info').mockImplementation(() => undefined); + campuses = new Subject(); + teachingPeriods = new Subject(); + unitRoles = new Subject(); + projects = new Subject(); + campusService = {query: vi.fn(() => campuses.asObservable())}; + teachingPeriodService = {query: vi.fn(() => teachingPeriods.asObservable())}; + unitRoleService = {cache: emptyCache(), query: vi.fn(() => unitRoles.asObservable())}; + projectService = {cache: emptyCache(), query: vi.fn(() => projects.asObservable())}; + router = {navigateByUrl: vi.fn()}; + authentication = { + attemptLoginUsingRefreshToken: vi.fn((callback: RefreshCallback) => { + authCallback = callback; + }), + isAuthenticated: vi.fn(() => true), + signOut: vi.fn(), + }; + + createService = () => + new GlobalStateService( + unitRoleService as never, + {cache: emptyCache()} as never, + {currentUser: {isStaff: false, hasRunFirstTimeSetup: true}, cache: emptyCache()} as never, + authentication as never, + projectService as never, + campusService as never, + teachingPeriodService as never, + {query: vi.fn()} as never, + {query: vi.fn()} as never, + router as never, + {error: vi.fn()} as never, + {isMatched: vi.fn(() => false)} as never, + {rememberCurrentUrl: vi.fn()} as never, + ); + service = createService(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + service.ngOnDestroy(); + window.history.replaceState(null, '', originalUrl); + }); + + it('opens the public verification page without session restoration or a loading overlay', () => { + service.ngOnDestroy(); + authentication.attemptLoginUsingRefreshToken.mockClear(); + window.history.replaceState(null, '', '/verify_additional_email'); + service = createService(); + + expect(authentication.attemptLoginUsingRefreshToken).not.toHaveBeenCalled(); + expect(service.isLoadingSubject.value).toBe(false); + expect(service.startupStateSubject.value.status).toBe('signed-out'); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('still redirects a signed-out protected page to sign-in', () => { + authCallback(false, 'not-requested'); + expect(router.navigateByUrl).toHaveBeenCalledWith('/sign_in'); + expect(service.isLoadingSubject.value).toBe(false); + }); + + it('removes serial project loading and the artificial final delay', () => { + service.loadGlobals(); + expect(unitRoleService.query).not.toHaveBeenCalled(); + expect(projectService.query).not.toHaveBeenCalled(); + + campuses.next([]); + campuses.complete(); + teachingPeriods.next([]); + teachingPeriods.complete(); + + expect(unitRoleService.query).toHaveBeenCalledOnce(); + expect(projectService.query).toHaveBeenCalledOnce(); + + unitRoles.next([]); + unitRoles.complete(); + projects.next([]); + projects.complete(); + + expect(service.startupStateSubject.value.status).toBe('ready'); + expect(service.isLoadingSubject.value).toBe(false); + }); + + it('publishes a recoverable terminal state when a required request fails', () => { + service.loadGlobals(); + campuses.error(new Error('network failed')); + + expect(service.startupStateSubject.value).toMatchObject({ + status: 'error', + failedResource: 'campuses', + }); + expect(service.startupStateSubject.value.message).toContain('campuses'); + expect(service.isLoadingSubject.value).toBe(true); + }); + + it('labels a required-request failure as offline when the browser is offline', () => { + vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false); + + service.loadGlobals(); + campuses.error(new Error('network failed')); + + expect(service.startupStateSubject.value).toMatchObject({ + status: 'offline', + failedResource: 'campuses', + }); + expect(service.startupStateSubject.value.message).toContain('offline'); + }); + + it('times out an otherwise permanent startup wait', async () => { + vi.useFakeTimers(); + service.loadGlobals(); + + await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS + 1); + + expect(service.startupStateSubject.value.status).toBe('error'); + expect(service.startupStateSubject.value.message).toContain('taking longer than expected'); + }); + + it('retries refresh-token hydration without routing away after a transient failure', () => { + authCallback(false, 'timeout'); + expect(service.startupStateSubject.value).toMatchObject({ + status: 'error', + phase: 'authentication', + }); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + + authentication.isAuthenticated.mockReturnValue(false); + service.retryStartup(); + + expect(authentication.attemptLoginUsingRefreshToken).toHaveBeenCalledTimes(2); + expect(service.startupStateSubject.value.status).toBe('loading'); + }); +}); + +describe('GlobalStateService project loading', () => { + let service: GlobalStateService; + let projectResponse: Subject; + let queryProjects: ReturnType; + let alerts: {error: ReturnType}; + let originalViewportHeight: string; + + beforeEach(() => { + vi.useFakeTimers(); + originalViewportHeight = document.body.style.getPropertyValue('--vh'); + // Viewport events are unrelated to project loading. Avoid retaining the + // service's bound window listeners after each independently created instance. + vi.spyOn(window, 'addEventListener').mockImplementation(() => {}); + projectResponse = new Subject(); + queryProjects = vi.fn(() => projectResponse); + alerts = {error: vi.fn()}; + TestBed.configureTestingModule({ + providers: [ + GlobalStateService, + { + provide: UnitRoleService, + useValue: {cache: new EntityCache(), query: vi.fn(() => of([]))}, + }, + {provide: UnitService, useValue: {cache: new EntityCache()}}, + { + provide: UserService, + useValue: { + cache: {clear: vi.fn()}, + currentUser: {isStaff: false, hasRunFirstTimeSetup: true}, + }, + }, + { + provide: AuthenticationService, + useValue: { + attemptLoginUsingRefreshToken: vi.fn((callback: (result: boolean) => void) => + callback(true), + ), + }, + }, + { + provide: ProjectService, + useValue: {cache: new EntityCache(), query: queryProjects}, + }, + {provide: CampusService, useValue: {query: vi.fn(() => of([]))}}, + {provide: TeachingPeriodService, useValue: {query: vi.fn(() => of([]))}}, + {provide: LearningOutcomeService, useValue: {query: vi.fn(() => of([]))}}, + {provide: FeedbackTemplateService, useValue: {query: vi.fn(() => of([]))}}, + {provide: Router, useValue: {navigateByUrl: vi.fn()}}, + {provide: AlertService, useValue: alerts}, + {provide: BreakpointObserver, useValue: {isMatched: vi.fn(() => false)}}, + {provide: AuthReturnUrlService, useValue: {rememberCurrentUrl: vi.fn()}}, + ], + }); + service = TestBed.inject(GlobalStateService); + }); + + afterEach(() => { + projectResponse.complete(); + TestBed.resetTestingModule(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + if (originalViewportHeight) { + document.body.style.setProperty('--vh', originalViewportHeight); + } else { + document.body.style.removeProperty('--vh'); + } + }); + + it('opens the loading gate with a recoverable error when the project request fails', () => { + const ready = vi.fn(); + service.onLoad(ready); + service.loadGlobals(); + expect(queryProjects).toHaveBeenCalledWith(undefined, { + params: {include_inactive: false, include_task_definitions: true}, + }); + expect(service.isLoadingSubject.value).toBe(true); + expect(ready).not.toHaveBeenCalled(); + + projectResponse.error(new Error('private upstream response')); + + expect(service.projectLoadErrorSubject.value).toBe(true); + expect(service.isLoadingSubject.value).toBe(false); + expect(ready).toHaveBeenCalledOnce(); + expect(alerts.error).toHaveBeenCalledExactlyOnceWith( + 'Unable to access the units you study.', + 6000, + ); + vi.runOnlyPendingTimers(); + expect(ready).toHaveBeenCalledOnce(); + }); + + it('opens the loading gate after a successful load without reporting an error', () => { + const ready = vi.fn(); + service.onLoad(ready); + service.loadGlobals(); + expect(service.isLoadingSubject.value).toBe(true); + expect(ready).not.toHaveBeenCalled(); + + projectResponse.next([]); + projectResponse.complete(); + + expect(service.isLoadingSubject.value).toBe(false); + expect(service.projectLoadErrorSubject.value).toBe(false); + expect(service.startupStateSubject.value.status).toBe('ready'); + expect(ready).toHaveBeenCalledOnce(); + expect(alerts.error).not.toHaveBeenCalled(); + }); + + it('clears a previous project error when a subsequent load succeeds', () => { + service.loadGlobals(); + projectResponse.error(new Error('offline')); + expect(service.projectLoadErrorSubject.value).toBe(true); + + projectResponse = new Subject(); + queryProjects.mockReturnValue(projectResponse); + service.loadGlobals(); + expect(service.projectLoadErrorSubject.value).toBe(false); + expect(service.isLoadingSubject.value).toBe(true); + projectResponse.next([]); + projectResponse.complete(); + vi.advanceTimersByTime(800); + + expect(service.isLoadingSubject.value).toBe(false); + expect(service.projectLoadErrorSubject.value).toBe(false); + expect(queryProjects).toHaveBeenCalledTimes(2); + }); +}); + +function emptyCache(): object { + return { + clear: vi.fn(), + values: new Subject().asObservable(), + currentValues: [], + }; +} diff --git a/src/app/projects/states/index/global-state.service.ts b/src/app/projects/states/index/global-state.service.ts index b95fde868c..16124ae919 100644 --- a/src/app/projects/states/index/global-state.service.ts +++ b/src/app/projects/states/index/global-state.service.ts @@ -1,8 +1,21 @@ -import {MediaObserver} from 'ng-flex-layout'; import {EntityCache} from 'ngx-entity-service'; +import {BreakpointObserver} from '@angular/cdk/layout'; import {Injectable, OnDestroy} from '@angular/core'; import {Router} from '@angular/router'; -import {BehaviorSubject, Observable, Subject, find} from 'rxjs'; +import { + BehaviorSubject, + Observable, + Subject, + Subscription, + TimeoutError, + catchError, + forkJoin, + of, + switchMap, + tap, + throwError, + timeout, +} from 'rxjs'; import { CampusService, LearningOutcomeService, @@ -15,9 +28,13 @@ import { UnitService, UserService, } from 'src/app/api/models/doubtfire-model'; -import {AuthenticationService} from 'src/app/api/services/authentication.service'; +import { + AuthenticationService, + RefreshTokenFailureReason, +} from 'src/app/api/services/authentication.service'; import {FeedbackTemplateService} from 'src/app/api/services/feedback-template.service'; import {AlertService} from 'src/app/common/services/alert.service'; +import {AuthReturnUrlService} from 'src/app/security/auth-return-url.service'; /** * The different types of views that can be shown. Used by the header to determine details to show. @@ -37,6 +54,31 @@ export class DoubtfireViewState { public viewType: ViewType; } +export type StartupPhase = 'authentication' | 'foundations' | 'units-and-projects' | 'ready'; +export type StartupStatus = 'loading' | 'ready' | 'error' | 'offline' | 'signed-out'; + +export interface StartupState { + status: StartupStatus; + phase: StartupPhase; + message: string; + attempt: number; + startedAt: number; + elapsedMs?: number; + failedResource?: string; +} + +export const STARTUP_TIMEOUT_MS = 12_000; + +class StartupRequestError extends Error { + constructor( + public readonly resource: string, + public readonly originalError: unknown, + ) { + super(`Failed loading ${resource}`); + this.name = 'StartupRequestError'; + } +} + @Injectable({ providedIn: 'root', }) @@ -98,9 +140,27 @@ export class GlobalStateService implements OnDestroy { * protect views from attempting to access details before they are loaded. */ public isLoadingSubject: BehaviorSubject = new BehaviorSubject(true); + public projectLoadErrorSubject: BehaviorSubject = new BehaviorSubject(false); + + /** + * A finite, user-visible bootstrap state. isLoadingSubject remains for older + * consumers that only need ready/not-ready, while this state distinguishes + * active work from a recoverable terminal failure. + */ + public startupStateSubject: BehaviorSubject = new BehaviorSubject({ + status: 'loading', + phase: 'authentication', + message: 'Checking your session…', + attempt: 0, + startedAt: Date.now(), + }); public showHideHeader: Subject = new Subject(); + private startupAttempt = 0; + private globalsSubscription?: Subscription; + private readonly resetHeightListener = () => this.resetHeight(); + constructor( private unitRoleService: UnitRoleService, private unitService: UnitService, @@ -113,41 +173,88 @@ export class GlobalStateService implements OnDestroy { private feedbackTemplateService: FeedbackTemplateService, private router: Router, private alerts: AlertService, - private mediaObserver: MediaObserver, + private breakpointObserver: BreakpointObserver, + private authReturnUrl: AuthReturnUrlService, ) { this.loadedUnitRoles = this.unitRoleService.cache; this.loadedUnits = this.unitService.cache; this.currentUserProjects = this.projectService.cache; - // Use timeout to ensure everything is loaded before we try to login - setTimeout(() => { - // Try to login using the refresh token - this.authenticationService.attemptLoginUsingRefreshToken((result: boolean) => { + this.authenticateFromRefreshToken(); + + // this is a hack to workaround horrific IOS "feature" + // https://stackoverflow.com/questions/37112218/css3-100vh-not-constant-in-mobile-browser + + window.addEventListener('orientationchange', this.resetHeightListener); + window.addEventListener('resize', this.resetHeightListener); + this.resetHeight(); + } + + private authenticateFromRefreshToken(): void { + const startedAt = Date.now(); + // Verification uses its own one-time token and must work in a signed-out + // email browser, even if a saved session is expired or unavailable. + if (this.isPublicVerificationRoute()) { + this.completeSignedOutStartup(startedAt); + return; + } + this.startupAttempt += 1; + this.isLoadingSubject.next(true); + this.publishStartupState({ + status: 'loading', + phase: 'authentication', + message: 'Checking your session…', + attempt: this.startupAttempt, + startedAt, + }); + + this.authenticationService.attemptLoginUsingRefreshToken( + (result: boolean, failureReason?: RefreshTokenFailureReason) => { if (result) { + this.recordStartupDiagnostic('authentication', 'ready', startedAt); if ( this.userService.currentUser.hasRunFirstTimeSetup === false && window.location.pathname !== '/welcome' ) { - this.router.navigateByUrl('/welcome'); + void this.router.navigateByUrl('/welcome'); } - } else { - // Loading is finshed... - this.isLoadingSubject.next(false); + return; + } - // and if we are not going to the sign in page, then redirect to it - if (window.location.pathname !== '/sign_in') { - this.router.navigateByUrl('/sign_in'); - } + if ( + failureReason === 'offline' || + failureReason === 'timeout' || + failureReason === 'unavailable' + ) { + this.publishStartupFailure('authentication', startedAt, failureReason); + return; } - }); - }, 100); - // this is a hack to workaround horrific IOS "feature" - // https://stackoverflow.com/questions/37112218/css3-100vh-not-constant-in-mobile-browser + this.completeSignedOutStartup(startedAt); + }, + ); + } - window.addEventListener('orientationchange', this.resetHeight.bind(this)); - window.addEventListener('resize', this.resetHeight.bind(this)); - this.resetHeight(); + private completeSignedOutStartup(startedAt: number): void { + this.isLoadingSubject.next(false); + this.publishStartupState({ + status: 'signed-out', + phase: 'authentication', + message: '', + attempt: this.startupAttempt, + startedAt, + elapsedMs: Date.now() - startedAt, + }); + this.recordStartupDiagnostic('authentication', 'signed-out', startedAt); + + if (window.location.pathname !== '/sign_in' && !this.isPublicVerificationRoute()) { + this.authReturnUrl.rememberCurrentUrl(); + void this.router.navigateByUrl('/sign_in'); + } + } + + private isPublicVerificationRoute(): boolean { + return window.location.pathname.replace(/\/$/, '') === '/verify_additional_email'; } private resetHeight() { @@ -156,7 +263,7 @@ export class GlobalStateService implements OnDestroy { if (this._isInboxState) { document.body.style.setProperty('--vh', `${vh}px`); - } else if (!this.mediaObserver.isActive('gt-sm') || !this._showFooter) { + } else if (!this.breakpointObserver.isMatched('(min-width: 960px)') || !this._showFooter) { document.body.style.setProperty('--vh', `${vh - 0.2}px`); } else { if (this._showFooter && !this._showFooterWarning) { @@ -174,20 +281,22 @@ export class GlobalStateService implements OnDestroy { public setInboxState() { this._isInboxState = true; - // set background color to inbox grey - document.body.style.setProperty('background-color', '#f5f5f5'); + // THM-M01: inbox grey, was hardcoded #f5f5f5 inline. Onto the page token so it + // follows the resolved theme; the fallback keeps the legacy value pre-boot. + document.body.style.setProperty('background-color', 'var(--ot-color-page, #f5f5f5)'); this.resetHeight(); } public goHome() { this.showHeader(); - document.body.style.setProperty('background-color', '#f5f5f5'); + document.body.style.setProperty('background-color', 'var(--ot-color-page, #f5f5f5)'); } public setNotInboxState() { this._isInboxState = false; - // set background color to white - document.body.style.setProperty('background-color', '#fff'); + // Hand the page back to the stylesheet. Painting it the surface colour here made + // every page after the inbox match its own cards, so the cards lost their edge. + document.body.style.removeProperty('background-color'); this.resetHeight(); } @@ -226,103 +335,211 @@ export class GlobalStateService implements OnDestroy { this.userService.cache.clear(); this.clearUnitsAndProjects(); this.isLoadingSubject.next(false); + this.publishStartupState({ + status: 'signed-out', + phase: 'authentication', + message: '', + attempt: this.startupAttempt, + startedAt: Date.now(), + }); this.authenticationService.signOut(); } public ngOnDestroy(): void { + this.globalsSubscription?.unsubscribe(); + window.removeEventListener('orientationchange', this.resetHeightListener); + window.removeEventListener('resize', this.resetHeightListener); this.isLoadingSubject.complete(); + this.startupStateSubject.complete(); + this.projectLoadErrorSubject.complete(); this.showHideHeader.complete(); this.currentViewAndEntitySubject$.complete(); } public loadGlobals(): void { - let loaded = 0; - // Indicate we are loading data... + this.globalsSubscription?.unsubscribe(); + const startedAt = Date.now(); + this.startupAttempt += 1; + this.projectLoadErrorSubject.next(false); this.isLoadingSubject.next(true); + this.publishStartupState({ + status: 'loading', + phase: 'foundations', + message: 'Loading OnTrack…', + attempt: this.startupAttempt, + startedAt, + }); - // Loading observer watches for loading of campuses, and teaching periods before loading unit roles, and projects - const loadingObserver = new Observable((subscriber) => { - // Loading campuses - this.campusService.query().subscribe({ - next: (_response) => { - subscriber.next(++loaded); - }, - error: (_response) => { - this.alerts.error('Unable to access service. Failed loading campuses.', 6000); - }, + this.loadOptionalStaffGlobals(); + + const foundations$ = forkJoin([ + this.requiredStartupRequest('campuses', this.campusService.query()), + this.requiredStartupRequest('teaching periods', this.teachingPeriodService.query()), + ]); + + this.globalsSubscription = foundations$ + .pipe( + tap(() => { + this.publishStartupState({ + status: 'loading', + phase: 'units-and-projects', + message: 'Loading your units…', + attempt: this.startupAttempt, + startedAt, + }); + }), + switchMap(() => + forkJoin([ + this.requiredStartupRequest('unit roles', this.unitRoleService.query()), + this.loadCurrentUserProjects(), + ]), + ), + timeout({first: STARTUP_TIMEOUT_MS}), + ) + .subscribe({ + next: () => this.completeGlobalLoad(startedAt), + error: (error: unknown) => this.failGlobalLoad(startedAt, error), }); + } - if (this.userService.currentUser.isStaff) { - this.learningOutcomeService - .query({}, {endpointFormat: LearningOutcomeService.globalEndpoint}) - .subscribe({ - next: (_response) => { - subscriber.next(null); - }, - error: (_response) => { - this.alerts.error('Unable to access service. Failed loading GLOs.', 6000); - }, - }); + /** + * Query the projects the current user studies. A failure here does not stop startup: + * it opens the loading gate with projectLoadErrorSubject set, so views can render a + * recoverable project error instead of blocking the whole application. + */ + private loadCurrentUserProjects(): Observable { + return this.projectService + .query(undefined, { + params: { + include_inactive: false, + include_task_definitions: true, + }, + }) + .pipe( + catchError(() => { + this.projectLoadErrorSubject.next(true); + this.alerts.error('Unable to access the units you study.', 6000); + return of([] as Project[]); + }), + ); + } - this.feedbackTemplateService - .query({}, {endpointFormat: FeedbackTemplateService.globalEndpoint}) - .subscribe({ - next: (_response) => { - subscriber.next(null); - }, - error: (_response) => { - this.alerts.error( - 'Unable to access service. Failed loading GLO feedback templates.', - 6000, - ); - }, - }); - } + public retryStartup(): void { + if (this.startupStateSubject.value.phase === 'authentication') { + this.authenticateFromRefreshToken(); + } else if (this.authenticationService.isAuthenticated()) { + this.loadGlobals(); + } else { + this.authenticateFromRefreshToken(); + } + } + + public continueToSignIn(): void { + const startedAt = Date.now(); + this.completeSignedOutStartup(startedAt); + } + + private requiredStartupRequest(resource: string, request: Observable): Observable { + return request.pipe( + catchError((error: unknown) => throwError(() => new StartupRequestError(resource, error))), + ); + } - // Loading teaching periods - this.teachingPeriodService.query().subscribe({ - next: (_response) => { - subscriber.next(++loaded); + private loadOptionalStaffGlobals(): void { + if (!this.userService.currentUser.isStaff) { + return; + } + + this.learningOutcomeService + .query({}, {endpointFormat: LearningOutcomeService.globalEndpoint}) + .subscribe({ + error: () => { + this.alerts.error('Unable to access service. Failed loading GLOs.', 6000); }, - error: (_response) => { - this.alerts.error('Unable to access service. Failed loading teaching periods.', 6000); + }); + + this.feedbackTemplateService + .query({}, {endpointFormat: FeedbackTemplateService.globalEndpoint}) + .subscribe({ + error: () => { + this.alerts.error( + 'Unable to access service. Failed loading GLO feedback templates.', + 6000, + ); }, }); + } + + private completeGlobalLoad(startedAt: number): void { + const elapsedMs = Date.now() - startedAt; + this.publishStartupState({ + status: 'ready', + phase: 'ready', + message: '', + attempt: this.startupAttempt, + startedAt, + elapsedMs, }); + this.isLoadingSubject.next(false); + this.recordStartupDiagnostic('globals', 'ready', startedAt); + } - // Watch for load of campuses and teaching periods, then trigger loading of unit roles and projects - loadingObserver.pipe(find((loaded) => loaded === 2)).subscribe({ - next: () => { - // trigger loading of units and projects - this will end the loading when complete - this.loadUnitsAndProjects(); - }, + private failGlobalLoad(startedAt: number, error: unknown): void { + const failedResource = + error instanceof StartupRequestError ? error.resource : 'startup services'; + this.publishStartupFailure('units-and-projects', startedAt, undefined, failedResource, error); + } + + private publishStartupFailure( + phase: StartupPhase, + startedAt: number, + failureReason?: RefreshTokenFailureReason, + failedResource?: string, + error?: unknown, + ): void { + const offline = failureReason === 'offline' || this.isOffline; + const timedOut = failureReason === 'timeout' || error instanceof TimeoutError; + const status: StartupStatus = offline ? 'offline' : 'error'; + const message = offline + ? 'You appear to be offline. Check your connection and try again.' + : timedOut + ? 'OnTrack is taking longer than expected. Try again when your connection is stable.' + : failedResource + ? `OnTrack could not load ${failedResource}. Please try again.` + : 'OnTrack could not finish starting. Please try again.'; + + this.publishStartupState({ + status, + phase, + message, + attempt: this.startupAttempt, + startedAt, + elapsedMs: Date.now() - startedAt, + failedResource, }); + this.recordStartupDiagnostic(phase, status, startedAt, failedResource); } - /** - * Query the API for the units taught and studied by the current user. - */ - private loadUnitsAndProjects() { - this.unitRoleService.query().subscribe({ - next: (_unitRoles: UnitRole[]) => { - // unit roles are now in the cache - - this.projectService.query(undefined, {params: {include_in_active: false}}).subscribe({ - next: (_projects: Project[]) => { - // projects updated in cache - - setTimeout(() => { - this.isLoadingSubject.next(false); - }, 800); - }, - error: (_response) => { - this.alerts.error('Unable to access the units you study.', 6000); - }, - }); - }, - error: (_response) => { - this.alerts.error('Unable to access your units.', 6000); - }, + private get isOffline(): boolean { + return typeof navigator !== 'undefined' && navigator.onLine === false; + } + + private publishStartupState(state: StartupState): void { + this.startupStateSubject.next(state); + } + + private recordStartupDiagnostic( + phase: string, + outcome: string, + startedAt: number, + resource?: string, + ): void { + console.info('[OnTrack bootstrap]', { + phase, + outcome, + resource, + attempt: this.startupAttempt, + elapsedMs: Date.now() - startedAt, }); } @@ -346,6 +563,7 @@ export class GlobalStateService implements OnDestroy { * Clear all of the project and unit role data on sign out */ public clearUnitsAndProjects(): void { + this.projectLoadErrorSubject.next(false); this.loadedUnits.clear(); this.loadedUnitRoles.clear(); this.userService.cache.clear(); diff --git a/src/app/projects/states/plan/project-plan.component.html b/src/app/projects/states/plan/project-plan.component.html index 50714cdf88..a9d05631d7 100644 --- a/src/app/projects/states/plan/project-plan.component.html +++ b/src/app/projects/states/plan/project-plan.component.html @@ -1,47 +1,67 @@ @if (project) { -
      -

      - Task Planner - @if (viewingOtherStudentProject) { - for {{ project.student.name }} - } -

      -

      - @if (unit.allowFlexibleDates) { - View and adjust the due dates for your tasks. Remember to leave time to get and respond to - feedback. - } @else { - View the task deadlines for your project, so you can organise your work and ensure you meet - all submission requirements on time. - } -

      -

      - Click on a task in the timeline to see how it connects to other tasks. This will show you - which tasks must be completed before it, and which tasks depend on it being completed first. -

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

      + Task Planner + @if (viewingOtherStudentProject && project.student?.displayName; as studentName) { + for {{ studentName }} + } +

      +

      + @if (unit.allowFlexibleDates) { + Drag the bars to plan your own dates. Leave time to get feedback and to act on it. + } @else { + See when each task starts and is due, so you can plan your work and submit on time. } - - -

      -

      - Subscribe to your unit calendar to be reminded about your due dates. - -

      -
      +

      +

      + Select a task to see its prerequisites and dependent tasks. Larger screens also show these + connections on the timeline. +

      +
      + +
      + + Target grade + + @for (grade of gradeValues; track grade) { + {{ gradeString(grade) }} + } + + + +
      + + -
      - -
      + + +
      +
      + +
      +
      +
      } diff --git a/src/app/projects/states/plan/project-plan.component.spec.ts b/src/app/projects/states/plan/project-plan.component.spec.ts new file mode 100644 index 0000000000..d2e34a4044 --- /dev/null +++ b/src/app/projects/states/plan/project-plan.component.spec.ts @@ -0,0 +1,132 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectChange, MatSelectModule} from '@angular/material/select'; +import {MatSelectHarness} from '@angular/material/select/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {ActivatedRoute} from '@angular/router'; +import {of} from 'rxjs'; +import {Project} from 'src/app/api/models/project'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {CalendarModalService} from 'src/app/common/modals/calendar-modal/calendar-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {ProjectPlanComponent} from './project-plan.component'; + +describe('ProjectPlanComponent', () => { + let component: ProjectPlanComponent; + let fixture: ComponentFixture; + let project: Project; + let projectService: {update: ReturnType}; + let calendarModal: {show: ReturnType}; + + const buildProject = (myRole: string, studentId: number): Project => + ({ + id: 18, + targetGrade: 0, + student: {id: studentId}, + unit: { + myRole, + allowFlexibleDates: false, + gradeDefinitions: [ + {value: 0, label: 'Pass', abbreviation: 'P'}, + {value: 1, label: 'Credit', abbreviation: 'C'}, + ], + }, + }) as unknown as Project; + + const createComponent = async (currentProject: Project) => { + project = currentProject; + projectService = {update: vi.fn(() => of(project))}; + calendarModal = {show: vi.fn()}; + + await TestBed.configureTestingModule({ + declarations: [ProjectPlanComponent], + imports: [FormsModule, MatFormFieldModule, MatSelectModule, NoopAnimationsModule], + providers: [ + { + provide: GradeService, + useValue: { + gradeValuesFor: () => [0, 1], + gradeLabel: (grade: number) => (grade === 0 ? 'Pass' : 'Credit'), + }, + }, + {provide: ProjectService, useValue: projectService}, + {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}}, + {provide: CalendarModalService, useValue: calendarModal}, + {provide: UserService, useValue: {currentUser: {id: 25}}}, + {provide: ActivatedRoute, useValue: {parent: null}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectPlanComponent); + component = fixture.componentInstance; + component.project$ = of(project); + fixture.detectChanges(); + await fixture.whenStable(); + }; + + const targetGradeSelect = () => + TestbedHarnessEnvironment.loader(fixture).getHarness( + MatSelectHarness.with({selector: '[aria-label="Target grade"]'}), + ); + + beforeEach(() => { + TestBed.resetTestingModule(); + }); + + it('lets a student change their own target grade', async () => { + await createComponent(buildProject('Student', 25)); + + expect(component.viewingOtherStudentProject).toBe(false); + expect(await (await targetGradeSelect()).isDisabled()).toBe(false); + }); + + it('locks the selector when staff are viewing another student project', async () => { + await createComponent(buildProject('Tutor', 99)); + + expect(component.viewingOtherStudentProject).toBe(true); + expect(await (await targetGradeSelect()).isDisabled()).toBe(true); + }); + + it('groups target grade and calendar access as clear primary controls', async () => { + await createComponent(buildProject('Student', 25)); + + const controls: HTMLElement = fixture.nativeElement.querySelector( + '[aria-label="Task planner controls"]', + ); + const calendarButton: HTMLButtonElement = controls.querySelector('button'); + + expect(controls.getAttribute('aria-label')).toBe('Task planner controls'); + expect(controls.querySelector('[aria-label="Target grade"]')).not.toBeNull(); + expect(calendarButton.textContent).toContain('Subscribe to calendar'); + + calendarButton.click(); + expect(calendarModal.show).toHaveBeenCalledWith(null); + }); + + it('lets the planner grow naturally so phone task cards are not clipped', async () => { + await createComponent(buildProject('Student', 25)); + + const surface = fixture.nativeElement.querySelector('.task-planner-surface') as HTMLElement; + expect(surface.style.height).toBe(''); + expect(surface.querySelector('f-task-planner')).not.toBeNull(); + }); + + it('refreshes the same planner after a successful target-grade change', async () => { + await createComponent(buildProject('Student', 25)); + const refreshItems = vi.fn(); + component.planner = {refreshItems} as never; + + component.onTargetGradeChange({value: 1} as MatSelectChange); + + expect(projectService.update).toHaveBeenCalledWith(project); + expect(project.targetGrade).toBe(1); + expect(refreshItems).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/app/projects/states/project-root-state.component.spec.ts b/src/app/projects/states/project-root-state.component.spec.ts new file mode 100644 index 0000000000..d31cfd7740 --- /dev/null +++ b/src/app/projects/states/project-root-state.component.spec.ts @@ -0,0 +1,24 @@ +import {describe, expect, it} from 'vitest'; +import {ActivatedRoute, Data} from '@angular/router'; +import {BehaviorSubject} from 'rxjs'; +import {Project} from 'src/app/api/models/doubtfire-model'; +import {ProjectRootStateComponent} from './project-root-state.component'; + +describe('ProjectRootStateComponent', () => { + it('passes project resolver updates to a reused child route', () => { + const firstProject = {id: 2} as Project; + const secondProject = {id: 18} as Project; + const routeData: BehaviorSubject = new BehaviorSubject({project: firstProject}); + const component = new ProjectRootStateComponent({ + data: routeData.asObservable(), + } as ActivatedRoute); + const child: {project$?: typeof component.project$} = {project$: undefined}; + const projectIds: number[] = []; + + component.onActivate(child); + child.project$.subscribe((project) => projectIds.push(project.id)); + routeData.next({project: secondProject}); + + expect(projectIds).toEqual([2, 18]); + }); +}); diff --git a/src/app/projects/states/project-root-state.component.ts b/src/app/projects/states/project-root-state.component.ts index a9d436dde3..ce724d3ecd 100644 --- a/src/app/projects/states/project-root-state.component.ts +++ b/src/app/projects/states/project-root-state.component.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; -import {Observable, Subscription, of} from 'rxjs'; +import {Observable, Subscription, filter, map, shareReplay} from 'rxjs'; import {Project} from 'src/app/api/models/doubtfire-model'; interface ProjectRouteChild { @@ -25,8 +24,11 @@ export class ProjectRootStateComponent implements OnDestroy { private taskListWidthSub?: Subscription; constructor(private activatedRoute: ActivatedRoute) { - const project = this.activatedRoute.snapshot.data.project as Project; - this.project$ = this.project$ ?? (project ? of(project) : undefined); + this.project$ = this.activatedRoute.data.pipe( + map((data) => data.project as Project), + filter((project): project is Project => !!project), + shareReplay({bufferSize: 1, refCount: true}), + ); } onActivate(component: ProjectRouteChild): void { diff --git a/src/app/projects/states/staff-notes/staff-notes.component.spec.ts b/src/app/projects/states/staff-notes/staff-notes.component.spec.ts index 270c10d536..781d7ae7ba 100644 --- a/src/app/projects/states/staff-notes/staff-notes.component.spec.ts +++ b/src/app/projects/states/staff-notes/staff-notes.component.spec.ts @@ -1,13 +1,24 @@ -import {beforeEach, describe, expect, it} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {EMPTY, Subject, of, throwError} from 'rxjs'; import {UserService} from 'src/app/api/models/doubtfire-model'; +import {StaffNote} from 'src/app/api/models/staff-note'; import {StaffNoteService} from 'src/app/api/services/staff-note.service'; +import {EmptyStateComponent} from 'src/app/common/empty-state/empty-state.component'; import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {HumanizedDatePipe} from 'src/app/common/pipes/humanized-date.pipe'; +import {LocalizedDatePipe} from 'src/app/common/pipes/localized-date.pipe'; +import {MarkedPipe} from 'src/app/common/pipes/marked.pipe'; import {AlertService} from 'src/app/common/services/alert.service'; import {StaffNotesComponent} from './staff-notes.component'; const emptyProvider = {}; +const staffNoteServiceStub = { + loadStaffNotes: () => EMPTY, + updateStaffNoteReplies: () => undefined, +}; describe('StaffNotesComponent', () => { let component: StaffNotesComponent; @@ -15,10 +26,10 @@ describe('StaffNotesComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [StaffNotesComponent], + declarations: [StaffNotesComponent, HumanizedDatePipe, LocalizedDatePipe, MarkedPipe], providers: [ {provide: UserService, useValue: emptyProvider}, - {provide: StaffNoteService, useValue: emptyProvider}, + {provide: StaffNoteService, useValue: staffNoteServiceStub}, {provide: AlertService, useValue: emptyProvider}, {provide: ConfirmationModalService, useValue: emptyProvider}, ], @@ -37,3 +48,215 @@ describe('StaffNotesComponent', () => { expect(component).toBeTruthy(); }); }); + +describe('StaffNotesComponent note actions', () => { + let component: StaffNotesComponent; + let fixture: ComponentFixture; + let note: StaffNote; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [StaffNotesComponent, HumanizedDatePipe, LocalizedDatePipe, MarkedPipe], + providers: [ + {provide: UserService, useValue: emptyProvider}, + {provide: StaffNoteService, useValue: staffNoteServiceStub}, + {provide: AlertService, useValue: emptyProvider}, + {provide: ConfirmationModalService, useValue: emptyProvider}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(StaffNotesComponent); + component = fixture.componentInstance; + note = {id: 7, replyToId: null, note: 'a note', user: {}, authorIsMe: true} as StaffNote; + + fixture.detectChanges(); + component.loadingStaffNotes = false; + component.project = { + student: {preferredName: 'Ada', lastName: 'Lovelace', displayName: 'Ada Lovelace'}, + staffNoteCache: {currentValues: [note]}, + } as never; + fixture.detectChanges(); + }); + + function card(): HTMLElement { + return fixture.nativeElement.querySelector('.note-card') as HTMLElement; + } + + function editButton(): HTMLButtonElement { + return card().querySelector('.note-actions button') as HTMLButtonElement; + } + + it('renders the note actions instead of gating them behind a pointer flag', () => { + const actions = card().querySelector('.note-actions') as HTMLElement; + + expect(actions).toBeTruthy(); + expect(actions.hasAttribute('hidden')).toBe(false); + }); + + it('builds the actions out of real buttons rather than bare icons', () => { + const buttons = card().querySelectorAll('.note-actions button'); + + expect(buttons.length).toBe(3); + expect(editButton().getAttribute('aria-label')).toBe('Edit this note'); + }); + + it('does not meet the reveal condition while nothing in the note has focus', () => { + expect(card().matches(':focus-within')).toBe(false); + }); + + it('meets the reveal condition once the keyboard reaches the actions', () => { + editButton().focus(); + + expect(document.activeElement).toBe(editButton()); + expect(card().matches(':focus-within')).toBe(true); + }); + + it('opens the editor through the button the pointer uses, not through injected state', () => { + const button = editButton(); + button.focus(); + button.click(); + fixture.detectChanges(); + + expect(component.editingNote).toBe(note); + expect(card().querySelector('textarea')).toBeTruthy(); + }); +}); + +describe('StaffNotesComponent states', () => { + let component: StaffNotesComponent; + let fixture: ComponentFixture; + let loadStaffNotes: ReturnType; + let addNote: ReturnType; + + beforeEach(async () => { + loadStaffNotes = vi.fn(() => of([])); + addNote = vi.fn(() => EMPTY); + + await TestBed.configureTestingModule({ + declarations: [StaffNotesComponent, HumanizedDatePipe, LocalizedDatePipe, MarkedPipe], + // The real button, so disabledInteractive behaves as it does in the app. + imports: [EmptyStateComponent, MatButtonModule], + providers: [ + {provide: UserService, useValue: emptyProvider}, + { + provide: StaffNoteService, + useValue: {loadStaffNotes, addNote, updateStaffNoteReplies: () => undefined}, + }, + {provide: AlertService, useValue: emptyProvider}, + {provide: ConfirmationModalService, useValue: emptyProvider}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + function projectFor(name: string): never { + return {student: {name}, staffNoteCache: {currentValues: []}} as never; + } + + function render(): void { + fixture = TestBed.createComponent(StaffNotesComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('project', projectFor('Ada Lovelace')); + fixture.detectChanges(); + } + + function text(): string { + return fixture.nativeElement.textContent.replace(/\s+/g, ' '); + } + + function buttonLabelled(label: string): HTMLButtonElement | undefined { + return Array.from(fixture.nativeElement.querySelectorAll('button')).find( + (button) => labelOf(button) === label, + ); + } + + // The button's own label, without the text of an icon beside it. + function labelOf(button: HTMLButtonElement): string { + return (button.querySelector('.mdc-button__label') ?? button).textContent.trim(); + } + + it('says there are no notes yet once an empty list loads', () => { + render(); + + expect(text()).toContain('No notes yet'); + expect(text()).toContain('Notes you add below are only seen by staff.'); + expect(fixture.nativeElement.querySelector('.note-card')).toBeNull(); + }); + + it('shows an error state with Try again when the notes fail to load', () => { + loadStaffNotes.mockReturnValueOnce(throwError(() => new Error('offline'))); + render(); + + expect(text()).toContain('The notes did not load'); + expect(text()).not.toContain('No notes yet'); + + buttonLabelled('Try again').click(); + fixture.detectChanges(); + + expect(loadStaffNotes).toHaveBeenCalledTimes(2); + expect(text()).not.toContain('The notes did not load'); + expect(text()).toContain('No notes yet'); + }); + + it('keeps Save note focusable but inactive until there is something to save', () => { + render(); + const save = buttonLabelled('Save note'); + + expect(save.getAttribute('type')).toBe('button'); + expect(save.classList).toContain('mat-mdc-unelevated-button'); + expect(save.querySelector('mat-icon')?.textContent.trim()).toBe('save'); + expect(save.hasAttribute('disabled')).toBe(false); + expect(save.getAttribute('aria-disabled')).toBe('true'); + + component.noteText = ' '; + fixture.detectChanges(); + save.click(); + expect(save.getAttribute('aria-disabled')).toBe('true'); + expect(addNote).not.toHaveBeenCalled(); + + component.noteText = 'Talked about the extension'; + fixture.detectChanges(); + expect(save.getAttribute('aria-disabled')).toBeNull(); + }); + + it('labels the note field and lets it grow from two to eight rows', () => { + render(); + const field = fixture.nativeElement.querySelector('textarea[cdkTextareaAutosize]'); + + expect(field.closest('mat-form-field').textContent).toContain('Add a note'); + expect(field.getAttribute('cdkAutosizeMinRows')).toBe('2'); + expect(field.getAttribute('cdkAutosizeMaxRows')).toBe('8'); + }); + + it('drops a reply, an edit and a draft meant for the previous student', () => { + render(); + component.replyingToNote = {id: 1} as StaffNote; + component.editingNote = {id: 2} as StaffNote; + component.noteText = 'A draft about Ada'; + + fixture.componentRef.setInput('project', projectFor('Grace Hopper')); + fixture.detectChanges(); + + expect(component.replyingToNote).toBeNull(); + expect(component.editingNote).toBeNull(); + expect(component.noteText).toBe(''); + expect(loadStaffNotes).toHaveBeenCalledTimes(2); + }); + + it('ignores a load for the previous student that finishes late', () => { + const late: Subject = new Subject(); + loadStaffNotes.mockReturnValueOnce(late); + render(); + + fixture.componentRef.setInput('project', projectFor('Grace Hopper')); + fixture.detectChanges(); + late.error(new Error('offline')); + fixture.detectChanges(); + + expect(text()).not.toContain('The notes did not load'); + expect(text()).toContain('No notes yet'); + }); +}); diff --git a/src/app/projects/states/tutor-discussion/tutor-discussion.component.html b/src/app/projects/states/tutor-discussion/tutor-discussion.component.html index 78089a4f36..c26b8d2068 100644 --- a/src/app/projects/states/tutor-discussion/tutor-discussion.component.html +++ b/src/app/projects/states/tutor-discussion/tutor-discussion.component.html @@ -1,274 +1,594 @@ -
      - @if (loadingStudentData) { - - } -
      -
      -
      -
      - Scan a student's QR code to instantly load their project and mark submissions. -
      - -
      + +
      +
      +

      + {{ attendance ? 'Check-in' : 'Discussion' }} + @if (unit?.code) { + in {{ unit.code }} + } +

      +

      + @if (attendance) { + Choose a task, then scan each student's QR code to check them in for it. + } @else { + Scan a student's QR code to open the tasks they want to talk about, then mark what you + discussed. + } +

      +
      +
      - -
      - @if (attendance && unit) { -
      - - - @for ( - td of $safeNavigationMigration(unit?.taskDefinitionCache.values) | async; - track td - ) { - {{ td.abbreviation }} - {{ td.name }} +
      + @if (attendance && hasUnit) { +
      +
      +

      + Task to check in +

      + @if (loadingUnit) { +

      + + Loading the unit's tasks... +

      + } @else if (unit) { +
      + + Task + + @for (td of unit.taskDefinitions; track td.id) { + {{ td.abbreviation }} - {{ td.name }} + } + + + @if (selectedTaskDefinition) { + + } +
      } - - - @if (selectedTaskDefinition) { - - } -
      - } +
      + + } -
      -
      - @if (project && project?.student) { - -
      -
      - {{ project?.student?.firstName }} {{ project?.student?.lastName }} - @if (project?.student.studentId) { - ({{ project?.student.studentId }}) - } -
      -
      - Target Grade: {{ getTargetTradeString($safeNavigationMigration(project?.targetGrade)) }} -
      + +
      +
      +
      +

      + Point the camera at the student's QR code +

      +

      + It opens their tasks as soon as it reads the code. +

      - } @else { -
      -
      Click the QR code to open the scanner..
      - } - -
      - @if (project && !filteredTasks.length) { -
      No tasks to discuss.
      - } - - @for (task of filteredTasks; track task) { - - @if (task) { -
      -
      + @if (cameraStarting || loadingStudentData) { +
      + - - - -
      -

      {{ task.definition.name }}

      - - {{ task.definition.abbreviation }} - - {{ getTargetTradeString(task.definition.targetGrade) }} Task - -
      - @if (task.hasGrade()) { -
      - {{ task.gradeDesc() }} - -
      - } - - - -
      - + + {{ + loadingStudentData ? "Opening the student's tasks..." : 'Starting the camera...' + }} +
      } -
      - } +
      - - - @if (project) { -
      - @if (this.allTasks?.length > 0) { - @if (this.filteredTasks?.length < this.allTasks?.length) { - - } @else { - + +
      + @if (scanHint) { +

      + + {{ scanHint }} +

      } - } +
      + +
      + @if (cameras.length > 1) { + + Camera + + @for (camera of cameras; track camera.id) { + {{ camera.label }} + } + + + } + +
      - } + - -
      + @if (!scanningQr && !project && (hasUnit || !attendance)) { +
      +
      +
      + +
      +

      + Scan a student's QR code +

      +

      + Students show their code from the QR button at the top of + {{ externalName | async }}. Your browser asks to use the camera when you start + scanning, and the camera turns off once the student's tasks open. +

      +
      +
      - @if (project && filteredTasks.length) { -
      -
      - @if (attendance) { + @if (loadingStudentData) { +

      + + Opening the student's tasks... +

      + } + + + + @if (attendance && hasUnit && !selectedTaskDefinition) { +

      Choose the task to check in before you scan.

      + } +
      + +
      +
      +
      + + @if (hasUnit) { +
      +
      +
      +

      + No camera? Find the student instead +

      +

      + Type their username or student ID, as it appears in the student list. +

      +
      + +
      +
      + } + } + + @if (!hasUnit && (attendance || (!scanningQr && !project))) { +
      +
      +

      + Choose a unit +

      +

      + @if (attendance) { + Check-in works one unit at a time. + } @else { + Open a unit to find a student without the camera. + } +

      +
      + @if (teachingUnits.length > 0) { +
      } @else { - - + + } +
      + } + @if (project && !scanningQr) { + +
      +

      Next student

      +
      - @if (selectedTasksIncludeDiscuss) { - + @if (hasUnit) { + or find them + } +
      + @if (loadingStudentData) { +

      + + Opening the student's tasks... +

      } -
      -
      - } + + - @if (selectedTask && project) { - - +
      +
      +
      + +
      +

      + {{ project.student?.displayName ?? project.student?.name }} +

      +

      + @if (project.student?.studentId) { + {{ project.student.studentId }} + + } + Target grade: {{ getTargetTradeString(project.targetGrade) }} +

      +
      + +
      - - - +

      + @if (attendance) { + Check in for this task + } @else if (showingAllSubmitted) { + All submitted tasks + } @else { + Tasks to discuss + } +

      - @if (footerTabView === TutorDiscussionTabView.SHOW_COMMENTS) { -
      - + @if (filteredTasks.length === 0) { + @if (attendance) { + + } @else { + + } + } + + + @for (task of filteredTasks; track task) { + +
      + +
      +

      + {{ task.definition?.name }} +

      +

      + {{ task.definition?.abbreviation }} · + {{ getTargetTradeString(task.definition?.targetGrade) }} task + @if (task.hasGrade()) { + · Grade {{ task.gradeDesc() }} + } +

      +
      + @if (task.similaritiesDetected) { + + Similarities found. + } @else if (task.numNewComments > 0) { + + New comments. + } + +
      +
      + } +
      - + @if (showingAllSubmitted) { + + } @else { + + } +
      + } + + @if (filteredTasks.length > 0) { +
      + + + {{ selectedCount(tasks) }} of {{ filteredTasks.length }} selected + + @if (attendance) { + + } @else { + + + + @if (selectionIncludesDiscuss(tasks)) { + + } + } +
      + } +
      + +
      - + + + + + + +
      + @switch (footerTabView) { + @case (TutorDiscussionTabView.SHOW_COMMENTS) { + @if (selectedTask) { +

      + Comments on + {{ + selectedTask.definition?.name + }} +

      + + } @else { + + } + } + @case (TutorDiscussionTabView.SHOW_STAFF_NOTES) { + + } + @case (TutorDiscussionTabView.SHOW_DISCUSSION_PROMPTS) { + + } + } +
      +
      - } @else if (footerTabView === TutorDiscussionTabView.SHOW_STAFF_NOTES) { - - } @else if (footerTabView === TutorDiscussionTabView.SHOW_DISCUSSION_PROMPTS) { - } +
      + + + + @if (loadError) { + } -
      + @if (cameraProblem; as problem) { + + } + + + + +
      + + Username or student ID + + + +
      +
      diff --git a/src/app/projects/states/tutorials/tutorials.component.html b/src/app/projects/states/tutorials/tutorials.component.html index b953b21592..99985f2c54 100644 --- a/src/app/projects/states/tutorials/tutorials.component.html +++ b/src/app/projects/states/tutorials/tutorials.component.html @@ -1,108 +1,207 @@ @if (project && unit) { -
      -
      -

      Tutorials

      -

      - View available tutorials and manage your enrolment. Note that availability is subject to - capacity. If you are unable to enrol in a tutorial, please contact your unit coordinator. -

      -
      - - - - - + +
      +
      +

      Tutorials

      +

      + View available tutorials and manage your enrolment. Note that availability is subject to + capacity. If you are unable to enrol in a tutorial, please contact your unit coordinator. +

      +
      + + @if (filteredTutorials.length === 0) { +
      + +

      No tutorials available

      +

      No tutorials are available for your campus or current enrolment.

      +
      + } + +
      + @for (tutorial of filteredTutorials; track tutorial.id) { + + + {{ tutorialValue(tutorial.abbreviation) }} + {{ tutorialStreamLabel(tutorial) }} + + + +
      +
      +
      Campus
      +
      {{ tutorialCampusLabel(tutorial) }}
      +
      +
      +
      Day
      +
      {{ tutorialValue(tutorial.meetingDay) }}
      +
      +
      +
      Time
      +
      {{ shortTime(tutorial.meetingTime) }}
      +
      +
      +
      Room
      +
      {{ tutorialValue(tutorial.meetingLocation) }}
      +
      +
      +
      Tutor
      +
      {{ tutorialValue(tutorial.tutorName) }}
      +
      +
      +
      - -
      - - + + @if (project.isEnrolledIn(tutorial)) { + @if (unit.allowStudentChangeTutorial) { + + } @else { + Enrolled + } + } @else if (unit.allowStudentChangeTutorial) { + + } @else { + Enrolment is managed by the unit team + } + + + } + - - - - +
      +
      Stream - @if (unit.tutorialStreamsCache.size > 0) { -
      {{ tutorial.tutorialStream?.name || 'All' }}
      - } @else { -
      N/A
      - } -
      Campus - {{ tutorial.campus?.name || 'All' }} - Code - {{ tutorial.abbreviation }} -
      + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - - - - - - -
      Stream + @if (unit.tutorialStreamsCache.size > 0) { +
      {{ tutorial.tutorialStream?.name || 'All' }}
      + } @else { +
      N/A
      + } +
      Day - {{ tutorial.meetingDay }} - Campus + {{ tutorial.campus?.name || 'All' }} + Time - {{ shortTime(tutorial.meetingTime) }} - Code + {{ tutorial.abbreviation }} + Room - {{ tutorial.meetingLocation }} - Day + {{ tutorial.meetingDay }} + Tutor - {{ tutorial.tutorName }} - Actions - @if (project.isEnrolledIn(tutorial)) { - @if (unit.allowStudentChangeTutorial) { - - } @else { -
      - Enrolled -
      - } - } @else if (unit.allowStudentChangeTutorial) { - - } @else { -
      - - } -
      -
      + + Time + + {{ shortTime(tutorial.meetingTime) }} + + + + + Room + + {{ tutorial.meetingLocation }} + + + + + Tutor + + {{ tutorial.tutorName }} + + + + Actions + + @if (project.isEnrolledIn(tutorial)) { + @if (unit.allowStudentChangeTutorial) { + + } @else { +
      + Enrolled +
      + } + } @else if (unit.allowStudentChangeTutorial) { + + } @else { +
      + + } + +
      + + + + + + + + +
      +
      + } diff --git a/src/app/projects/states/tutorials/tutorials.component.spec.ts b/src/app/projects/states/tutorials/tutorials.component.spec.ts new file mode 100644 index 0000000000..fdc7ba69ee --- /dev/null +++ b/src/app/projects/states/tutorials/tutorials.component.spec.ts @@ -0,0 +1,126 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {CommonModule} from '@angular/common'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +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 {MatSortModule} from '@angular/material/sort'; +import {MatTableModule} from '@angular/material/table'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {ActivatedRoute} from '@angular/router'; +import {of} from 'rxjs'; +import {Project, Tutorial, Unit} from 'src/app/api/models/doubtfire-model'; +import {TutorialsComponent} from './tutorials.component'; + +describe('TutorialsComponent phone cards', () => { + let fixture: ComponentFixture; + let component: TutorialsComponent; + let project: Project; + let firstTutorial: Tutorial; + let secondTutorial: Tutorial; + + beforeEach(async () => { + firstTutorial = { + id: 1, + abbreviation: 'LA1-01', + tutorialStream: {name: 'Practical 1'}, + campus: {id: 4, name: 'Burwood'}, + meetingDay: 'Tuesday', + meetingTime: '13:30:00', + meetingLocation: 'EN107', + tutorName: 'Demo Tutor', + hasCapacity: () => true, + } as unknown as Tutorial; + secondTutorial = { + id: 2, + abbreviation: 'LA1-02', + tutorialStream: {name: 'Practical 1'}, + campus: {id: 4, name: 'Burwood'}, + meetingDay: 'Wednesday', + meetingTime: '09:00:00', + meetingLocation: 'Online', + tutorName: 'Second Tutor', + hasCapacity: () => false, + } as unknown as Tutorial; + + const unit = { + tutorials: [firstTutorial, secondTutorial], + tutorialStreamsCache: {size: 1}, + allowStudentChangeTutorial: true, + } as unknown as Unit; + project = { + unit, + campus: {id: 4, name: 'Burwood'}, + isEnrolledIn: vi.fn((tutorial: Tutorial) => tutorial.id === firstTutorial.id), + switchToTutorial: vi.fn(), + } as unknown as Project; + + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NoopAnimationsModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatSortModule, + MatTableModule, + MatTooltipModule, + ], + declarations: [TutorialsComponent], + providers: [{provide: ActivatedRoute, useValue: {parent: {snapshot: {data: {project}}}}}], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TutorialsComponent); + component = fixture.componentInstance; + component.project$ = of(project); + fixture.detectChanges(); + }); + + it('keeps the desktop table and exposes every desktop value in each phone card', () => { + const cards = Array.from( + fixture.nativeElement.querySelectorAll('.tutorial-card'), + ) as HTMLElement[]; + + expect(fixture.nativeElement.querySelector('table.tutorial-table')).not.toBeNull(); + expect(cards).toHaveLength(2); + expect(cards[0].textContent).toContain('LA1-01'); + expect(cards[0].textContent).toContain('Practical 1'); + expect(cards[0].textContent).toContain('Burwood'); + expect(cards[0].textContent).toContain('Tuesday'); + expect(cards[0].textContent).toContain('13:30'); + expect(cards[0].textContent).toContain('EN107'); + expect(cards[0].textContent).toContain('Demo Tutor'); + expect(cards[0].textContent).toContain('Withdraw'); + expect(cards[1].textContent).toContain('Full'); + }); + + it('uses the same enrolment action for phone cards and the desktop table', () => { + const withdraw = fixture.nativeElement.querySelector( + '.tutorial-card button[aria-label="Withdraw from LA1-01"]', + ) as HTMLButtonElement; + withdraw.click(); + + expect(project.switchToTutorial).toHaveBeenCalledOnce(); + expect(project.switchToTutorial).toHaveBeenCalledWith(firstTutorial); + }); + + it('handles missing times and fields without throwing or rendering blanks', () => { + expect(component.shortTime('')).toBe('Not set'); + expect(component.shortTime('9')).toBe('9'); + expect(component.tutorialValue(undefined)).toBe('Not set'); + }); + + it('renders an exact empty state when the authorised unit payload has no tutorials', () => { + (project.unit as unknown as {tutorials: Tutorial[]}).tutorials = []; + component.project$ = of(project); + component.ngOnDestroy(); + component.ngOnInit(); + fixture.detectChanges(); + + const empty = fixture.nativeElement.querySelector('.tutorial-empty[role="status"]'); + expect(empty.textContent).toContain('No tutorials available'); + }); +}); diff --git a/src/app/projects/states/tutorials/tutorials.component.ts b/src/app/projects/states/tutorials/tutorials.component.ts index 07f4879f06..d22c92e33c 100644 --- a/src/app/projects/states/tutorials/tutorials.component.ts +++ b/src/app/projects/states/tutorials/tutorials.component.ts @@ -91,19 +91,42 @@ export class TutorialsComponent implements OnInit, OnDestroy { /** * Formats the passed-in time string to the format of: HH:mm - * Todo: Add date validation + * Falls back to the raw value (or a missing-time label) when it is not a + * "hours:minutes" string, instead of throwing. + * * @param meetingTime * * @returns string */ - shortTime(meetingTime: string): string { + shortTime(meetingTime?: string): string { + if (!meetingTime) { + return 'Not set'; + } + const [hours, minutes] = meetingTime.split(':'); + if (minutes === undefined) { + return meetingTime; + } const formattedHours = hours.padStart(2, '0'); const formattedMinutes = minutes.padStart(2, '0'); return `${formattedHours}:${formattedMinutes}`; } + tutorialStreamLabel(tutorial: Tutorial): string { + return this.unit?.tutorialStreamsCache?.size > 0 + ? tutorial.tutorialStream?.name || 'All streams' + : 'Not applicable'; + } + + tutorialCampusLabel(tutorial: Tutorial): string { + return tutorial.campus?.name || 'All campuses'; + } + + tutorialValue(value: string | null | undefined): string { + return value?.trim() || 'Not set'; + } + private sortCompare( aValue: number | string | undefined, bValue: number | string | undefined, diff --git a/src/app/student-onboarding/student-onboarding-target.ts b/src/app/student-onboarding/student-onboarding-target.ts new file mode 100644 index 0000000000..4f931c6a50 --- /dev/null +++ b/src/app/student-onboarding/student-onboarding-target.ts @@ -0,0 +1,23 @@ +/** Return only a visible, explicitly registered target on the current page. */ +export function resolveOnboardingTarget(document: Document, name: string): HTMLElement | null { + // Targets are compile-time ids, never CSS or HTML supplied by a user/storage. + if (!/^[a-z-]+$/.test(name)) { + return null; + } + for (const target of document.querySelectorAll( + `[data-onboarding-target="${name}"]`, + )) { + const style = document.defaultView.getComputedStyle(target); + const rect = target.getBoundingClientRect(); + if ( + !target.closest('[hidden], [aria-hidden="true"], [inert]') && + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0 + ) { + return target; + } + } + return null; +} diff --git a/src/app/student-onboarding/student-onboarding.component.html b/src/app/student-onboarding/student-onboarding.component.html new file mode 100644 index 0000000000..4571e88305 --- /dev/null +++ b/src/app/student-onboarding/student-onboarding.component.html @@ -0,0 +1,127 @@ +@if (view) { + @if (view.panel !== 'step') { + + } + @if (rect && view.panel === 'step') { + + } + +} diff --git a/src/app/student-onboarding/student-onboarding.component.scss b/src/app/student-onboarding/student-onboarding.component.scss new file mode 100644 index 0000000000..be25987ccb --- /dev/null +++ b/src/app/student-onboarding/student-onboarding.component.scss @@ -0,0 +1,86 @@ +:host { + // The panel floats over the page, so it takes the raised surface. + --tutorial-surface: var(--ot-color-surface-raised); + --tutorial-text: var(--ot-color-text); +} +.onboarding-backdrop { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 20%); + z-index: 1099; +} +.onboarding-panel { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 1101; + width: min(28rem, calc(100vw - 2rem)); + box-sizing: border-box; + max-height: calc(100dvh - 2rem); + overflow: auto; + border: 2px solid var(--tutorial-text); + border-radius: 0.75rem; + padding: 1rem; + color: var(--tutorial-text); + background: var(--tutorial-surface); + box-shadow: 0 4px 20px var(--ot-scrim); + font-size: 1rem; + line-height: 1.5; + overflow-wrap: anywhere; + h2 { + margin: 0; + font-size: 1.25rem; + } + p { + margin-block: 0.75rem; + } + a { + color: inherit; + text-decoration: underline; + } + :focus-visible { + outline: 3px solid currentcolor; + outline-offset: 3px; + } +} +.onboarding-panel--top { + top: 1rem; + bottom: auto; +} +.onboarding-controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.onboarding-position { + font-weight: 600; +} +.onboarding-fallback { + border-inline-start: 3px solid currentcolor; + padding-inline-start: 0.75rem; +} +.onboarding-highlight { + position: fixed; + pointer-events: none; + z-index: 1098; + border: 3px dashed var(--tutorial-text); + border-radius: 4px; + box-sizing: border-box; + box-shadow: 0 0 0 3px var(--tutorial-surface); +} +@media (max-width: 600px) { + .onboarding-panel { + right: 0.5rem; + bottom: 0.5rem; + width: calc(100vw - 1rem); + max-height: 55dvh; + } +} +@media (prefers-reduced-motion: reduce) { + :host, + :host * { + scroll-behavior: auto; + animation: none; + transition: none; + } +} diff --git a/src/app/student-onboarding/student-onboarding.component.spec.ts b/src/app/student-onboarding/student-onboarding.component.spec.ts new file mode 100644 index 0000000000..4c34526857 --- /dev/null +++ b/src/app/student-onboarding/student-onboarding.component.spec.ts @@ -0,0 +1,219 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BehaviorSubject} from 'rxjs'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {resolveOnboardingTarget} from './student-onboarding-target'; +import {StudentOnboardingComponent} from './student-onboarding.component'; +import {OnboardingView, StudentOnboardingService} from './student-onboarding.service'; +import {ONBOARDING_STEPS} from './student-onboarding.steps'; + +function visibleTarget(name: string): HTMLElement { + const target = document.createElement('button'); + target.dataset.onboardingTarget = name; + target.textContent = 'Synthetic control'; + target.getBoundingClientRect = () => ({ + x: 20, + y: 20, + left: 20, + top: 20, + right: 100, + bottom: 60, + width: 80, + height: 40, + toJSON: () => ({}), + }); + document.body.appendChild(target); + return target; +} + +describe('StudentOnboardingComponent', () => { + let fixture: ComponentFixture; + let view: BehaviorSubject; + let onboarding: { + view$: BehaviorSubject; + storageUnavailable: boolean; + start: ReturnType; + begin: ReturnType; + next: ReturnType; + back: ReturnType; + requestSkip: ReturnType; + cancelSkip: ReturnType; + skip: ReturnType; + dismiss: ReturnType; + finish: ReturnType; + close: ReturnType; + escape: ReturnType; + }; + let targets: HTMLElement[]; + const buttons = () => + [...fixture.nativeElement.querySelectorAll('button')] as HTMLButtonElement[]; + const click = (label: string) => + buttons() + .find((button) => button.textContent.trim() === label) + ?.click(); + const show = (panel: OnboardingView['panel'], index = 0, replay = false) => { + view.next({panel, index, replay}); + fixture.detectChanges(); + }; + beforeEach(async () => { + targets = []; + view = new BehaviorSubject(null); + onboarding = { + view$: view, + storageUnavailable: false, + start: vi.fn(), + begin: vi.fn(), + next: vi.fn(), + back: vi.fn(), + requestSkip: vi.fn(), + cancelSkip: vi.fn(), + skip: vi.fn(), + dismiss: vi.fn(), + finish: vi.fn(), + close: vi.fn(), + escape: vi.fn(), + }; + await TestBed.configureTestingModule({ + imports: [StudentOnboardingComponent], + providers: [ + {provide: StudentOnboardingService, useValue: onboarding}, + {provide: GlobalStateService, useValue: {currentUserProjects: {currentValues: []}}}, + ], + }).compileComponents(); + fixture = TestBed.createComponent(StudentOnboardingComponent); + fixture.detectChanges(); + }); + afterEach(() => { + fixture.destroy(); + targets.forEach((target) => target.remove()); + vi.restoreAllMocks(); + }); + + it('starts integration once and names the welcome dialog using the approved copy', async () => { + expect(onboarding.start).toHaveBeenCalledOnce(); + show('welcome'); + await new Promise((resolve) => setTimeout(resolve, 10)); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('[role="dialog"]').getAttribute('aria-labelledby'), + ).toBe('onboarding-title'); + expect(fixture.nativeElement.textContent).toContain('Take a quick tour of the main areas'); + expect(document.activeElement).toBe(fixture.nativeElement.querySelector('h2')); + click('Start tutorial'); + expect(onboarding.begin).toHaveBeenCalledOnce(); + }); + it('offers Back, Next, Skip and Close in keyboard reading order without a modal step trap', () => { + show('step'); + expect( + buttons() + .slice(0, 4) + .map((button) => button.textContent.trim()), + ).toEqual(['Back', 'Next', 'Skip for now', 'Close']); + expect(buttons()[0].disabled).toBe(true); + expect( + fixture.nativeElement.querySelector('[role="dialog"]').getAttribute('aria-modal'), + ).toBeNull(); + click('Next'); + click('Skip for now'); + click('Close'); + expect(onboarding.next).toHaveBeenCalledOnce(); + expect(onboarding.requestSkip).toHaveBeenCalledOnce(); + expect(onboarding.close).toHaveBeenCalledOnce(); + show('step', 1); + click('Back'); + expect(onboarding.back).toHaveBeenCalledOnce(); + }); + it('announces each exact step title and count, including missing-data fallbacks', () => { + ONBOARDING_STEPS.forEach((step, index) => { + show('step', index); + expect(fixture.nativeElement.querySelector('[aria-live="polite"]').textContent).toContain( + `Step ${index + 1} of 4: ${step.title}`, + ); + expect(fixture.nativeElement.textContent).toContain(step.body); + expect(fixture.nativeElement.textContent).toContain(step.fallback); + expect(fixture.nativeElement.textContent).toContain('No current unit is available'); + }); + }); + it('exposes named confirmation, dismissal, completion and Escape controls', () => { + show('skip'); + click('Go back'); + click('Skip tutorial'); + click('Do not show automatically again'); + expect(onboarding.cancelSkip).toHaveBeenCalledOnce(); + expect(onboarding.skip).toHaveBeenCalledOnce(); + expect(onboarding.dismiss).toHaveBeenCalledOnce(); + fixture.nativeElement + .querySelector('section') + .dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); + expect(onboarding.escape).toHaveBeenCalledOnce(); + show('complete'); + click('Finish'); + expect(onboarding.finish).toHaveBeenCalledOnce(); + }); + it('hides dismissal during replay and explains a save failure', () => { + onboarding.storageUnavailable = true; + show('step', 0, true); + expect(fixture.nativeElement.textContent).not.toContain('Do not show automatically again'); + expect(fixture.nativeElement.textContent).toContain('Progress could not be saved'); + }); + it('resolves all four stable targets and locating one never clicks it', () => { + ONBOARDING_STEPS.forEach((step, index) => { + const target = visibleTarget(step.target); + targets.push(target); + const clickSpy = vi.fn(); + target.addEventListener('click', clickSpy); + target.scrollIntoView = vi.fn(); + show('step', index); + fixture.componentInstance.refreshTarget(); + fixture.detectChanges(); + expect(fixture.componentInstance.target).toBe(target); + expect(fixture.nativeElement.querySelector('.onboarding-highlight')).not.toBeNull(); + click(step.action); + expect(target.scrollIntoView).toHaveBeenCalledOnce(); + expect(clickSpy).not.toHaveBeenCalled(); + }); + }); + it('recovers when a delayed target appears and removes the highlight when it disappears', () => { + show('step', 2); + fixture.componentInstance.refreshTarget(); + expect(fixture.componentInstance.target).toBeNull(); + const target = visibleTarget('target-grade'); + targets.push(target); + fixture.componentInstance.refreshTarget(); + expect(fixture.componentInstance.target).toBe(target); + target.remove(); + fixture.componentInstance.refreshTarget(); + expect(fixture.componentInstance.rect).toBeNull(); + }); +}); + +describe('stable onboarding target visibility', () => { + let targets: HTMLElement[]; + beforeEach(() => { + targets = []; + }); + afterEach(() => targets.forEach((target) => target.remove())); + it('rejects selectors and ignores hidden targets, choosing the visible menu entry', () => { + const hidden = visibleTarget('calendar'); + targets.push(hidden); + hidden.hidden = true; + const visible = visibleTarget('calendar'); + targets.push(visible); + expect(resolveOnboardingTarget(document, 'calendar')).toBe(visible); + expect(resolveOnboardingTarget(document, 'calendar"] button')).toBeNull(); + visible.style.visibility = 'hidden'; + expect(resolveOnboardingTarget(document, 'calendar')).toBeNull(); + }); + it('ignores inert or aria-hidden ancestors and zero-size controls', () => { + const target = visibleTarget('unit-selector'); + targets.push(target); + target.setAttribute('aria-hidden', 'true'); + expect(resolveOnboardingTarget(document, 'unit-selector')).toBeNull(); + target.removeAttribute('aria-hidden'); + target.setAttribute('inert', ''); + expect(resolveOnboardingTarget(document, 'unit-selector')).toBeNull(); + target.removeAttribute('inert'); + target.getBoundingClientRect = () => new DOMRect(); + expect(resolveOnboardingTarget(document, 'unit-selector')).toBeNull(); + }); +}); diff --git a/src/app/student-onboarding/student-onboarding.component.ts b/src/app/student-onboarding/student-onboarding.component.ts new file mode 100644 index 0000000000..57dbc768cb --- /dev/null +++ b/src/app/student-onboarding/student-onboarding.component.ts @@ -0,0 +1,146 @@ +import {A11yModule} from '@angular/cdk/a11y'; +import {DOCUMENT} from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + HostListener, + Inject, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {Subscription} from 'rxjs'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {resolveOnboardingTarget} from './student-onboarding-target'; +import {OnboardingView, StudentOnboardingService} from './student-onboarding.service'; +import {ONBOARDING_STEPS} from './student-onboarding.steps'; + +@Component({ + selector: 'f-student-onboarding', + imports: [A11yModule, MatButtonModule], + templateUrl: './student-onboarding.component.html', + styleUrls: ['./student-onboarding.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, +}) +export class StudentOnboardingComponent implements OnInit, OnDestroy { + @ViewChild('heading') heading?: ElementRef; + readonly steps = ONBOARDING_STEPS; + readonly guideUrl = + 'https://github.com/ontrack-features-t2-2026/github-guide/blob/main/docs/student-onboarding/student-guide.md'; + view: OnboardingView | null = null; + target: HTMLElement | null = null; + rect: DOMRect | null = null; + private subscription?: Subscription; + private observer?: MutationObserver; + private focusTimer?: ReturnType; + private readonly onScroll = () => this.scheduleTargetRefresh(); + private targetTimer?: ReturnType; + + constructor( + readonly onboarding: StudentOnboardingService, + private globals: GlobalStateService, + private changeDetector: ChangeDetectorRef, + @Inject(DOCUMENT) private document: Document, + ) {} + + get noUnits(): boolean { + return this.globals.currentUserProjects.currentValues.length === 0; + } + get panelAtTop(): boolean { + return !!this.rect && this.rect.top > this.document.defaultView.innerHeight / 2; + } + get step() { + return this.steps[this.view?.index ?? 0]; + } + get title(): string { + switch (this.view?.panel) { + case 'welcome': + return 'Welcome to OnTrack'; + case 'skip': + return 'Skip the tutorial?'; + case 'complete': + return 'Tutorial complete'; + default: + return this.step.title; + } + } + + ngOnInit(): void { + this.subscription = this.onboarding.view$.subscribe((view) => { + this.view = view; + this.target = null; + this.rect = null; + clearTimeout(this.focusTimer); + clearTimeout(this.targetTimer); + this.targetTimer = undefined; + if (view) { + this.focusTimer = setTimeout(() => { + this.heading?.nativeElement.focus(); + this.refreshTarget(); + }); + } + this.changeDetector.markForCheck(); + }); + this.observer = new MutationObserver(() => this.scheduleTargetRefresh()); + this.observer.observe(this.document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['hidden', 'aria-hidden', 'class', 'style'], + }); + this.document.addEventListener('scroll', this.onScroll, true); + this.onboarding.start(); + } + + @HostListener('window:resize') + scheduleTargetRefresh(): void { + if (this.targetTimer || this.view?.panel !== 'step') { + return; + } + this.targetTimer = setTimeout(() => { + this.targetTimer = undefined; + this.refreshTarget(); + }, 50); + } + + refreshTarget(): void { + if (this.view?.panel !== 'step') { + return; + } + this.target = resolveOnboardingTarget(this.document, this.step.target); + const rect = this.target?.getBoundingClientRect(); + // No detached/offscreen highlight. The written fallback always remains usable. + this.rect = + rect && + rect.bottom > 0 && + rect.top < this.document.defaultView.innerHeight && + rect.right > 0 && + rect.left < this.document.defaultView.innerWidth + ? rect + : null; + this.changeDetector.markForCheck(); + } + + findTarget(): void { + this.refreshTarget(); + // Explicitly locating the control never activates it or changes a route/setting. + this.target?.scrollIntoView({block: 'center', behavior: 'instant'}); + this.refreshTarget(); + } + + onEscape(event: Event): void { + event.stopPropagation(); + this.onboarding.escape(); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.observer?.disconnect(); + this.document.removeEventListener('scroll', this.onScroll, true); + clearTimeout(this.focusTimer); + clearTimeout(this.targetTimer); + } +} diff --git a/src/app/student-onboarding/student-onboarding.service.spec.ts b/src/app/student-onboarding/student-onboarding.service.spec.ts new file mode 100644 index 0000000000..0f2f20127c --- /dev/null +++ b/src/app/student-onboarding/student-onboarding.service.spec.ts @@ -0,0 +1,441 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {HttpClient} from '@angular/common/http'; +import {NavigationEnd, Router} from '@angular/router'; +import {BehaviorSubject, Subject, of, throwError} from 'rxjs'; +import {AuthenticationService} from 'src/app/api/services/authentication.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {StudentOnboardingService, parseOnboardingProgress} from './student-onboarding.service'; + +const key = 'ontrack:student-onboarding:1'; +const record = (state: string, step = 'unit', version = 1) => + JSON.stringify({version, state, step}); + +describe('StudentOnboardingService', () => { + let storage: Storage; + let storageDescriptor: PropertyDescriptor | undefined; + let service: StudentOnboardingService; + let users: {currentUser: {id: number; role: string; hasRunFirstTimeSetup: boolean}}; + let auth: {isAuthenticated: ReturnType}; + let http: {get: ReturnType}; + let globals: {isLoadingSubject: BehaviorSubject}; + let settings: {IsTutorialEnabled: BehaviorSubject}; + let router: {url: string; events: Subject; navigate: ReturnType}; + const route = (url = '/home') => { + router.url = url; + router.events.next(new NavigationEnd(1, url, url)); + }; + const finishProfile = () => { + users.currentUser.hasRunFirstTimeSetup = true; + globals.isLoadingSubject.next(false); + route(); + }; + beforeEach(() => { + const values: Map = new Map(); + storage = { + get length() { + return values.size; + }, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, + }; + storageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage'); + Object.defineProperty(window, 'localStorage', {value: storage, configurable: true}); + users = {currentUser: {id: 1, role: 'Student', hasRunFirstTimeSetup: false}}; + auth = {isAuthenticated: vi.fn(() => true)}; + http = {get: vi.fn(() => of({hasProjects: false}))}; + globals = {isLoadingSubject: new BehaviorSubject(true)}; + settings = {IsTutorialEnabled: new BehaviorSubject(true)}; + router = {url: '/welcome', events: new Subject(), navigate: vi.fn()}; + service = new StudentOnboardingService( + users as unknown as UserService, + auth as unknown as AuthenticationService, + globals as unknown as GlobalStateService, + settings as unknown as DoubtfireConstants, + router as unknown as Router, + http as unknown as HttpClient, + document, + ); + }); + afterEach(() => { + service.ngOnDestroy(); + vi.restoreAllMocks(); + if (storageDescriptor) { + Object.defineProperty(window, 'localStorage', storageDescriptor); + } else { + delete window['localStorage']; + } + }); + + it('requests only the authenticated current-user history boolean without owner parameters', () => { + service.start(); + expect(http.get).toHaveBeenCalledOnce(); + expect(http.get).toHaveBeenCalledWith(expect.stringMatching(/\/projects\/history$/)); + }); + it.each([ + {history: {hasProjects: true}}, + {history: {}}, + {history: []}, + {history: null}, + {history: {hasProjects: 0}}, + {history: {hasProjects: 'false'}}, + {history: {hasProjects: false, userId: 1}}, + {history: {projects: []}}, + ])('keeps existing, malformed or unknown history replay-only: $history', ({history}) => { + http.get.mockReturnValue(of(history)); + service.start(); + finishProfile(); + expect(service.view$.value).toBeNull(); + expect(storage.getItem(key)).toBeNull(); + service.replay(); + expect(service.view$.value?.replay).toBe(true); + }); + it('does not replace invalid or future stored progress with a new automatic offer', () => { + storage.setItem(key, record('completed', 'unit', 999)); + service.start(); + finishProfile(); + expect(http.get).not.toHaveBeenCalled(); + expect(service.view$.value).toBeNull(); + expect(storage.getItem(key)).toBe(record('completed', 'unit', 999)); + }); + it('fails open without retries when the history request fails', () => { + http.get.mockReturnValue(throwError(() => new Error('network'))); + service.start(); + finishProfile(); + route(); + expect(service.view$.value).toBeNull(); + expect(http.get).toHaveBeenCalledOnce(); + }); + it('discards a delayed eligibility result after disable or account replacement', () => { + const response: Subject = new Subject(); + http.get.mockReturnValue(response); + service.start(); + settings.IsTutorialEnabled.next(false); + users.currentUser = {id: 2, role: 'Student', hasRunFirstTimeSetup: true}; + settings.IsTutorialEnabled.next(true); + response.next({hasProjects: false}); + finishProfile(); + expect(service.view$.value).toBeNull(); + expect(storage.length).toBe(0); + }); + it('does not persist eligibility when the pending account becomes staff', () => { + const response: Subject = new Subject(); + http.get.mockReturnValue(response); + service.start(); + users.currentUser.role = 'Tutor'; + response.next({hasProjects: false}); + expect(storage.length).toBe(0); + expect(service.view$.value).toBeNull(); + }); + it('times out history checks without retrying or interrupting normal use', () => { + vi.useFakeTimers(); + try { + http.get.mockReturnValue(new Subject()); + service.start(); + finishProfile(); + vi.advanceTimersByTime(10000); + route(); + expect(service.view$.value).toBeNull(); + expect(http.get).toHaveBeenCalledOnce(); + service.replay(); + expect(service.view$.value?.replay).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('returns automatic close to the last target and replay close to its visible launch control', () => { + const target = document.createElement('button'); + target.dataset.onboardingTarget = 'unit-selector'; + const launch = document.createElement('button'); + launch.dataset.onboardingTarget = 'account-menu'; + for (const element of [target, launch]) { + element.getBoundingClientRect = () => new DOMRect(10, 10, 80, 40); + document.body.appendChild(element); + } + try { + service.start(); + finishProfile(); + service.begin(); + service.close(); + expect(document.activeElement).toBe(target); + launch.focus(); + service.replay(); + service.begin(); + service.next(); + service.close(); + expect(document.activeElement).toBe(launch); + } finally { + target.remove(); + launch.remove(); + } + }); + it('can complete profile setup before the successful eligibility response without a race', () => { + const response: Subject = new Subject(); + http.get.mockReturnValue(response); + service.start(); + finishProfile(); + expect(service.view$.value).toBeNull(); + response.next({hasProjects: false}); + expect(service.view$.value?.panel).toBe('welcome'); + }); + it('waits for profile, enrolment loading and a safe route before offering a new student the tutorial', () => { + service.start(); + expect(service.view$.value).toBeNull(); + globals.isLoadingSubject.next(false); + expect(service.view$.value).toBeNull(); + users.currentUser.hasRunFirstTimeSetup = true; + globals.isLoadingSubject.next(false); + expect(service.view$.value).toBeNull(); + route(); + expect(service.view$.value?.panel).toBe('welcome'); + expect(users.currentUser.hasRunFirstTimeSetup).toBe(true); + }); + it('keeps returning students with absent progress replay-only', () => { + users.currentUser.hasRunFirstTimeSetup = true; + service.start(); + finishProfile(); + expect(service.view$.value).toBeNull(); + service.replay(); + expect(service.view$.value?.replay).toBe(true); + }); + it.each(['Admin', 'Tutor', 'Convenor', 'Auditor'])( + 'excludes the %s role from automatic and manual starts', + (role) => { + users.currentUser.role = role; + service.start(); + finishProfile(); + service.replay(); + expect(service.available).toBe(false); + expect(service.view$.value).toBeNull(); + expect(storage.length).toBe(0); + }, + ); + it('does not start or write progress while unauthenticated or disabled', () => { + settings.IsTutorialEnabled.next(false); + service.start(); + finishProfile(); + service.replay(); + expect(service.view$.value).toBeNull(); + auth.isAuthenticated.mockReturnValue(false); + settings.IsTutorialEnabled.next(true); + service.replay(); + expect(service.view$.value).toBeNull(); + expect(storage.length).toBe(0); + }); + it('persists progress, supports Back and resumes the exact stable step after a reload', () => { + service.start(); + finishProfile(); + service.begin(); + service.next(); + service.next(); + service.back(); + expect(JSON.parse(storage.getItem(key))).toEqual({ + version: 1, + state: 'in-progress', + step: 'tasks', + }); + // Reconstruct the service as a fresh browser page; only persisted state crosses the boundary. + service.ngOnDestroy(); + service = new StudentOnboardingService( + users as unknown as UserService, + auth as unknown as AuthenticationService, + globals as unknown as GlobalStateService, + settings as unknown as DoubtfireConstants, + router as unknown as Router, + http as unknown as HttpClient, + document, + ); + service.start(); + expect(service.view$.value).toMatchObject({panel: 'step', index: 1}); + }); + it('skips for this session without re-opening on route or loading events', () => { + service.start(); + finishProfile(); + service.begin(); + service.skip(); + route('/projects/1/dashboard'); + globals.isLoadingSubject.next(false); + expect(service.view$.value).toBeNull(); + expect(JSON.parse(storage.getItem(key)).state).toBe('skipped'); + }); + it('offers saved skipped progress in the next session', () => { + storage.setItem(key, record('skipped', 'target-grade')); + users.currentUser.hasRunFirstTimeSetup = true; + service.start(); + finishProfile(); + expect(service.view$.value).toMatchObject({panel: 'welcome', index: 0}); + }); + it.each(['completed', 'dismissed'])( + 'never reopens %s automatically and replay preserves that choice through every transition', + (state) => { + storage.setItem(key, record(state)); + users.currentUser.hasRunFirstTimeSetup = true; + service.start(); + finishProfile(); + expect(service.view$.value).toBeNull(); + service.replay(); + service.begin(); + service.next(); + service.requestSkip(); + service.cancelSkip(); + service.finish(); + expect(storage.getItem(key)).toBe(record(state)); + }, + ); + it('implements confirmation and Escape semantics without losing the current step', () => { + service.start(); + finishProfile(); + service.begin(); + service.next(); + service.escape(); + expect(service.view$.value).toMatchObject({panel: 'skip', index: 1}); + service.escape(); + expect(service.view$.value).toMatchObject({panel: 'step', index: 1}); + service.close(); + expect(service.view$.value).toBeNull(); + }); + it('finishes the four steps and stores completion only when completion is acknowledged', () => { + service.start(); + finishProfile(); + service.begin(); + for (let i = 0; i < 4; i++) { + service.next(); + } + expect(service.view$.value?.panel).toBe('complete'); + service.escape(); + expect(service.view$.value).toBeNull(); + expect(JSON.parse(storage.getItem(key)).state).toBe('completed'); + }); + it('dismisses automatic prompts permanently in this browser', () => { + service.start(); + finishProfile(); + service.dismiss(); + route(); + expect(JSON.parse(storage.getItem(key)).state).toBe('dismissed'); + expect(service.view$.value).toBeNull(); + }); + it('fails open on blocked reads, allows manual replay and never loops', () => { + vi.spyOn(storage, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + service.start(); + finishProfile(); + expect(service.view$.value).toBeNull(); + expect(service.storageUnavailable).toBe(true); + service.replay(); + expect(service.view$.value?.panel).toBe('welcome'); + service.close(); + route(); + expect(service.view$.value).toBeNull(); + }); + it('keeps the shell usable and exposes a warning when writes fail', () => { + service.start(); + finishProfile(); + vi.spyOn(storage, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + service.begin(); + service.next(); + expect(service.storageUnavailable).toBe(true); + expect(service.view$.value).toMatchObject({panel: 'step', index: 1}); + service.close(); + route(); + expect(service.view$.value).toBeNull(); + }); + it('clears only memory on sign-out and isolates a different authenticated account', () => { + service.start(); + finishProfile(); + service.begin(); + settings.IsTutorialEnabled.next(false); + expect(service.view$.value).toBeNull(); + expect(storage.getItem(key)).toBeTruthy(); + users.currentUser = {id: 2, role: 'Student', hasRunFirstTimeSetup: true}; + settings.IsTutorialEnabled.next(true); + expect(service.view$.value).toBeNull(); + service.replay(); + service.begin(); + service.finish(); + expect(storage.getItem('ontrack:student-onboarding:2')).toBeNull(); + expect(JSON.parse(storage.getItem(key)).state).toBe('in-progress'); + }); + it('never changes account data, navigates, or issues an assessment/calendar update', () => { + const original = {...users.currentUser}; + service.start(); + finishProfile(); + const afterSetup = {...users.currentUser}; + service.begin(); + service.next(); + service.next(); + service.next(); + service.next(); + service.finish(); + expect(users.currentUser).toEqual(afterSetup); + expect(afterSetup).toEqual({...original, hasRunFirstTimeSetup: true}); + expect(router.navigate).not.toHaveBeenCalled(); + expect(Object.keys(JSON.parse(storage.getItem(key))).sort()).toEqual([ + 'state', + 'step', + 'version', + ]); + }); + it.each([ + '/edit_profile', + '/welcome;mode=setup', + '/projects/1/task_def_id/2/scorm-player/normal', + '/projects/1/task_def_id/2/scorm-player/review/3', + '/task_def_id/2/preview-scorm', + ])('never covers the protected application route %s', (path) => { + service.start(); + finishProfile(); + service.begin(); + route(path); + service.replay(); + expect(service.available).toBe(false); + expect(service.view$.value).toBeNull(); + }); + it('continues safely on browser Back and suppresses the tutorial on the profile route', () => { + service.start(); + finishProfile(); + service.begin(); + service.next(); + route('/home'); + expect(service.view$.value?.index).toBe(1); + route('/welcome'); + expect(service.view$.value).toBeNull(); + route(); + expect(service.view$.value).toBeNull(); + }); +}); + +describe('untrusted onboarding progress', () => { + it.each([ + null, + '', + '{', + 'null', + '[]', + record('unknown'), + record('completed', 'injected'), + record('completed', 'unit', 999), + record('completed', 'unit', 0), + JSON.stringify({version: 1, state: 'completed', step: 'unit', userId: 4}), + ' '.repeat(257), + ])('rejects invalid or future input %s', (raw) => { + expect(parseOnboardingProgress(raw)).toBeNull(); + }); + it('accepts only the minimum valid progress fields', () => { + expect(parseOnboardingProgress(record('in-progress', 'calendar'))).toEqual({ + version: 1, + state: 'in-progress', + step: 'calendar', + }); + }); +}); diff --git a/src/app/student-onboarding/student-onboarding.service.ts b/src/app/student-onboarding/student-onboarding.service.ts new file mode 100644 index 0000000000..6ce3952853 --- /dev/null +++ b/src/app/student-onboarding/student-onboarding.service.ts @@ -0,0 +1,373 @@ +import {DOCUMENT} from '@angular/common'; +import {HttpClient} from '@angular/common/http'; +import {Inject, Injectable, OnDestroy} from '@angular/core'; +import {NavigationEnd, Router} from '@angular/router'; +import {BehaviorSubject, Subscription, filter, merge, timeout} from 'rxjs'; +import {AuthenticationService} from 'src/app/api/services/authentication.service'; +import {UserService} from 'src/app/api/services/user.service'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {resolveOnboardingTarget} from './student-onboarding-target'; +import {ONBOARDING_STEPS, ONBOARDING_VERSION, OnboardingStepId} from './student-onboarding.steps'; + +export type ProgressState = 'new' | 'in-progress' | 'skipped' | 'dismissed' | 'completed'; +export interface OnboardingProgress { + version: number; + state: ProgressState; + step: OnboardingStepId; +} +export type OnboardingPanel = 'welcome' | 'step' | 'skip' | 'complete'; +export interface OnboardingView { + panel: OnboardingPanel; + index: number; + replay: boolean; +} +const STATES: readonly string[] = ['new', 'in-progress', 'skipped', 'dismissed', 'completed']; + +/** Browser progress is untrusted; reject extra fields and unknown/future schemas. */ +export function parseOnboardingProgress(raw: string | null): OnboardingProgress | null { + if (!raw || raw.length > 256) { + return null; + } + try { + const value = JSON.parse(raw); + if ( + !value || + Object.keys(value).sort().join(',') !== 'state,step,version' || + !Number.isInteger(value.version) || + value.version < 1 || + value.version > ONBOARDING_VERSION || + !STATES.includes(value.state) || + !ONBOARDING_STEPS.some((step) => step.id === value.step) + ) { + return null; + } + return {version: value.version, state: value.state, step: value.step}; + } catch { + return null; + } +} + +@Injectable({providedIn: 'root'}) +export class StudentOnboardingService implements OnDestroy { + readonly view$: BehaviorSubject = new BehaviorSubject(null); + storageUnavailable = false; + private subscription?: Subscription; + private accountId?: number; + private historySubscription?: Subscription; + private historyChecked = false; + private invalidProgress = false; + private generation = 0; + private progress: OnboardingProgress | null = null; + private offered = false; + private skipReturn: OnboardingPanel = 'welcome'; + private returnFocus?: HTMLElement; + + constructor( + private users: UserService, + private auth: AuthenticationService, + private globals: GlobalStateService, + private settings: DoubtfireConstants, + private router: Router, + private http: HttpClient, + @Inject(DOCUMENT) private document: Document, + ) {} + + start(): void { + if (this.subscription) { + return; + } + this.subscription = merge( + this.globals.isLoadingSubject, + this.settings.IsTutorialEnabled, + this.router.events.pipe(filter((event) => event instanceof NavigationEnd)), + ).subscribe(() => this.evaluate()); + } + + get available(): boolean { + const user = this.users.currentUser; + const path = this.router.url.split(/[?#]/)[0]; + const protectedRoute = + /^\/(welcome|edit_profile|sign_in|sign_out)([;/]|$)|\/(scorm-player|preview-scorm)([;/]|$)/.test( + path, + ); + return ( + this.settings.IsTutorialEnabled.value && + this.auth.isAuthenticated() && + Number.isSafeInteger(user?.id) && + user.id > 0 && + user.role === 'Student' && + user.hasRunFirstTimeSetup === true && + !this.globals.isLoadingSubject.value && + !protectedRoute + ); + } + + private evaluate(): void { + const user = this.users.currentUser; + // Reset at sign-out/settings failure even before the asynchronous auth teardown finishes. + if ( + !this.settings.IsTutorialEnabled.value || + !this.auth.isAuthenticated() || + user?.role !== 'Student' + ) { + this.reset(); + return; + } + if (!Number.isSafeInteger(user.id) || user.id < 1) { + this.reset(); + return; + } + if (this.accountId !== user.id) { + this.reset(); + this.accountId = user.id; + try { + const raw = this.document.defaultView.localStorage.getItem(this.key); + this.progress = parseOnboardingProgress(raw); + this.invalidProgress = raw !== null && !this.progress; + } catch { + this.storageUnavailable = true; + } + } + // Observe the existing setup boundary; never change the user or profile flag. + // An existing account with no progress is replay-only, not assumed new. + if ( + user.hasRunFirstTimeSetup === false && + !this.progress && + !this.storageUnavailable && + !this.invalidProgress && + !this.historyChecked + ) { + this.historyChecked = true; + const accountId = this.accountId; + const generation = this.generation; + // This endpoint derives its owner from authentication; no user id is supplied. + // The bounded boolean includes withdrawn/inactive history without returning + // project, assessment or identity data. An older API fails safely to replay. + this.historySubscription = this.http + .get(`${API_URL}/projects/history`) + .pipe(timeout(10000)) + .subscribe({ + next: (history) => { + if ( + generation !== this.generation || + accountId !== this.users.currentUser?.id || + this.users.currentUser.role !== 'Student' || + !this.settings.IsTutorialEnabled.value || + !this.auth.isAuthenticated() + ) { + return; + } + if ( + history !== null && + typeof history === 'object' && + Object.keys(history).join(',') === 'hasProjects' && + (history as {hasProjects: unknown}).hasProjects === false + ) { + this.save('new', 0); + this.evaluate(); + } + }, + error: () => { + /* Unknown history stays replay-only; never block profile setup. */ + }, + }); + } + if (!this.available) { + this.view$.next(null); + return; + } + if ( + this.offered || + this.storageUnavailable || + !this.progress || + ['completed', 'dismissed'].includes(this.progress.state) + ) { + return; + } + this.offered = true; + const resume = + this.progress.state === 'in-progress' && this.progress.version === ONBOARDING_VERSION; + this.returnFocus = this.document.activeElement as HTMLElement; + this.view$.next({ + panel: resume ? 'step' : 'welcome', + index: resume ? this.savedIndex : 0, + replay: false, + }); + } + + replay(): void { + this.evaluate(); + if (!this.available) { + return; + } + this.offered = true; + this.returnFocus = this.document.activeElement as HTMLElement; + this.view$.next({panel: 'welcome', index: 0, replay: true}); + } + + begin(): void { + this.transition('step', 0); + } + + next(): void { + const view = this.view$.value; + if (view?.panel !== 'step') { + return; + } + this.transition( + view.index === ONBOARDING_STEPS.length - 1 ? 'complete' : 'step', + Math.min(view.index + 1, ONBOARDING_STEPS.length - 1), + ); + } + + back(): void { + const view = this.view$.value; + if (view?.panel === 'step') { + this.transition('step', Math.max(0, view.index - 1)); + } + } + + requestSkip(): void { + const view = this.view$.value; + if (view && view.panel !== 'skip') { + this.skipReturn = view.panel; + this.view$.next({...view, panel: 'skip'}); + } + } + + cancelSkip(): void { + const view = this.view$.value; + if (view?.panel === 'skip') { + this.view$.next({...view, panel: this.skipReturn}); + } + } + + escape(): void { + switch (this.view$.value?.panel) { + case 'step': + this.requestSkip(); + break; + case 'skip': + this.cancelSkip(); + break; + case 'complete': + this.finish(); + break; + case 'welcome': + this.skip(); + break; + } + } + + skip(): void { + this.end('skipped'); + } + dismiss(): void { + this.end('dismissed'); + } + finish(): void { + this.end('completed'); + } + close(): void { + this.end(this.view$.value?.panel === 'complete' ? 'completed' : 'skipped'); + } + + private transition(panel: OnboardingPanel, index: number): void { + const view = this.view$.value; + if (!view) { + return; + } + if (!view.replay) { + this.save('in-progress', index); + } + this.view$.next({...view, panel, index}); + } + + private end(state: ProgressState): void { + const view = this.view$.value; + if (!view) { + return; + } + if (!view.replay) { + this.save(state, view.index); + } + this.view$.next(null); + const lastTarget = !view.replay + ? resolveOnboardingTarget(this.document, ONBOARDING_STEPS[view.index].target) + : null; + const launch = this.returnFocus; + const visibleLaunch = + launch && + launch !== this.document.body && + launch.isConnected && + launch.getBoundingClientRect().width > 0 + ? launch + : null; + const target = + lastTarget ?? + visibleLaunch ?? + resolveOnboardingTarget(this.document, 'account-menu') ?? + this.document.querySelector('main, [role="main"]'); + if (target) { + const addedTabIndex = + !target.hasAttribute('tabindex') && + !target.matches('button, a[href], input, select, textarea'); + if (addedTabIndex) { + target.setAttribute('tabindex', '-1'); + } + target.focus(); + if (addedTabIndex) { + target.addEventListener('blur', () => target.removeAttribute('tabindex'), {once: true}); + } + } + } + + private get savedIndex(): number { + return Math.max( + 0, + ONBOARDING_STEPS.findIndex((step) => step.id === this.progress?.step), + ); + } + private get key(): string { + return `ontrack:student-onboarding:${this.accountId}`; + } + + private save(state: ProgressState, index: number): void { + // Never accept a caller-supplied account id or write after the session changes. + if ( + !this.accountId || + this.users.currentUser?.id !== this.accountId || + !this.auth.isAuthenticated() + ) { + return; + } + this.progress = {version: ONBOARDING_VERSION, state, step: ONBOARDING_STEPS[index].id}; + try { + this.document.defaultView.localStorage.setItem(this.key, JSON.stringify(this.progress)); + } catch { + this.storageUnavailable = true; + } + } + + private reset(): void { + this.generation++; + this.historySubscription?.unsubscribe(); + this.historySubscription = undefined; + this.historyChecked = false; + this.invalidProgress = false; + this.view$.next(null); + this.accountId = undefined; + this.progress = null; + this.offered = false; + this.storageUnavailable = false; + this.returnFocus = undefined; + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.reset(); + this.view$.complete(); + } +} diff --git a/src/app/student-onboarding/student-onboarding.steps.ts b/src/app/student-onboarding/student-onboarding.steps.ts new file mode 100644 index 0000000000..b5b438b21b --- /dev/null +++ b/src/app/student-onboarding/student-onboarding.steps.ts @@ -0,0 +1,59 @@ +/** Copy: github-guide/onboarding-tutorial-step-copy.md (DOC-11). */ +export const ONBOARDING_VERSION = 1; +export type OnboardingStepId = 'unit' | 'tasks' | 'target-grade' | 'calendar'; +export interface OnboardingStep { + id: OnboardingStepId; + version: number; + route: 'current' | 'project-dashboard'; + target: string; + title: string; + body: string; + action: string; + fallback: string; +} +export const ONBOARDING_STEPS: readonly OnboardingStep[] = [ + { + id: 'unit', + version: ONBOARDING_VERSION, + route: 'current', + target: 'unit-selector', + title: 'Choose Your Unit', + body: 'Use the unit selector to open the unit you want to work on.', + action: 'Find unit selector', + fallback: + 'Use Select Unit in the toolbar. If no units appear, check your enrolment with your teaching team. You can continue without a unit.', + }, + { + id: 'tasks', + version: ONBOARDING_VERSION, + route: 'project-dashboard', + target: 'task-dashboard', + title: 'Find Your Tasks', + body: 'This dashboard shows the tasks for your selected unit and the progress you have made.', + action: 'Find task dashboard', + fallback: + 'Choose a unit, then open Dashboard from its navigation menu to find tasks and their statuses. You can continue without opening a project.', + }, + { + id: 'target-grade', + version: ONBOARDING_VERSION, + route: 'project-dashboard', + target: 'target-grade', + title: 'Check Your Target Grade', + body: 'Your target grade shows the result you are working towards, and you can change it here if needed.', + action: 'Find target grade', + fallback: + 'Open your unit dashboard and find Select Target Grade. If this control is unavailable, continue the tutorial; no grade will be changed.', + }, + { + id: 'calendar', + version: ONBOARDING_VERSION, + route: 'current', + target: 'calendar', + title: 'Use the Calendar', + body: 'Open the calendar options to download or subscribe to important unit dates.', + action: 'Find Calendar', + fallback: + 'Open the account menu and choose Calendar. You can continue if Calendar is unavailable. This tutorial never creates a subscription.', + }, +]; diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.component.html b/src/app/tasks/task-comment-composer/task-comment-composer.component.html index 8455a0b330..d46642c195 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.component.html +++ b/src/app/tasks/task-comment-composer/task-comment-composer.component.html @@ -1,16 +1,5 @@ - - @if (task) { -
      +
      Replying to: - {{ originalComment !== null ? originalComment.author.name : '' }} + {{ originalComment !== null ? originalComment.author.displayName : '' }}

      {{ originalComment !== null ? originalComment.text : '' }}

      } @if (task) { -
      +
      }
      + + @if (task) { } -
      -
      - -
      - @if ($userIsTyping | async) { + @if (stagedAttachments.length > 0) { +
      + @for (attachment of stagedAttachments; track attachment.clientRequestId) { +
      + +
      + {{ attachment.fileName }} + {{ attachment.byteSize | number }} bytes · + {{ attachmentCategoryName(attachment) || attachment.mimeType }} + @if (attachment.status === 'uploading') { + + } + @if (attachment.status === 'failed') { + {{ attachment.error }} + } +
      + @if (attachment.status === 'failed') { + + } - } - - + @if (!isEditing && !recording) { +
      - crisis_alert - --> - - @if (isStaff) { - @if (!isEditing) { - - } - } - - @if (!isEditing) { - } - - @if (!isEditing) { - } -
      +
      + }
      -
      -
      + + + Press Enter for a new line. Use Send to post the message. + + @if (recording) { } -
      - @if (isEditing) { - - check - } - + +
      + @if (!isEditing) { + + } + + @if (!recording) { +
      -
      - @if (!isEditing) { - - task - } -
      + emoji_emotions + + } +
      diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.component.scss b/src/app/tasks/task-comment-composer/task-comment-composer.component.scss index a1eddf543b..2c8bccd05a 100644 --- a/src/app/tasks/task-comment-composer/task-comment-composer.component.scss +++ b/src/app/tasks/task-comment-composer/task-comment-composer.component.scss @@ -1,67 +1,172 @@ -@use 'sass:color'; - -$emoji-color: color.adjust(black, $lightness: 25%); +:host { + position: relative; + display: block; + width: 100%; + background: var(--ot-color-surface); +} .composer-container { - padding-bottom: 0; + // Measured by its own width, so the comments panel beside a task (about 300px) + // gets the narrow layout below even on a wide screen. + container-type: inline-size; display: flex; - flex-grow: 1; + flex-wrap: wrap; + width: 100%; + align-items: flex-end; + gap: 4px; + row-gap: 6px; + box-sizing: border-box; + padding: 8px max(8px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) + max(8px, env(safe-area-inset-left)); + + .composer-leading-actions, + .composer-trailing-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; + } + #textFieldContainer { - background-color: rgba(0, 0, 0, 0.05); - border-radius: 18px; + background-color: var(--ot-color-hover); + border: 1px solid transparent; + border-radius: 20px; display: flex; - flex: 1 1 auto; - flex-grow: 0; - width: 100%; + flex: 1 1 8rem; + align-items: flex-end; + min-width: 0; max-width: 100%; - margin: 8px 8px 8px 0; - padding: 0 8px 0 12px; - min-height: 39px; + min-height: 44px; + margin: 0; + padding: 0 12px; + transition: + border-color 120ms ease, + background-color 120ms ease; + } + + #textFieldContainer:focus-within { + border-color: var(--ot-color-focus); + background-color: var(--ot-color-surface-raised); + } + + // The field has no outline of its own, so the ring goes on the pill around it. + #textFieldContainer:has(#textField:focus-visible) { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; } #textFieldContainer.recording { - background-color: #3939ff; + background-color: var(--ot-color-primary); + padding: 0 8px; } #textField { - flex-grow: 0; + display: block; + flex: 1 1 auto; width: 100%; max-width: 100%; - font-size: 14px; + min-height: 24px; max-height: 144px; - min-height: 18px; - overflow-y: auto; - overflow-x: auto; - padding: 9px 0; - vertical-align: middle; + box-sizing: border-box; + margin: 0; + padding: 10px 0; + border: 0; + outline: 0; + background: transparent; box-shadow: none; - border: none; + color: var(--ot-color-text); + font-size: 14px; resize: none; - outline: none; line-height: 1.5em; + overflow-x: hidden; + overflow-y: hidden; + overflow-wrap: anywhere; -webkit-font-smoothing: antialiased; text-rendering: optimizelegibility; - white-space: pre-wrap; - word-break: break-word; - text-wrap: wrap; - user-select: text; } - #textField[placeholder]:empty::before { - padding: 9px 0; - -webkit-font-smoothing: antialiased; - content: attr(placeholder); - color: #555; - cursor: text; + #textField::placeholder { + color: var(--ot-color-text-muted); + opacity: 1; } - #textField.active { - max-height: 39px; - max-lines: 1; - overflow: hidden; + .audio-recorder { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: center; + } +} + +.composer-staff-tools { + display: flex; + align-items: center; + gap: 2px; +} + +.staged-attachments { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px max(8px, env(safe-area-inset-right)) 0 max(8px, env(safe-area-inset-left)); +} + +.staged-attachment { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--ot-color-border); + border-radius: 12px; + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); +} + +.staged-attachment.upload-failed { + border-color: var(--ot-color-error); + background: var(--ot-color-surface-raised); +} + +.staged-attachment-details { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-direction: column; + + strong, + span { + overflow-wrap: anywhere; + } + + span { + color: var(--ot-color-text-muted); + font-size: 12px; + word-break: break-word; + } + + .attachment-error { + color: var(--ot-color-error); + white-space: normal; } } +.composer-attachment-guidance { + margin: 0; + padding: 4px max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left)); + color: var(--ot-color-text-muted); + font-size: 12px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.discard-draft-row { + display: flex; + justify-content: flex-end; + padding: 0 max(8px, env(safe-area-inset-right)) 0 max(8px, env(safe-area-inset-left)); +} + #textField.draft-loaded { animation: draftFadeIn 0.3s ease-in; } @@ -69,7 +174,7 @@ $emoji-color: color.adjust(black, $lightness: 25%); @keyframes draftFadeIn { from { opacity: 0.5; - background-color: rgba(0, 0, 255, 0.05); + background-color: color-mix(in srgb, var(--ot-color-primary) 8%, transparent); } to { opacity: 1; @@ -84,12 +189,12 @@ $emoji-color: color.adjust(black, $lightness: 25%); @keyframes blueGlowFade { 0% { - background-color: rgba(66, 133, 244, 0.2); - box-shadow: 0 0 8px 2px rgba(66, 133, 244, 0.6); + background-color: color-mix(in srgb, var(--ot-color-info) 20%, transparent); + box-shadow: 0 0 8px 2px color-mix(in srgb, var(--ot-color-info) 55%, transparent); } 70% { - background-color: rgba(66, 133, 244, 0.1); - box-shadow: 0 0 6px 1px rgba(66, 133, 244, 0.3); + background-color: color-mix(in srgb, var(--ot-color-info) 12%, transparent); + box-shadow: 0 0 6px 1px color-mix(in srgb, var(--ot-color-info) 30%, transparent); } 100% { background-color: transparent; @@ -97,43 +202,55 @@ $emoji-color: color.adjust(black, $lightness: 25%); } } -#innerButtons { - align-items: flex-end; +.emoji-picker-host { + z-index: 1060; + position: absolute; + right: max(8px, env(safe-area-inset-right)); + bottom: calc(100% + 4px); + left: max(8px, env(safe-area-inset-left)); display: flex; - padding: 6px 0 6px 2px; + max-height: min(420px, 60dvh); + justify-content: flex-end; + overflow: auto; } -#emojiButton { - height: 24px; - width: 24px; - cursor: pointer; - color: $emoji-color; +.emoji-picker-backdrop { + z-index: 1050; + position: fixed; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: transparent; } -.emoji-picker { - z-index: 1060; - position: absolute; - right: 26px; - bottom: 150px; +.emoji-picker-host .emoji-picker { + max-width: 100%; +} + +:host ::ng-deep .emoji-picker-host .emoji-mart { + width: min(352px, calc(100vw - 16px)) !important; + max-width: 100%; } #emojiSearchResults { margin: 0 5px 0 5px; max-height: 125px; overflow: scroll; - background-color: white; + background-color: var(--ot-color-surface-raised); border-radius: 12px; border-style: solid; border-width: 1px; - border-color: color.adjust($color: white, $lightness: -10%); + border-color: var(--ot-color-border); } -#replyContainer { +.reply-container { margin: 0.4em 1em -0.2em 1em; font-size: 14px; p { - color: rgba(0, 0, 0, 0.5); + color: var(--ot-color-text-muted); font-size: 12px; max-lines: 2; max-height: 35px; @@ -180,7 +297,7 @@ $emoji-color: color.adjust(black, $lightness: 25%); } .markdown-editor-action { - color: grey; + color: var(--ot-color-text-muted); font-size: 1em; cursor: pointer; } @@ -191,6 +308,7 @@ $emoji-color: color.adjust(black, $lightness: 25%); } form { + position: relative; display: flex; width: 100%; } @@ -211,7 +329,7 @@ form { width: 20px; height: 20px; transform: rotate(-45deg); - background: #fff; + background: var(--ot-color-surface-raised); position: absolute; box-shadow: 1px 4px 8px rgba(0, 0, 0, 0.5); z-index: -1; @@ -222,28 +340,137 @@ form { .image-uploader { width: 100%; border-left: 1px solid; - border-color: #ddd; -} - -.composer-action-group { - margin-right: 6px; + border-color: var(--ot-color-divider); } button.btn-no-outline { - margin-left: -12px; - margin-right: -12px; - color: $emoji-color; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 44px; + min-width: 44px; + height: 44px; + min-height: 44px; + margin: 0; + padding: 10px; + color: var(--ot-color-text-muted); line-height: 1; - transform: scale(0.8); + transform: none; +} + +button.btn-no-outline:focus-visible, +.composer-send-button:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; } .message-inbox-icon { line-height: 1; - font-size: 22px; - width: 22px; - height: 22px; + font-size: 24px; + width: 24px; + height: 24px; +} + +.composer-send-button { + flex: 0 0 auto; + min-width: 58px; + min-height: 44px; + padding: 0 12px; + border-radius: 9999px; + font-weight: 600; +} + +.composer-send-button:disabled { + color: var(--ot-color-text-muted); + background: var(--ot-color-divider); } #commentComposerFoter { border-top: none; } + +@media (max-width: 639.98px) { + .composer-container { + gap: 2px; + + #textFieldContainer { + flex-basis: 4rem; + min-height: 44px; + } + } + + button.btn-no-outline.composer-touch-action { + width: 48px; + min-width: 48px; + height: 48px; + min-height: 48px; + margin: 0; + padding: 12px; + border: 1px solid var(--ot-color-border); + border-radius: 9999px; + background-color: var(--ot-color-surface-raised); + color: var(--ot-color-text); + transform: none; + } + + button.btn-no-outline.composer-icon-action { + width: 44px; + min-width: 44px; + height: 44px; + min-height: 44px; + } + + button.btn-no-outline.composer-touch-action:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; + } + + button.btn-no-outline.composer-touch-action .message-inbox-icon { + width: 24px; + height: 24px; + font-size: 24px; + } + + .composer-send-button { + min-width: 56px; + padding-right: 10px; + padding-left: 10px; + } +} + +@media (max-width: 359.98px) { + .composer-container { + #textFieldContainer { + flex-basis: 3rem; + padding-right: 8px; + padding-left: 8px; + } + } + + button.btn-no-outline.composer-touch-action, + button.btn-no-outline.composer-icon-action { + width: 44px; + min-width: 44px; + } + + .composer-send-button { + min-width: 52px; + padding-right: 8px; + padding-left: 8px; + } +} + +// Too narrow for the message and four controls on one row: the attach, record, emoji +// and Send buttons squeezed the message box to a few letters a line. The message +// takes the whole first row and the controls sit under it, Send on the right. +@container (max-width: 26rem) { + .composer-container #textFieldContainer { + order: -1; + flex-basis: 100%; + } + + .composer-container .composer-trailing-actions { + margin-left: auto; + } +} diff --git a/src/app/tasks/task-comment-composer/task-comment-composer.component.spec.ts b/src/app/tasks/task-comment-composer/task-comment-composer.component.spec.ts new file mode 100644 index 0000000000..2788ac95bd --- /dev/null +++ b/src/app/tasks/task-comment-composer/task-comment-composer.component.spec.ts @@ -0,0 +1,1043 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {SimpleChange} from '@angular/core'; +import {EMPTY, Subject, of, throwError} from 'rxjs'; +import {TaskComment} from 'src/app/api/models/doubtfire-model'; +import {AttachmentPolicy} from 'src/app/api/models/task-comment/attachment-policy'; +import {AttachmentUploadState, TaskCommentService} from 'src/app/api/services/task-comment.service'; +import { + FeedbackDraftContext, + FeedbackDraftStore, + StagedFeedbackAttachment, +} from 'src/app/common/services/feedback-draft-store.service'; +import {TaskCommentComposerComponent} from './task-comment-composer.component'; + +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +// The same categories the API publishes at /task_comments/upload_policy. +const CHAT_POLICY: AttachmentPolicy = { + version: 1, + max_bytes_exclusive: 30_000_000, + max_selection_count: 5, + categories: [ + {id: 'pdf', name: 'PDF', extensions: ['pdf'], preview: 'pdf'}, + {id: 'document', name: 'Document', extensions: ['docx'], preview: 'download'}, + {id: 'spreadsheet', name: 'Spreadsheet', extensions: ['csv', 'xlsx'], preview: 'download'}, + { + id: 'image', + name: 'Image', + extensions: ['png', 'bmp', 'tiff', 'tif', 'jpeg', 'jpg', 'gif'], + preview: 'image', + }, + {id: 'audio', name: 'Audio', extensions: ['wav', 'ogg', 'mp3', 'webm'], preview: 'audio'}, + ], +}; + +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)), + }; +} + +function textarea(value = ''): HTMLTextAreaElement { + const element = document.createElement('textarea'); + element.value = value; + Object.defineProperty(element, 'scrollHeight', {configurable: true, value: 24}); + return element; +} + +function task(id: number) { + return { + id, + projectId: 50, + project: {id: 50}, + definition: {id: id + 100}, + unit: {id: 9, currentUserIsStaff: false}, + comments: [], + }; +} + +function contextFor(taskValue: ReturnType, userId = 7): FeedbackDraftContext { + return { + userId, + unitId: 9, + projectId: 50, + taskDefinitionId: taskValue.definition.id, + taskId: taskValue.id, + conversation: 'task-feedback', + }; +} + +interface ComposerHarness { + component: TaskCommentComposerComponent; + draftStore: FeedbackDraftStore; + taskCommentService: { + addComment: ReturnType; + uploadStagedAttachment: ReturnType; + editComment: ReturnType; + }; + alerts: {error: ReturnType}; + message: HTMLTextAreaElement; +} + +function createComposer(taskValue = task(1), userId = 7): ComposerHarness { + const draftStore = new FeedbackDraftStore(); + const taskCommentService = { + addComment: vi.fn(() => of({id: 1} as TaskComment)), + uploadStagedAttachment: vi.fn(() => + of({state: 'complete', progress: 100}), + ), + editComment: vi.fn(() => of({id: 1} as TaskComment)), + }; + const alerts = {error: vi.fn()}; + const message = textarea(); + const component = new TaskCommentComposerComponent( + {find: () => ({create: () => ({diff: () => null})})} as never, + {} as never, + {search: vi.fn(() => [])} as never, + {nativeEmojiToColons: vi.fn((value: string) => value)} as never, + {scrollDown: vi.fn()} as never, + alerts as never, + taskCommentService as unknown as TaskCommentService, + {detectChanges: vi.fn(), markForCheck: vi.fn()} as never, + {currentUser: {id: userId}} as never, + {events: EMPTY} as never, + draftStore, + {isDark: () => false} as never, + ); + component.attachmentPolicy = CHAT_POLICY; + component.task = taskValue as never; + component.sharedData = {originalComment: null, editingComment: null}; + component.input = {first: {nativeElement: message}} as never; + component.uploader = {nativeElement: {value: 'chosen'}} as never; + return {component, draftStore, taskCommentService, alerts, message}; +} + +function fakeFile(name: string, type: string, size = 10): File { + return {name, type, size} as File; +} + +function stagedFile(name: string): StagedFeedbackAttachment { + return { + data: fakeFile(name, 'application/pdf'), + fileName: name, + mimeType: 'application/pdf', + byteSize: 10, + kind: 'file', + clientRequestId: `staged-${name}`, + status: 'staged', + progress: 0, + }; +} + +function switchTask( + harness: ComposerHarness, + from: ReturnType, + to: ReturnType, +): void { + harness.component.task = to as never; + harness.component.ngOnChanges({ + task: new SimpleChange(from, to, false), + }); +} + +function typeDraft(harness: ComposerHarness, text: string): void { + harness.message.value = text; + harness.component.onInputChange({target: harness.message} as unknown as Event); +} + +// What ngDoCheck does once its differ sees editingComment or originalComment change. +function syncSharedData(harness: ComposerHarness): void { + (harness.component as unknown as {syncComposerState(): void}).syncComposerState(); +} + +function startEditing(harness: ComposerHarness, comment: TaskComment): void { + harness.component.sharedData.editingComment = comment; + syncSharedData(harness); +} + +describe('TaskCommentComposerComponent staged feedback', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage(), + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: memoryStorage(), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stages picker files without posting and accepts an uppercase DOCX extension', () => { + const harness = createComposer(); + const documentFile = fakeFile('Assessment.DOCX', DOCX_MIME); + + harness.component.uploadFiles([documentFile]); + + expect(harness.taskCommentService.uploadStagedAttachment).not.toHaveBeenCalled(); + expect(harness.taskCommentService.addComment).not.toHaveBeenCalled(); + expect(harness.component.stagedAttachments).toHaveLength(1); + expect(harness.component.stagedAttachments[0]).toMatchObject({ + data: documentFile, + fileName: 'Assessment.DOCX', + mimeType: DOCX_MIME, + status: 'staged', + }); + }); + + it('treats picker cancellation as a no-op and keeps the existing draft intact', () => { + const harness = createComposer(); + harness.message.value = 'keep typing'; + harness.component.uploadFiles([fakeFile('already.pdf', 'application/pdf')]); + const before = [...harness.component.stagedAttachments]; + + // A cancelled native picker produces an empty selection (and on some + // browsers no change event at all). Either way, the composer must not send + // or clear anything. + harness.component.uploadFiles([]); + + expect(harness.message.value).toBe('keep typing'); + expect(harness.component.stagedAttachments).toEqual(before); + expect(harness.taskCommentService.uploadStagedAttachment).not.toHaveBeenCalled(); + expect(harness.taskCommentService.addComment).not.toHaveBeenCalled(); + }); + + it.each([ + ['renamed.docx', 'application/pdf'], + ['renamed.pdf', DOCX_MIME], + ])('rejects extension and MIME mismatches for %s', (name, type) => { + const harness = createComposer(); + + harness.component.uploadFiles([fakeFile(name, type)]); + + expect(harness.component.stagedAttachments).toEqual([]); + expect(harness.alerts.error).toHaveBeenCalledWith( + expect.stringContaining('does not match'), + 6000, + ); + }); + + it('enforces the strict smaller-than-30-MB client boundary', () => { + const harness = createComposer(); + + harness.component.uploadFiles([fakeFile('too-large.DOCX', DOCX_MIME, 30_000_000)]); + + expect(harness.component.stagedAttachments).toEqual([]); + expect(harness.alerts.error).toHaveBeenCalledWith( + expect.stringContaining('smaller than 30 MB'), + 6000, + ); + }); + + it('keeps multiple staged items independent and removes only the selected one', () => { + const harness = createComposer(); + harness.component.uploadFiles([ + fakeFile('one.pdf', 'application/pdf'), + fakeFile('two.DOCX', DOCX_MIME), + ]); + const [first, second] = harness.component.stagedAttachments; + + harness.component.removeStagedAttachment(first.clientRequestId); + + expect(harness.component.stagedAttachments).toHaveLength(1); + expect(harness.component.stagedAttachments[0].clientRequestId).toBe(second.clientRequestId); + }); + + it('replaces only the prior unsent recording and leaves staged files intact', () => { + const harness = createComposer(); + const firstRecording = new Blob(['first'], {type: 'audio/webm'}); + const replacement = new Blob(['replacement'], {type: 'audio/ogg'}); + harness.component.uploadFiles([fakeFile('notes.pdf', 'application/pdf')]); + + harness.component.stageAudioRecording(firstRecording); + const oldAudioId = harness.component.stagedAttachments.find( + (item) => item.kind === 'audio', + )?.clientRequestId; + harness.component.stageAudioRecording(replacement); + + expect(harness.component.stagedAttachments).toHaveLength(2); + expect(harness.component.stagedAttachments.map((item) => item.fileName)).toEqual([ + 'notes.pdf', + 'feedback-recording.ogg', + ]); + expect( + harness.component.stagedAttachments.some((item) => item.clientRequestId === oldAudioId), + ).toBe(false); + }); + + it('sends every attachment with an empty caption, then posts the text exactly once', () => { + const harness = createComposer(); + harness.message.value = 'Keep this exact\nmessage'; + harness.component.uploadFiles([ + fakeFile('one.pdf', 'application/pdf'), + fakeFile('two.DOCX', DOCX_MIME), + ]); + const attachmentIds = harness.component.stagedAttachments.map((item) => item.clientRequestId); + + harness.component.addComment(); + + expect(harness.taskCommentService.uploadStagedAttachment).toHaveBeenCalledTimes(2); + expect(harness.taskCommentService.uploadStagedAttachment.mock.calls).toEqual([ + [ + harness.component.task, + expect.objectContaining({name: 'one.pdf'}), + 'one.pdf', + '', + null, + attachmentIds[0], + ], + [ + harness.component.task, + expect.objectContaining({name: 'two.DOCX'}), + 'two.DOCX', + '', + null, + attachmentIds[1], + ], + ]); + expect(harness.taskCommentService.addComment).toHaveBeenCalledOnce(); + expect(harness.taskCommentService.addComment).toHaveBeenCalledWith( + harness.component.task, + 'Keep this exact\nmessage', + 'text', + null, + undefined, + expect.any(String), + ); + }); + + it('retains a failed item, gives an offline recovery message, and retries with the same id', () => { + const harness = createComposer(); + harness.taskCommentService.uploadStagedAttachment.mockReturnValueOnce( + throwError(() => ({status: 0})), + ); + harness.component.uploadFiles([fakeFile('offline.pdf', 'application/pdf')]); + const requestId = harness.component.stagedAttachments[0].clientRequestId; + + harness.component.addComment(); + + expect(harness.component.stagedAttachments[0]).toMatchObject({ + clientRequestId: requestId, + status: 'failed', + error: expect.stringContaining('offline'), + }); + expect(harness.alerts.error).toHaveBeenCalledWith( + expect.stringContaining('Some attachments could not be sent'), + 6000, + ); + + harness.taskCommentService.uploadStagedAttachment.mockReturnValueOnce( + of({state: 'complete', progress: 100}), + ); + harness.component.retryStagedAttachment(requestId); + + expect(harness.taskCommentService.uploadStagedAttachment).toHaveBeenNthCalledWith( + 2, + harness.component.task, + expect.objectContaining({name: 'offline.pdf'}), + 'offline.pdf', + '', + null, + requestId, + ); + expect(harness.component.stagedAttachments).toEqual([]); + }); + + it('reports timeout recovery without discarding the staged item', () => { + const harness = createComposer(); + harness.taskCommentService.uploadStagedAttachment.mockReturnValue( + throwError(() => ({name: 'TimeoutError'})), + ); + harness.component.uploadFiles([fakeFile('slow.pdf', 'application/pdf')]); + + harness.component.addComment(); + + expect(harness.component.stagedAttachments[0].error).toContain('timed out'); + expect(harness.component.stagedAttachments[0].error).toContain('draft is still here'); + }); + + it('retries text with one stable idempotency id after an ambiguous failure', () => { + const harness = createComposer(); + const firstAttempt: Subject = new Subject(); + harness.taskCommentService.addComment.mockReturnValueOnce(firstAttempt.asObservable()); + harness.message.value = 'send once'; + + harness.component.addComment(); + const firstRequestId = harness.taskCommentService.addComment.mock.calls[0][5]; + firstAttempt.error({status: 0}); + harness.taskCommentService.addComment.mockReturnValueOnce(of({id: 2} as TaskComment)); + harness.component.addComment(); + + expect(harness.taskCommentService.addComment).toHaveBeenCalledTimes(2); + expect(harness.taskCommentService.addComment.mock.calls[1][5]).toBe(firstRequestId); + expect(harness.alerts.error).toHaveBeenCalledWith(expect.stringContaining('offline'), 6000); + }); + + it('keeps task A text, reply, and attachments isolated when moving to task B', () => { + const taskA = task(1); + const taskB = task(2); + const replyTarget = {id: 812} as TaskComment; + taskA.comments = [replyTarget] as never[]; + const harness = createComposer(taskA); + harness.message.value = 'task A only'; + harness.component.sharedData.originalComment = replyTarget; + harness.component.uploadFiles([fakeFile('a.pdf', 'application/pdf')]); + + harness.component.task = taskB as never; + harness.component.ngOnChanges({ + task: new SimpleChange(taskA, taskB, false), + }); + + expect(harness.draftStore.load(contextFor(taskA)).text).toBe('task A only'); + expect(harness.draftStore.load(contextFor(taskA)).replyToId).toBe(812); + expect(harness.draftStore.attachments(contextFor(taskA))).toHaveLength(1); + expect(harness.component.stagedAttachments).toEqual([]); + expect(harness.message.value).toBe(''); + expect(harness.draftStore.load(contextFor(taskB)).text).toBe(''); + expect(harness.draftStore.load(contextFor(taskB)).replyToId).toBeNull(); + expect(harness.component.sharedData.originalComment).toBeNull(); + }); + + it('explicitly discards the exact scoped text-and-reply draft', () => { + const taskValue = task(3); + const harness = createComposer(taskValue); + const replyTarget = {id: 411} as TaskComment; + harness.component.sharedData.originalComment = replyTarget; + harness.message.value = 'private unsent reply\nwith whitespace '; + harness.component.onInputChange({target: harness.message} as unknown as Event); + const key = harness.draftStore.key(contextFor(taskValue)); + + expect(sessionStorage.getItem(key)).not.toBeNull(); + expect(harness.draftStore.load(contextFor(taskValue))).toMatchObject({ + text: 'private unsent reply\nwith whitespace ', + replyToId: 411, + }); + + harness.component.discardDraft(); + + expect(sessionStorage.getItem(key)).toBeNull(); + expect(harness.message.value).toBe(''); + expect(harness.component.sharedData.originalComment).toBeNull(); + }); + + it('does not let a stale task-A draft timer overwrite task B', () => { + vi.useFakeTimers(); + const taskA = task(1); + const taskB = task(2); + const harness = createComposer(taskA); + harness.draftStore.save(contextFor(taskA), 'stale A', null); + harness.component.input = undefined; + + (harness.component as unknown as {loadDraftForTask(taskValue: unknown): void}).loadDraftForTask( + taskA, + ); + + harness.component.input = {first: {nativeElement: harness.message}} as never; + harness.component.task = taskB as never; + harness.component.ngOnChanges({ + task: new SimpleChange(taskA, taskB, false), + }); + vi.runAllTimers(); + + expect(harness.message.value).toBe(''); + expect(harness.component.stagedAttachments).toEqual([]); + }); + + it('finishes a text send on the task it was sent from, not the task open when it lands', () => { + const taskA = task(1); + const taskB = task(2); + const harness = createComposer(taskA); + const response: Subject = new Subject(); + harness.taskCommentService.addComment.mockReturnValueOnce(response.asObservable()); + harness.draftStore.save(contextFor(taskB), 'B unsent draft', null); + harness.draftStore.stageAttachment(contextFor(taskB), stagedFile('b.pdf')); + harness.message.value = 'A feedback'; + + harness.component.addComment(); + switchTask(harness, taskA, taskB); + + expect(harness.message.value).toBe('B unsent draft'); + expect(harness.component.isSending).toBe(false); + + response.next({id: 3} as TaskComment); + response.complete(); + + expect(harness.taskCommentService.addComment).toHaveBeenCalledOnce(); + expect(harness.draftStore.load(contextFor(taskA)).text).toBe(''); + expect(harness.draftStore.load(contextFor(taskB)).text).toBe('B unsent draft'); + expect(harness.draftStore.attachments(contextFor(taskB))).toHaveLength(1); + expect(harness.component.stagedAttachments).toHaveLength(1); + expect(harness.message.value).toBe('B unsent draft'); + }); + + it('keeps the rest of an attachment queue and its text on the task it was sent from', () => { + const taskA = task(1); + const taskB = task(2); + const harness = createComposer(taskA); + const firstUpload: Subject = new Subject(); + harness.taskCommentService.uploadStagedAttachment.mockReturnValueOnce( + firstUpload.asObservable(), + ); + harness.message.value = 'A feedback, see attached'; + harness.component.uploadFiles([ + fakeFile('a1.pdf', 'application/pdf'), + fakeFile('a2.pdf', 'application/pdf'), + ]); + harness.draftStore.save(contextFor(taskB), 'B half-written feedback', null); + + harness.component.addComment(); + switchTask(harness, taskA, taskB); + firstUpload.next({state: 'complete', progress: 100}); + firstUpload.complete(); + + const uploads = harness.taskCommentService.uploadStagedAttachment.mock.calls; + expect(uploads).toHaveLength(2); + expect(uploads[0][0]).toBe(taskA); + expect(uploads[1][0]).toBe(taskA); + expect(harness.taskCommentService.addComment).toHaveBeenCalledOnce(); + expect(harness.taskCommentService.addComment.mock.calls[0][0]).toBe(taskA); + expect(harness.taskCommentService.addComment.mock.calls[0][1]).toBe('A feedback, see attached'); + expect(harness.draftStore.attachments(contextFor(taskA))).toEqual([]); + expect(harness.draftStore.load(contextFor(taskA)).text).toBe(''); + expect(harness.draftStore.load(contextFor(taskB)).text).toBe('B half-written feedback'); + expect(harness.message.value).toBe('B half-written feedback'); + expect(harness.component.isSending).toBe(false); + }); + + it('posts the text Send was pressed on when the composer is rebuilt during an upload', () => { + const taskValue = task(1); + const first = createComposer(taskValue); + const upload: Subject = new Subject(); + first.taskCommentService.uploadStagedAttachment.mockReturnValueOnce(upload.asObservable()); + first.message.value = 'Original feedback, see attached'; + first.component.uploadFiles([fakeFile('a1.pdf', 'application/pdf')]); + first.component.addComment(); + + // The phone dashboard destroys the composer when the pane changes and builds a new one. + first.component.ngOnDestroy(); + const second = createComposer(taskValue); + (second.component as unknown as {loadDraftForTask(taskValue: unknown): void}).loadDraftForTask( + taskValue, + ); + typeDraft(second, 'A new message not sent yet'); + + upload.next({state: 'complete', progress: 100}); + upload.complete(); + + expect(first.taskCommentService.addComment).toHaveBeenCalledOnce(); + expect(first.taskCommentService.addComment.mock.calls[0][1]).toBe( + 'Original feedback, see attached', + ); + expect(second.taskCommentService.addComment).not.toHaveBeenCalled(); + expect(second.draftStore.load(contextFor(taskValue)).text).toBe('A new message not sent yet'); + }); + + it('shows a send still in flight as sending again after coming back to its task', () => { + const taskA = task(1); + const taskB = task(2); + const harness = createComposer(taskA); + const response: Subject = new Subject(); + harness.taskCommentService.addComment.mockReturnValueOnce(response.asObservable()); + harness.message.value = 'A feedback'; + + harness.component.addComment(); + switchTask(harness, taskA, taskB); + expect(harness.component.isSending).toBe(false); + switchTask(harness, taskB, taskA); + expect(harness.component.isSending).toBe(true); + + response.next({id: 3} as TaskComment); + response.complete(); + + expect(harness.component.isSending).toBe(false); + expect(harness.message.value).toBe(''); + expect(harness.draftStore.load(contextFor(taskA)).text).toBe(''); + }); + + it('does not clear the task now open when an earlier edit finishes saving', () => { + const taskA = task(1); + const taskB = task(2); + const harness = createComposer(taskA); + const edit: Subject = new Subject(); + harness.taskCommentService.editComment.mockReturnValueOnce(edit.asObservable()); + harness.draftStore.save(contextFor(taskB), 'B draft', null); + startEditing(harness, {id: 5, text: 'Please resubmit'} as TaskComment); + harness.message.value = 'Please resubmit by Friday'; + + harness.component.send(); + switchTask(harness, taskA, taskB); + edit.next({id: 5} as TaskComment); + edit.complete(); + + expect(harness.taskCommentService.editComment).toHaveBeenCalledOnce(); + expect(harness.message.value).toBe('B draft'); + expect(harness.component.isSending).toBe(false); + }); + + it('does not upload an attachment removed while an earlier one is still uploading', () => { + const harness = createComposer(); + const firstUpload: Subject = new Subject(); + harness.taskCommentService.uploadStagedAttachment.mockReturnValueOnce( + firstUpload.asObservable(), + ); + harness.component.uploadFiles([ + fakeFile('one.pdf', 'application/pdf'), + fakeFile('wrong-student.pdf', 'application/pdf'), + ]); + const [first, second] = harness.component.stagedAttachments; + + harness.component.addComment(); + harness.component.removeStagedAttachment(second.clientRequestId); + firstUpload.next({state: 'complete', progress: 100}); + firstUpload.complete(); + + expect(harness.taskCommentService.uploadStagedAttachment).toHaveBeenCalledOnce(); + expect(harness.taskCommentService.uploadStagedAttachment.mock.calls[0][5]).toBe( + first.clientRequestId, + ); + expect(harness.component.stagedAttachments).toEqual([]); + }); + + it('uses a new idempotency id when the text changes after an ambiguous failure', () => { + const harness = createComposer(); + const firstAttempt: Subject = new Subject(); + harness.taskCommentService.addComment.mockReturnValueOnce(firstAttempt.asObservable()); + harness.message.value = 'Fix the tpyo'; + + harness.component.addComment(); + const firstRequestId = harness.taskCommentService.addComment.mock.calls[0][5]; + firstAttempt.error({status: 0}); + harness.message.value = 'Fix the typo'; + harness.component.addComment(); + + expect(harness.taskCommentService.addComment).toHaveBeenCalledTimes(2); + expect(harness.taskCommentService.addComment.mock.calls[1][1]).toBe('Fix the typo'); + expect(harness.taskCommentService.addComment.mock.calls[1][5]).not.toBe(firstRequestId); + }); + + it('uses a new idempotency id when the reply target changes after an ambiguous failure', () => { + const harness = createComposer(); + const firstAttempt: Subject = new Subject(); + harness.taskCommentService.addComment.mockReturnValueOnce(firstAttempt.asObservable()); + harness.component.sharedData.originalComment = {id: 812} as TaskComment; + harness.message.value = 'Same words'; + + harness.component.addComment(); + const firstRequestId = harness.taskCommentService.addComment.mock.calls[0][5]; + firstAttempt.error({status: 0}); + harness.component.cancelReply(); + harness.component.addComment(); + + expect(harness.taskCommentService.addComment.mock.calls[1][3]).toBeNull(); + expect(harness.taskCommentService.addComment.mock.calls[1][5]).not.toBe(firstRequestId); + }); + + it.each([ + ['keeps', 'send once', 'send once'], + ['replaces', 'Fix the tpyo', 'Fix the typo'], + ])( + '%s the idempotency id across a reload when the retried text is %s then %s', + (verb, firstText, retriedText) => { + const taskValue = task(4); + const firstTab = createComposer(taskValue); + firstTab.taskCommentService.addComment.mockReturnValueOnce(throwError(() => ({status: 0}))); + firstTab.message.value = firstText; + firstTab.component.addComment(); + const firstRequestId = firstTab.taskCommentService.addComment.mock.calls[0][5]; + typeDraft(firstTab, retriedText); + + const reloaded = createComposer(taskValue); + ( + reloaded.component as unknown as {loadDraftForTask(taskValue: unknown): void} + ).loadDraftForTask(taskValue); + expect(reloaded.message.value).toBe(retriedText); + reloaded.component.addComment(); + + const retriedRequestId = reloaded.taskCommentService.addComment.mock.calls[0][5]; + if (verb === 'keeps') { + expect(retriedRequestId).toBe(firstRequestId); + } else { + expect(retriedRequestId).not.toBe(firstRequestId); + } + }, + ); +}); + +describe('TaskCommentComposerComponent editing keeps the unsent draft', () => { + const earlierComment = {id: 5, text: 'Please resubmit'} as TaskComment; + + beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage(), + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: memoryStorage(), + }); + }); + + it('when another task is opened during the edit', () => { + const taskA = task(1); + const taskB = task(2); + const harness = createComposer(taskA); + typeDraft(harness, 'Good start, but fix X'); + startEditing(harness, earlierComment); + expect(harness.message.value).toBe('Please resubmit'); + + switchTask(harness, taskA, taskB); + + expect(harness.draftStore.load(contextFor(taskA)).text).toBe('Good start, but fix X'); + }); + + it('when an emoji is added during the edit', () => { + const harness = createComposer(); + typeDraft(harness, 'Good start, but fix X'); + startEditing(harness, earlierComment); + + harness.component.addEmoji('👍'); + + expect(harness.message.value).toContain('👍'); + expect(harness.draftStore.load(contextFor(task(1))).text).toBe('Good start, but fix X'); + }); + + it('when the composer is destroyed during the edit', () => { + const harness = createComposer(); + typeDraft(harness, 'Good start, but fix X'); + startEditing(harness, earlierComment); + + harness.component.ngOnDestroy(); + + expect(harness.draftStore.load(contextFor(task(1))).text).toBe('Good start, but fix X'); + }); + + it('and puts it back in the field after the edit is saved', () => { + const harness = createComposer(); + typeDraft(harness, 'Good start, but fix X'); + startEditing(harness, earlierComment); + harness.message.value = 'Please resubmit by Friday'; + + harness.component.send(); + syncSharedData(harness); + + expect(harness.taskCommentService.editComment).toHaveBeenCalledWith( + earlierComment, + 'Please resubmit by Friday', + ); + expect(harness.message.value).toBe('Good start, but fix X'); + expect(harness.draftStore.load(contextFor(task(1))).text).toBe('Good start, but fix X'); + }); + + it('when Reply is chosen during the edit', () => { + const harness = createComposer(); + const replyTarget = {id: 812} as TaskComment; + typeDraft(harness, 'Good start, but fix X'); + startEditing(harness, earlierComment); + + // The Reply bubble action drops the edit and sets a reply target. + harness.component.sharedData.editingComment = null; + harness.component.sharedData.originalComment = replyTarget; + syncSharedData(harness); + + expect(harness.message.value).toBe('Good start, but fix X'); + expect(harness.draftStore.load(contextFor(task(1)))).toMatchObject({ + text: 'Good start, but fix X', + replyToId: 812, + }); + }); + + it('when a second comment is edited before the first edit ends', () => { + const harness = createComposer(); + typeDraft(harness, 'Good start, but fix X'); + startEditing(harness, earlierComment); + harness.message.value = 'Please resubmit by Friday'; + + startEditing(harness, {id: 6, text: 'See the rubric'} as TaskComment); + expect(harness.message.value).toBe('See the rubric'); + harness.component.cancelEdit(); + + expect(harness.message.value).toBe('Good start, but fix X'); + }); +}); + +// Method-level test on a bare instance. The composer's constructor wires up a +// KeyValueDiffer and session storage, so we drive addCommentWithType directly +// with stubbed collaborators. +describe('TaskCommentComposerComponent addCommentWithType', () => { + it('clears the draft and posts without placeholder debug logging', () => { + const component = Object.create(TaskCommentComposerComponent.prototype) as { + taskCommentService: {addComment: ReturnType}; + task: unknown; + comment: {text: string; type: string}; + commentsViewer: {scrollDown: ReturnType}; + alerts: {error: ReturnType}; + addCommentWithType(comment: string, type: string): void; + }; + component.taskCommentService = {addComment: vi.fn(() => of({}))}; + component.task = {}; + component.comment = {text: 'hello', type: 'text'}; + component.commentsViewer = {scrollDown: vi.fn()}; + component.alerts = {error: vi.fn()}; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + component.addCommentWithType('hello', 'text'); + + expect(logSpy).not.toHaveBeenCalled(); + expect(component.comment.text).toBe(''); + expect(component.commentsViewer.scrollDown).toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); + +function clipboardPasteEvent(files: File[]) { + return { + clipboardData: { + files, + items: [], + }, + preventDefault: vi.fn(), + } as never as ClipboardEvent; +} + +function beforeInputPasteEvent(files: File[]) { + return { + inputType: 'insertFromPaste', + dataTransfer: {files}, + preventDefault: vi.fn(), + } as never as InputEvent; +} + +function image(name: string, lastModified: number): File { + return new File(['image'], name, {type: 'image/png', lastModified}); +} + +describe('TaskCommentComposerComponent clipboard image paste', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage(), + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: memoryStorage(), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage(), + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: memoryStorage(), + }); + }); + + function clipboardComposer() { + const harness = createComposer(); + const uploadSpy = vi.spyOn(harness.component, 'uploadFiles').mockImplementation(() => {}); + return {...harness, uploadSpy}; + } + + it('accepts an approved image from the paste event', () => { + const {component, uploadSpy} = clipboardComposer(); + const file = image('screenshot.png', 10); + const event = clipboardPasteEvent([file]); + + component.handlePaste(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(uploadSpy).toHaveBeenCalledWith([file]); + }); + + it('accepts an approved image from the beforeinput path', () => { + const {component, uploadSpy} = clipboardComposer(); + const file = image('screenshot.png', 20); + const event = beforeInputPasteEvent([file]); + + component.handleBeforeInput(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(uploadSpy).toHaveBeenCalledWith([file]); + }); + + it('prevents beforeinput and paste from creating the same attachment twice', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-17T12:00:00Z')); + const {component, uploadSpy} = clipboardComposer(); + const file = image('screenshot.png', 30); + + component.handleBeforeInput(beforeInputPasteEvent([file])); + component.handlePaste(clipboardPasteEvent([file])); + + expect(uploadSpy).toHaveBeenCalledOnce(); + }); + + it('preserves text already typed when an image is pasted', () => { + vi.useFakeTimers(); + const {component, message} = clipboardComposer(); + message.value = 'keep this text'; + + component.handlePaste(clipboardPasteEvent([image('screenshot.png', 40)])); + message.value = 'temporary pasted placeholder'; + vi.runAllTimers(); + + expect(message.value).toBe('keep this text'); + }); + + it('rejects unsupported clipboard files with a clear message', () => { + const {component, alerts, uploadSpy} = clipboardComposer(); + const pdf = new File(['pdf'], 'evidence.pdf', {type: 'application/pdf', lastModified: 50}); + const event = clipboardPasteEvent([pdf]); + + component.handlePaste(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(uploadSpy).not.toHaveBeenCalled(); + expect(alerts.error).toHaveBeenCalledWith( + 'Clipboard paste supports approved image files only.', + 4000, + ); + }); + + it('leaves normal text or HTML paste alone when there is no clipboard file', () => { + const {component, alerts, uploadSpy} = clipboardComposer(); + const event = { + clipboardData: {files: [], items: [], types: ['text/plain', 'text/html']}, + preventDefault: vi.fn(), + } as never as ClipboardEvent; + + component.handlePaste(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); + expect(alerts.error).not.toHaveBeenCalled(); + }); + + // Attachments are staged and only post on Send, so removing one is how a + // student backs out of it. + it('does not post an attachment that is removed before Send', () => { + const harness = createComposer(); + const image = new File(['image'], 'cancelled.png', {type: 'image/png'}); + harness.component.uploadFiles([image]); + const [staged] = harness.component.stagedAttachments; + + harness.component.removeStagedAttachment(staged.clientRequestId); + harness.component.addComment(); + + expect(harness.component.stagedAttachments).toEqual([]); + expect(harness.taskCommentService.uploadStagedAttachment).not.toHaveBeenCalled(); + expect(harness.taskCommentService.addComment).not.toHaveBeenCalled(); + }); + + it('shows the API failure when a staged image cannot be posted', () => { + const harness = createComposer(); + harness.taskCommentService.uploadStagedAttachment.mockReturnValue( + throwError(() => new Error('Upload failed')), + ); + harness.component.uploadFiles([new File(['image'], 'failed.png', {type: 'image/png'})]); + + harness.component.addComment(); + + expect(harness.component.stagedAttachments[0]).toMatchObject({ + status: 'failed', + error: 'Upload failed', + }); + expect(harness.alerts.error).toHaveBeenCalledWith( + expect.stringContaining('could not be sent'), + 6000, + ); + }); +}); + +describe('server-owned chat attachment policy', () => { + const policy: AttachmentPolicy = { + version: 1, + max_bytes_exclusive: 30_000_000, + max_selection_count: 5, + categories: [ + {id: 'document', name: 'Document', extensions: ['docx'], preview: 'download'}, + {id: 'spreadsheet', name: 'Spreadsheet', extensions: ['csv', 'xlsx'], preview: 'download'}, + ], + }; + + beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage(), + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: memoryStorage(), + }); + }); + + it('shows exact requirements and stages DOCX, CSV and XLSX even with empty browser MIME', () => { + const harness = createComposer(); + harness.component.attachmentPolicy = policy; + harness.component.uploadFiles( + ['report.DOCX', 'results.csv', 'results.xlsx'].map((name) => new File(['data'], name)), + ); + expect(harness.component.stagedAttachments.map((item) => item.fileName)).toEqual([ + 'report.DOCX', + 'results.csv', + 'results.xlsx', + ]); + expect(harness.taskCommentService.uploadStagedAttachment).not.toHaveBeenCalled(); + expect(harness.component.attachmentAccept).toBe('.docx,.csv,.xlsx'); + expect(harness.component.attachmentGuidance).toContain('smaller than 30 MB'); + expect(harness.component.attachmentCategoryName(harness.component.stagedAttachments[1])).toBe( + 'Spreadsheet', + ); + expect(harness.alerts.error).not.toHaveBeenCalled(); + }); + + it('rejects excluded formats, empty files, exact boundary and excessive selection before staging', () => { + const harness = createComposer(); + harness.component.attachmentPolicy = policy; + const boundary = new File(['data'], 'large.csv'); + Object.defineProperty(boundary, 'size', {value: 30_000_000}); + harness.component.uploadFiles([ + new File(['x'], 'macro.xlsm'), + new File([], 'empty.csv'), + boundary, + ]); + harness.component.uploadFiles(Array.from({length: 6}, () => new File(['x'], 'a.csv'))); + expect(harness.component.stagedAttachments).toEqual([]); + expect(harness.alerts.error).toHaveBeenCalledTimes(4); + }); + + it('fails closed when policy cannot be loaded and preserves a draft on proxy 413', () => { + const harness = createComposer(); + harness.component.attachmentPolicy = null; + harness.message.value = 'My draft'; + harness.component.uploadFiles([new File(['x'], 'a.csv')]); + expect(harness.alerts.error).toHaveBeenCalledWith(expect.stringContaining('unavailable'), 6000); + expect(harness.component.stagedAttachments).toEqual([]); + + harness.component.attachmentPolicy = policy; + harness.component.uploadFiles([new File(['x'], 'a.csv')]); + harness.taskCommentService.uploadStagedAttachment.mockReturnValue( + throwError(() => ({status: 413, error: 'Request too large'})), + ); + harness.component.addComment(); + + expect(harness.message.value).toBe('My draft'); + expect(harness.component.attachmentsUploading).toBe(0); + expect(harness.component.stagedAttachments[0].error).toContain('too large'); + expect(harness.taskCommentService.addComment).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html index 00af98e249..8224396ce2 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.html @@ -1,32 +1,83 @@ -
      - @if (comment.assessed) { -
      -
      -

      reason: {{ comment.text }}

      +
      +
      + +
      +
      + {{ + comment.automated ? 'Extended automatically' : 'Extension request' + }} + {{ comment.weeksRequested }} {{ comment.weeksRequested === 1 ? 'week' : 'weeks' }} +
      + + + @if (comment.automated) { + By {{ externalName | async }} + } @else { + {{ comment.author?.name }} + } + @if (comment.createdAt) { + · {{ comment.createdAt | date: 'd MMM y' }} + } +
      - } +
      + +
      + {{ comment.automated ? 'Why' : 'Reason' }} + +
      +
      - @if (!comment.assessed) { -
      -
      -

      - {{ message }}
      - reason: {{ comment.text }} -

      - @if (isNotStudent) { -
      - - -
      + @if (comment.assessed) { + + } @else if (isNotStudent) { + + } @else { + } -
      +
      diff --git a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts index 4db00f9864..59928eadf2 100644 --- a/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/extension-comment/extension-comment.component.ts @@ -2,6 +2,7 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; import {Task, TaskComment} from 'src/app/api/models/doubtfire-model'; import {ExtensionComment} from 'src/app/api/models/task-comment/extension-comment'; import {AlertService} from 'src/app/common/services/alert.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; @Component({ selector: 'extension-comment', @@ -13,15 +14,21 @@ import {AlertService} from 'src/app/common/services/alert.service'; export class ExtensionCommentComponent { @Input() comment: ExtensionComment; @Input() task: Task; + public busy = false; + /** What this deployment calls itself, for an automated extension's byline. */ + public readonly externalName = this.constants.ExternalName; - constructor(private alerts: AlertService) {} + constructor( + private alerts: AlertService, + private constants: DoubtfireConstants, + ) {} private handleError(error: {data: {error: string}}) { this.alerts.error('Error: ' + error.data.error, 6000); } get message() { - const studentName = this.comment.author.name; + const studentName = this.comment.author.displayName; if (this.comment.assessed) { return this.comment.extensionResponse; } @@ -41,22 +48,28 @@ export class ExtensionCommentComponent { } denyExtension() { + this.busy = true; this.comment.deny().subscribe({ next: (_tc: TaskComment) => { + this.busy = false; this.alerts.success('Extension updated', 2000); }, error: (response) => { + this.busy = false; this.handleError(response); }, }); } grantExtension() { + this.busy = true; this.comment.grant().subscribe({ next: (_tc: TaskComment) => { + this.busy = false; this.alerts.success('Extension updated', 2000); }, error: (response) => { + this.busy = false; this.handleError(response); }, }); diff --git a/src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-dialog.spec.ts b/src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-dialog.spec.ts new file mode 100644 index 0000000000..c42c49fe6e --- /dev/null +++ b/src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-dialog.spec.ts @@ -0,0 +1,181 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {MatDialogRef} from '@angular/material/dialog'; +import {Router} from '@angular/router'; +import {Subject} from 'rxjs'; +import {DiscussionComment, Task} from 'src/app/api/models/doubtfire-model'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {AppLifecycleService} from 'src/app/common/services/app-lifecycle.service'; +import {IntelligentDiscussionDialog} from './intelligent-discussion-player.component'; +import {IntelligentDiscussionPlayerService} from './intelligent-discussion-player.service'; +import {IntelligentDiscussionRecorderComponent} from './intelligent-discussion-recorder/intelligent-discussion-recorder.component'; + +describe('IntelligentDiscussionDialog', () => { + let lifecycle: AppLifecycleService; + let visibilityState: DocumentVisibilityState; + let originalVisibilityState: PropertyDescriptor | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + visibilityState = 'visible'; + originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState'); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => visibilityState, + }); + lifecycle = new AppLifecycleService(document, { + events: new Subject().asObservable(), + } as unknown as Router); + lifecycle.start(); + }); + + afterEach(() => { + lifecycle.ngOnDestroy(); + if (originalVisibilityState) { + Object.defineProperty(document, 'visibilityState', originalVisibilityState); + } else { + delete (document as Document & {visibilityState?: DocumentVisibilityState}).visibilityState; + } + vi.useRealTimers(); + }); + + function hideApp(): void { + visibilityState = 'hidden'; + document.dispatchEvent(new Event('visibilitychange')); + } + + it('keeps the prompt and the recording going when the window only loses focus', () => { + const {dialog, audio, recorder} = setupDialog(lifecycle); + startAndReachFirstPrompt(dialog); + audio.pause.mockClear(); + + window.dispatchEvent(new Event('blur')); + + expect(audio.pause).not.toHaveBeenCalled(); + expect(dialog.promptPlaying).toBe(true); + expect(dialog.startedDiscussion).toBe(true); + expect(recorder.stopRecording).not.toHaveBeenCalled(); + expect(recorder.cancelRecording).not.toHaveBeenCalled(); + }); + + it('abandons a backgrounded discussion without submitting it and lets the student start again', () => { + const {dialog, audio, recorder, discussion} = setupDialog(lifecycle); + startAndReachFirstPrompt(dialog); + + hideApp(); + + expect(recorder.stopRecording).not.toHaveBeenCalled(); + expect(recorder.cancelRecording).toHaveBeenCalled(); + expect(audio.pause).toHaveBeenCalled(); + expect(dialog.startedDiscussion).toBe(false); + expect(dialog.inDiscussion).toBe(false); + expect(dialog.promptPlaying).toBe(false); + expect(dialog.responseRecording).toBe(false); + expect(dialog.discussionComplete).toBe(false); + expect(discussion.status).toBe('not_started'); + expect(dialog.discussionStatusTitle).toBe('Discussion interrupted'); + + visibilityState = 'visible'; + document.dispatchEvent(new Event('visibilitychange')); + startAndReachFirstPrompt(dialog); + + expect(recorder.startRecording).toHaveBeenCalledTimes(2); + expect(dialog.activePromptId).toBe(0); + expect(dialog.promptPlaying).toBe(true); + expect(dialog.discussionStatusTitle).toBe('Listening to prompt 1'); + }); + + it('abandons a discussion that is backgrounded during the countdown', () => { + const {dialog, recorder} = setupDialog(lifecycle); + dialog.startDiscussion(); + vi.advanceTimersByTime(1000); + + hideApp(); + vi.advanceTimersByTime(5000); + + expect(dialog.startedDiscussion).toBe(false); + expect(dialog.countdownValue).toBeNull(); + expect(recorder.startRecording).not.toHaveBeenCalled(); + }); + + it('ignores a prompt download that finishes after the discussion was abandoned', () => { + const {dialog, audio, fileDownloader} = setupDialog(lifecycle, {deferDownloads: true}); + dialog.startDiscussion(); + vi.advanceTimersByTime(3000); + const onDownloaded = fileDownloader.downloadBlob.mock.calls[0][1] as (url: string) => void; + + hideApp(); + onDownloaded('blob:late-prompt'); + + expect(audio.play).not.toHaveBeenCalled(); + expect(dialog.promptPlaying).toBe(false); + expect(fileDownloader.releaseBlob).toHaveBeenCalledWith('blob:late-prompt'); + }); + + it('does not cancel the upload of a finished discussion', () => { + const {dialog, recorder} = setupDialog(lifecycle); + startAndReachFirstPrompt(dialog); + dialog.finishDiscussion(); + + hideApp(); + + expect(recorder.stopRecording).toHaveBeenCalledOnce(); + expect(recorder.cancelRecording).not.toHaveBeenCalled(); + expect(dialog.discussionComplete).toBe(true); + }); +}); + +function startAndReachFirstPrompt(dialog: IntelligentDiscussionDialog): void { + dialog.startDiscussion(); + vi.advanceTimersByTime(3000); +} + +function setupDialog(lifecycle: AppLifecycleService, options: {deferDownloads?: boolean} = {}) { + const audio = { + src: '', + currentTime: 0, + paused: true, + onended: null as (() => void) | null, + load: vi.fn(), + play: vi.fn(() => { + audio.paused = false; + return Promise.resolve(); + }), + pause: vi.fn(() => { + audio.paused = true; + }), + }; + const fileDownloader = { + downloadBlob: vi.fn((url: string, onSuccess: (blobUrl: string) => void) => { + if (!options.deferDownloads) { + onSuccess(`blob:${url}`); + } + }), + releaseBlob: vi.fn(), + }; + const discussion = { + id: 12, + status: 'not_started', + numberOfPrompts: 2, + generateDiscussionPromptUrl: (promptNumber: number) => `/discussion/prompt/${promptNumber}`, + }; + const dialog = new IntelligentDiscussionDialog( + {close: vi.fn()} as unknown as MatDialogRef, + {} as IntelligentDiscussionPlayerService, + fileDownloader as unknown as FileDownloaderService, + {error: vi.fn()} as unknown as AlertService, + lifecycle, + { + dc: discussion as unknown as DiscussionComment, + task: {} as Task, + audioRef: audio as unknown as HTMLAudioElement, + }, + ); + const recorder = { + startRecording: vi.fn(), + stopRecording: vi.fn(), + cancelRecording: vi.fn(), + }; + dialog.discussionRecorder = recorder as unknown as IntelligentDiscussionRecorderComponent; + return {dialog, audio, recorder, fileDownloader, discussion}; +} diff --git a/src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-player.component.ts b/src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-player.component.ts index cbdb8469d9..e92bd438eb 100644 --- a/src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-player.component.ts +++ b/src/app/tasks/task-comments-viewer/intelligent-discussion-player/intelligent-discussion-player.component.ts @@ -14,6 +14,7 @@ import {AudioPlayerComponent} from 'src/app/common/audio-player/audio-player.com import {MicrophoneTesterComponent} from 'src/app/common/audio-recorder/audio/microphone-tester/microphone-tester.component'; import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; import {AlertService} from 'src/app/common/services/alert.service'; +import {AppLifecycleService} from 'src/app/common/services/app-lifecycle.service'; import {IntelligentDiscussionPlayerService} from './intelligent-discussion-player.service'; import {IntelligentDiscussionRecorderComponent} from './intelligent-discussion-recorder/intelligent-discussion-recorder.component'; @@ -130,7 +131,7 @@ export class IntelligentDiscussionPlayerComponent { } // The Dialog Component -// eslint-disable-next-line max-classes-per-file + @Component({ selector: 'intelligent-discussion-dialog', templateUrl: 'intelligent-discussion-dialog.html', @@ -146,6 +147,7 @@ export class IntelligentDiscussionDialog implements OnDestroy { startedDiscussion = false; inDiscussion = false; discussionComplete: boolean = false; + discussionInterrupted = false; promptLoading = false; promptPlaying = false; responseRecording = false; @@ -156,6 +158,9 @@ export class IntelligentDiscussionDialog implements OnDestroy { private countdownTimer: ReturnType; guide = {text: 'Click start to begin'}; private promptBlobUrl: string; + private promptRequest = 0; + private readonly unregisterMedia: () => void; + private readonly lifecycleSubscription: Subscription; @ViewChild('testRecorder', {static: true}) testRecorder: MicrophoneTesterComponent; @ViewChild('discussionRecorder', {static: true}) @@ -166,18 +171,26 @@ export class IntelligentDiscussionDialog implements OnDestroy { private discussionService: IntelligentDiscussionPlayerService, private fileDownloader: FileDownloaderService, private alerts: AlertService, + private appLifecycle: AppLifecycleService, @Inject(MAT_DIALOG_DATA) public data: { dc: DiscussionComment; task: Task; audioRef: HTMLAudioElement; }, - ) {} + ) { + this.unregisterMedia = this.appLifecycle.registerMedia(this.data.audioRef); + this.lifecycleSubscription = this.appLifecycle.mediaPauseSubject.subscribe(() => + this.abandonDiscussion(), + ); + } ngOnDestroy(): void { this.counter?.unsubscribe(); this.clearCountdown(); this.data.audioRef.pause(); + this.unregisterMedia(); + this.lifecycleSubscription.unsubscribe(); this.releasePromptBlob(); } @@ -202,7 +215,7 @@ export class IntelligentDiscussionDialog implements OnDestroy { return 'Starting discussion'; } if (!this.startedDiscussion) { - return 'Ready when you are'; + return this.discussionInterrupted ? 'Discussion interrupted' : 'Ready when you are'; } if (this.promptLoading) { return 'Loading prompt'; @@ -224,7 +237,9 @@ export class IntelligentDiscussionDialog implements OnDestroy { return 'Get ready. Recording will begin when the countdown finishes.'; } if (!this.startedDiscussion) { - return 'When you start, your microphone will begin recording and the first prompt will play.'; + return this.discussionInterrupted + ? 'The discussion stopped when you left this page, so nothing was submitted. Start again when you are ready.' + : 'When you start, your microphone will begin recording and the first prompt will play.'; } if (this.promptLoading) { return 'Getting the next tutor prompt ready.'; @@ -259,6 +274,7 @@ export class IntelligentDiscussionDialog implements OnDestroy { startDiscussion() { if (!this.startedDiscussion) { this.startedDiscussion = true; + this.discussionInterrupted = false; this.inDiscussion = true; this.startCountdown(); } @@ -312,6 +328,7 @@ export class IntelligentDiscussionDialog implements OnDestroy { } setPrompt() { + const request = ++this.promptRequest; this.promptLoading = true; this.promptPlaying = false; this.responseRecording = false; @@ -322,6 +339,10 @@ export class IntelligentDiscussionDialog implements OnDestroy { this.fileDownloader.downloadBlob( this.data.dc.generateDiscussionPromptUrl(this.activePromptId), (blobUrl) => { + if (request !== this.promptRequest) { + this.fileDownloader.releaseBlob(blobUrl); + return; + } this.promptBlobUrl = blobUrl; this.data.audioRef.src = blobUrl; this.guide.text = 'Listening to prompt'; @@ -332,14 +353,20 @@ export class IntelligentDiscussionDialog implements OnDestroy { this.data.audioRef.onended = () => { this.promptPlaying = false; const audio = new Audio(); + const unregisterSignal = this.appLifecycle.registerMedia(audio); audio.src = '/assets/sounds/discussion-start-signal.wav'; audio.load(); + audio.onended = unregisterSignal; + audio.onpause = unregisterSignal; audio.play(); this.guide.text = 'Start responding'; this.responseRecording = true; }; }, (error) => { + if (request !== this.promptRequest) { + return; + } this.promptLoading = false; this.promptPlaying = false; this.responseRecording = false; @@ -358,6 +385,33 @@ export class IntelligentDiscussionDialog implements OnDestroy { } } + // Backgrounding pauses the prompt and the lifecycle service never resumes it, + // so the prompt's onended would never fire and the dialog would be stuck. + // Posting the take would also submit part of an answer as the finished reply. + // So an unfinished attempt is dropped and the student starts it again. + private abandonDiscussion(): void { + if (!this.startedDiscussion || this.discussionComplete) { + return; + } + this.promptRequest++; + this.discussionRecorder.cancelRecording(); + this.clearCountdown(); + this.counter?.unsubscribe(); + this.data.audioRef.onended = null; + this.data.audioRef.pause(); + this.data.audioRef.currentTime = 0; + this.releasePromptBlob(); + this.startedDiscussion = false; + this.inDiscussion = false; + this.promptLoading = false; + this.promptPlaying = false; + this.responseRecording = false; + this.activePromptId = 0; + this.timerText = '15m:00s'; + this.discussionInterrupted = true; + this.guide = {text: 'Discussion interrupted'}; + } + private releasePromptBlob(): void { if (this.promptBlobUrl) { this.fileDownloader.releaseBlob(this.promptBlobUrl); diff --git a/src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.component.html b/src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.component.html index 9f759eed40..d3d5d2efe0 100644 --- a/src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.component.html +++ b/src/app/tasks/task-comments-viewer/pdf-image-comment/pdf-image-comment.component.html @@ -1,10 +1,22 @@ - - @if (comment.commentType === 'image' && resourceUrl) { - Image attachment preview - } - @if (comment.commentType === 'pdf') { -
      - picture_as_pdf view pdf -
      - } -
      +@if (comment.commentType === 'image') { + +} @else if (comment.commentType === 'pdf') { + +} diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts index ea963d9b00..3a69e96b9a 100644 --- a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts @@ -20,7 +20,7 @@ export class ScormExtensionCommentComponent { } get message() { - const studentName = this.comment.author.name; + const studentName = this.comment.author.displayName; if (this.comment.assessed && this.comment.granted) { return 'Extra attempt granted.'; } else if (this.comment.assessed && !this.comment.granted) { diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.spec.ts b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.spec.ts index 908c92e587..c1954c141c 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.spec.ts +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.spec.ts @@ -1,16 +1,31 @@ -import {beforeEach, describe, expect, it} from 'vitest'; -import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ElementRef, NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {EMPTY} from 'rxjs'; -import {TaskCommentService, TaskService, UserService} from 'src/app/api/models/doubtfire-model'; +import {EMPTY, Subject} from 'rxjs'; +import { + Project, + Task, + TaskComment, + TaskCommentService, + TaskService, + UserService, +} from 'src/app/api/models/doubtfire-model'; import {FeedbackTemplateService} from 'src/app/api/services/feedback-template.service'; import {CommentsModalService} from 'src/app/common/modals/comments-modal/comments-modal.service'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {HumanizedDatePipe} from 'src/app/common/pipes/humanized-date.pipe'; +import {LocalizedDatePipe} from 'src/app/common/pipes/localized-date.pipe'; +import {MarkedPipe} from 'src/app/common/pipes/marked.pipe'; import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {CommentBubbleActionComponent} from './comment-bubble-action/comment-bubble-action.component'; +import {ConversationLandingService} from './conversation-landing.service'; import {TaskCommentsViewerComponent} from './task-comments-viewer.component'; const taskCommentServiceStub = { commentAdded$: EMPTY, + fetchAll: vi.fn(), + downloadAttachment: vi.fn(), }; const taskServiceStub = { taskStatusUpdated$: EMPTY, @@ -44,7 +59,432 @@ describe('TaskCommentsViewerComponent', () => { component = fixture.componentInstance; }); + it('routes drag and drop through the composer confirmation and policy flow', () => { + component.commentComposer = {uploadFiles: vi.fn()}; + const files = [new File(['a,b'], 'results.csv')]; + component.uploadFiles(files); + expect(component.commentComposer.uploadFiles).toHaveBeenCalledWith(files); + }); + it('should create', () => { expect(component).toBeTruthy(); }); + + it('releases phone feedback scrolling without dropping attachment-card sizing', () => { + const styles = ( + TaskCommentsViewerComponent as unknown as { + ɵcmp: {styles: string[]}; + } + ).ɵcmp.styles.join('\n'); + + expect(styles).toMatch( + /@media\s*\(max-width:\s*639\.98px\)[\s\S]*?\.comments-body[^{]*\{[^}]*overflow-y:\s*visible/, + ); + expect(styles).toMatch(/\.comment[^{]*\.document-bubble[^{]*\{[^}]*max-width:/); + }); + + it('routes dropped files to the composer for staging instead of posting them', () => { + const uploadFiles = vi.fn(); + const files = [ + new File(['document'], 'feedback.DOCX', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }), + ]; + component.commentComposer = {uploadFiles}; + + component.uploadFiles(files); + + expect(uploadFiles).toHaveBeenCalledOnce(); + expect(uploadFiles).toHaveBeenCalledWith(files); + expect(taskCommentServiceStub).not.toHaveProperty('addComment'); + }); + + it('does not throw when a reply target has already been deleted', () => { + expect(() => component.scrollToComment(undefined)).not.toThrow(); + expect(() => component.scrollToComment(404)).not.toThrow(); + }); + + it('removes every stale cached comment while reconciling a response', () => { + const task = new Task(); + const comments = [1, 2, 3].map((id) => ({id}) as TaskComment); + comments.forEach((comment) => task.commentCache.add(comment)); + vi.spyOn(task, 'refreshCommentData').mockImplementation(() => {}); + + ( + component as unknown as { + mapComments(task: Task, comments: TaskComment[]): void; + } + ).mapComments(task, [comments[2]]); + + expect(task.comments.map((comment) => comment.id)).toEqual([3]); + }); +}); + +describe('TaskCommentsViewerComponent bubble actions', () => { + let component: TaskCommentsViewerComponent; + let fixture: ComponentFixture; + let comment: Record; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + TaskCommentsViewerComponent, + CommentBubbleActionComponent, + HumanizedDatePipe, + LocalizedDatePipe, + MarkedPipe, + ], + providers: [ + {provide: TaskCommentService, useValue: taskCommentServiceStub}, + {provide: FeedbackTemplateService, useValue: emptyProvider}, + {provide: UserService, useValue: emptyProvider}, + {provide: TaskService, useValue: taskServiceStub}, + {provide: DoubtfireConstants, useValue: {IsOverseerEnabled: {value: false}}}, + {provide: CommentsModalService, useValue: emptyProvider}, + {provide: ConfirmationModalService, useValue: emptyProvider}, + {provide: AlertService, useValue: emptyProvider}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TaskCommentsViewerComponent); + component = fixture.componentInstance; + + comment = { + id: 11, + commentType: 'text', + text: 'a comment', + replyToId: null, + isNew: false, + isBubbleComment: true, + authorIsMe: false, + shouldShowTimestamp: false, + shouldShowAvatar: false, + firstInSeries: false, + lastRead: false, + createdAt: new Date(), + author: { + preferredName: 'Ada', + lastName: 'Lovelace', + name: 'Ada Lovelace', + displayName: 'Ada Lovelace', + }, + }; + component.task = {comments: [comment], scormEnabled: false} as never; + + fixture.detectChanges(); + component.loading = false; + fixture.detectChanges(); + }); + + it('shows generic attachment metadata as text and downloads through the authorized service', () => { + Object.assign(comment, { + commentType: 'spreadsheet', + attachmentFileName: '.xlsx', + attachmentByteSize: 1024, + }); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector( + 'button[aria-label^="Download "]', + ) as HTMLButtonElement; + expect(button).toBeTruthy(); + expect(button.getAttribute('aria-label')).toContain('.xlsx'); + expect(fixture.nativeElement.textContent).toContain('Download only'); + expect(fixture.nativeElement.querySelector('img, iframe, object, embed')).toBeNull(); + button.click(); + expect(taskCommentServiceStub.downloadAttachment).toHaveBeenCalledWith(comment); + }); + + it('renders accessible empty-state text from the real template after loading', () => { + component.task = {comments: [], scormEnabled: false} as never; + fixture.detectChanges(); + + const icon = fixture.nativeElement.querySelector('#noView') as HTMLElement; + expect(icon.getAttribute('aria-hidden')).toBe('true'); + expect(icon.parentElement.getAttribute('role')).toBe('status'); + expect(icon.parentElement.textContent).toContain('No comments on this task yet.'); + }); + + it('does not claim the comments are empty while they are loading', () => { + component.task = {comments: [], scormEnabled: false} as never; + component.loading = true; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('#noView')).toBeNull(); + }); + + it('renders the empty state safely when no task is selected', () => { + component.task = undefined; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain( + 'No comments on this task yet.', + ); + }); + + it('omits the empty state when the task has a comment', () => { + expect(fixture.nativeElement.querySelector('#noView')).toBeNull(); + }); + + function anchor(): HTMLElement { + return fixture.nativeElement.querySelector('.anchor') as HTMLElement; + } + + function replyButton(): HTMLButtonElement { + return anchor().querySelector( + '.comment-overflow comment-bubble-action button', + ) as HTMLButtonElement; + } + + it('renders the comment actions instead of gating them behind a pointer flag', () => { + const actions = fixture.nativeElement.querySelector('comment-bubble-action') as HTMLElement; + + expect(actions).toBeTruthy(); + expect(actions.hasAttribute('hidden')).toBe(false); + }); + + it('builds the actions out of real buttons rather than bare icons', () => { + const buttons = anchor().querySelectorAll('comment-bubble-action button'); + + expect(buttons.length).toBe(3); + expect(replyButton().getAttribute('aria-label')).toBe('Reply to this comment'); + }); + + it('does not meet the reveal condition while nothing in the comment has focus', () => { + expect(anchor().matches(':focus-within')).toBe(false); + }); + + it('meets the reveal condition once the keyboard reaches the actions', () => { + replyButton().focus(); + + expect(document.activeElement).toBe(replyButton()); + expect(anchor().matches(':focus-within')).toBe(true); + }); + + it('replies to the comment when the focused button is activated', () => { + replyButton().focus(); + replyButton().click(); + + expect(component.sharedCommentComposerData.originalComment).toBe(comment); + }); +}); + +describe('TaskCommentsViewerComponent latest-message landing', () => { + let frameCallbacks: FrameRequestCallback[]; + + beforeEach(() => { + frameCallbacks = []; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback); + return frameCallbacks.length; + }), + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function flushFrames(): void { + while (frameCallbacks.length) { + frameCallbacks.shift()?.(performance.now()); + } + } + + function taskFor(taskDefinitionId: number): Task { + const project = { + id: 7, + unit: {currentUserIsStaff: false}, + } as unknown as Project; + // The production constructor receives a real Project instance. This focused + // test uses a minimal structural project, so attach it explicitly rather + // than relying on Task's instanceof guard. + const task = new Task(); + task.project = project; + task.definition = {id: taskDefinitionId} as never; + return task; + } + + function createViewer( + fetchAll: ReturnType, + conversationLanding: ConversationLandingService, + query: ReturnType = vi.fn(), + ): { + viewer: TaskCommentsViewerComponent; + commentsBody: HTMLElement; + conversationEnd: HTMLElement; + commentsFooter: HTMLElement; + } { + const taskCommentService = { + commentAdded$: new Subject(), + fetchAll, + query, + }; + const taskService = {taskStatusUpdated$: new Subject()}; + const viewer = new TaskCommentsViewerComponent( + taskCommentService as unknown as TaskCommentService, + {} as FeedbackTemplateService, + {} as UserService, + taskService as unknown as TaskService, + {IsOverseerEnabled: {value: false}} as DoubtfireConstants, + {} as CommentsModalService, + {} as AlertService, + conversationLanding, + ); + + const commentsBody = {scrollTop: 0, scrollHeight: 960} as HTMLElement; + const conversationEnd = {scrollIntoView: vi.fn()} as unknown as HTMLElement; + const commentsFooter = {scrollIntoView: vi.fn()} as unknown as HTMLElement; + viewer.commentsBody = new ElementRef(commentsBody); + viewer.conversationEnd = new ElementRef(conversationEnd); + viewer.commentsFooter = new ElementRef(commentsFooter); + viewer.ngAfterViewInit(); + + return {viewer, commentsBody, conversationEnd, commentsFooter}; + } + + function loadTask(viewer: TaskCommentsViewerComponent, task: Task): void { + viewer.task = task; + viewer.project = task.project; + viewer.ngOnChanges({ + task: { + currentValue: task, + previousValue: null, + firstChange: true, + isFirstChange: () => true, + }, + }); + } + + it('retains an early request until fresh history is rendered, then reveals without focusing', () => { + const history$: Subject = new Subject(); + const landing = new ConversationLandingService(); + const task = taskFor(11); + const request = landing.requestLatestMessages({projectId: 7, taskDefinitionId: 11}); + const {viewer, commentsBody, conversationEnd, commentsFooter} = createViewer( + vi.fn(() => history$), + landing, + ); + const focusKeeper = document.createElement('button'); + document.body.appendChild(focusKeeper); + focusKeeper.focus(); + + loadTask(viewer, task); + flushFrames(); + + expect(commentsBody.scrollTop).toBe(0); + expect(landing.pendingFor(taskDefinitionTarget(task))).toBe(request); + + history$.next([]); + flushFrames(); + + expect(commentsBody.scrollTop).toBe(960); + expect(conversationEnd.scrollIntoView).toHaveBeenCalledWith({ + block: 'end', + inline: 'nearest', + }); + expect(commentsFooter.scrollIntoView).toHaveBeenCalledWith({ + block: 'nearest', + inline: 'nearest', + }); + expect(document.activeElement).toBe(focusKeeper); + expect(landing.pendingFor(taskDefinitionTarget(task))).toBeNull(); + + focusKeeper.remove(); + viewer.ngOnDestroy(); + }); + + it('waits for the authoritative response instead of fulfilling from a stale cache', () => { + const cached$: Subject = new Subject(); + const fresh$: Subject = new Subject(); + const landing = new ConversationLandingService(); + const task = taskFor(11); + task.commentCache.add({id: 1} as TaskComment); + landing.requestLatestMessages(taskDefinitionTarget(task)); + const {viewer, commentsBody} = createViewer( + vi.fn(() => fresh$), + landing, + vi.fn(() => cached$), + ); + Reflect.set(viewer, 'mapComments', vi.fn()); + + loadTask(viewer, task); + cached$.next([{id: 1} as TaskComment]); + flushFrames(); + + expect(commentsBody.scrollTop).toBe(0); + expect(landing.pendingFor(taskDefinitionTarget(task))).not.toBeNull(); + + fresh$.next([{id: 1} as TaskComment]); + flushFrames(); + + expect(commentsBody.scrollTop).toBe(960); + expect(landing.pendingFor(taskDefinitionTarget(task))).toBeNull(); + + viewer.ngOnDestroy(); + }); + + it('refreshes an already-mounted conversation before answering a same-route landing request', () => { + const history$: Subject = new Subject(); + const landing = new ConversationLandingService(); + const task = taskFor(11); + const fetchAll = vi.fn(() => history$); + const {viewer, commentsBody} = createViewer(fetchAll, landing); + + loadTask(viewer, task); + history$.next([]); + flushFrames(); + commentsBody.scrollTop = 0; + + landing.requestLatestMessages(taskDefinitionTarget(task)); + flushFrames(); + + expect(fetchAll).toHaveBeenCalledTimes(2); + expect(commentsBody.scrollTop).toBe(0); + expect(landing.pendingFor(taskDefinitionTarget(task))).not.toBeNull(); + + history$.next([]); + flushFrames(); + + expect(commentsBody.scrollTop).toBe(960); + expect(landing.pendingFor(taskDefinitionTarget(task))).toBeNull(); + + viewer.ngOnDestroy(); + }); + + it('does not let a cancelled task request reveal the next task', () => { + const firstHistory$: Subject = new Subject(); + const secondHistory$: Subject = new Subject(); + const landing = new ConversationLandingService(); + const firstTask = taskFor(11); + const secondTask = taskFor(12); + const fetchAll = vi + .fn() + .mockImplementationOnce(() => firstHistory$) + .mockImplementationOnce(() => secondHistory$); + const {viewer, commentsBody} = createViewer(fetchAll, landing); + + loadTask(viewer, firstTask); + loadTask(viewer, secondTask); + firstHistory$.next([]); + flushFrames(); + + expect(commentsBody.scrollTop).toBe(0); + + secondHistory$.next([]); + flushFrames(); + + expect(commentsBody.scrollTop).toBe(960); + + viewer.ngOnDestroy(); + }); + + function taskDefinitionTarget(task: Task): {projectId: number; taskDefinitionId: number} { + return {projectId: task.project.id, taskDefinitionId: task.definition.id}; + } }); diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts index 40b1fe4522..22c65158f9 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts @@ -1,4 +1,5 @@ import { + AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, @@ -26,6 +27,11 @@ import {CommentsModalService} from 'src/app/common/modals/comments-modal/comment import {AlertService} from 'src/app/common/services/alert.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {TaskCommentComposerData} from '../task-comment-composer/task-comment-composer.component'; +import { + ConversationLandingRequest, + ConversationLandingService, + ConversationLandingTarget, +} from './conversation-landing.service'; import {TaskAssessmentComment} from './task-assessment-comment/task-assessment-comment.component'; @Component({ @@ -35,9 +41,12 @@ import {TaskAssessmentComment} from './task-assessment-comment/task-assessment-c changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class TaskCommentsViewerComponent implements OnChanges, OnDestroy { +export class TaskCommentsViewerComponent implements AfterViewInit, OnChanges, OnDestroy { // Get the comments body from the HTML template - @ViewChild('commentsBody') commentsBody: ElementRef; + @ViewChild('commentsBody') commentsBody?: ElementRef; + @ViewChild('conversationEnd') conversationEnd?: ElementRef; + @ViewChild('commentsFooter') commentsFooter?: ElementRef; + @ViewChild('commentComposer') commentComposer?: {uploadFiles(files: ArrayLike): void}; lastComment: TaskComment; @Input() project: Project; @@ -54,6 +63,14 @@ export class TaskCommentsViewerComponent implements OnChanges, OnDestroy { private taskStatusSub: Subscription; private commentAddedSub: Subscription; + private landingRequestSub: Subscription; + private commentsRequestSub: Subscription = new Subscription(); + private commentLoadGeneration = 0; + private freshHistoryReady = false; + private viewInitialised = false; + private revealLatestRequested = false; + private revealFrame: number | null = null; + private settleFrame: number | null = null; constructor( private taskCommentService: TaskCommentService, @@ -63,21 +80,45 @@ export class TaskCommentsViewerComponent implements OnChanges, OnDestroy { private constants: DoubtfireConstants, private commentsModalRef: CommentsModalService, private alerts: AlertService, + private conversationLanding: ConversationLandingService, ) { - this.commentAddedSub = this.taskCommentService.commentAdded$.subscribe((_tc: TaskComment) => { - this.scrollDown(); - }); + this.commentAddedSub = this.taskCommentService.commentAdded$.subscribe( + (comment: TaskComment) => { + if (this.commentBelongsToCurrentConversation(comment)) { + this.revealLatestMessagesAndComposer(); + } + }, + ); this.taskStatusSub = this.taskService.taskStatusUpdated$.subscribe((task) => { - if (task && task.project && this.project && task.project.id === this.project.id) { + if (this.isCurrentTask(task)) { this.fetchComments(task, false); } }); + + this.landingRequestSub = this.conversationLanding.requests$.subscribe((request) => { + if (this.requestBelongsToCurrentConversation(request)) { + // A notification can arrive while this exact conversation is already + // open. Its previously fetched history was authoritative then, but it + // cannot contain the new server-side message. Reuse the Batch 02 load + // and reveal boundary with a fresh request instead of scrolling stale + // rows or guessing a delay here in the routing layer. + this.fetchComments(this.task, false); + } + }); + } + + ngAfterViewInit(): void { + this.viewInitialised = true; + this.tryRevealLatestMessagesAndComposer(); } ngOnDestroy(): void { this.taskStatusSub?.unsubscribe(); this.commentAddedSub?.unsubscribe(); + this.landingRequestSub?.unsubscribe(); + this.commentsRequestSub.unsubscribe(); + this.cancelRevealFrames(); } public asAssessmentComment(comment: TaskComment): TaskAssessmentComment | null { @@ -106,13 +147,41 @@ export class TaskCommentsViewerComponent implements OnChanges, OnDestroy { // Must have project for task to be mapped if (changes.task?.currentValue?.project != null) { this.project = changes.task.currentValue.project; + this.loading = true; this.fetchComments(this.task, true, true); } else { + this.cancelCommentLoad(); this.loading = false; } } - fetchComments(task: Task, useCache: boolean = true, fetchAfterCache: boolean = false) { + public fetchComments( + task: Task, + useCache: boolean = true, + fetchAfterCache: boolean = false, + ): void { + this.cancelCommentLoad(); + const generation = ++this.commentLoadGeneration; + this.freshHistoryReady = false; + this.revealLatestRequested = true; + + this.loadComments(task, useCache, fetchAfterCache, generation); + + if (this.project.unit.currentUserIsStaff) { + this.feedbackTemplateService + .query({contextType: 'task_definitions', contextId: task.definition.id}, {}) + .subscribe({ + error: () => this.alerts.error('Error loading task feedback templates.'), + }); + } + } + + private loadComments( + task: Task, + useCache: boolean, + fetchAfterCache: boolean, + generation: number, + ): void { if (!task.comments.length) { // If the cache is empty we know the query will attempt to fetch, so we can avoid fetching a second time useCache = false; @@ -136,71 +205,241 @@ export class TaskCommentsViewerComponent implements OnChanges, OnDestroy { }, ); - request$.subscribe((comments) => { - // Remove task notification - task.numNewComments = 0; - - for (const comment of comments) { - const existingComment = task.commentCache.get(comment.id); - comment.task = task; - if (!existingComment) { - // Update the cache with any new comments - task.commentCache.add(comment); - } else if ( - existingComment.recipientReadTime !== comment.recipientReadTime || - existingComment.text !== comment.text - ) { - // Update cached read receipts or edited messages - task.commentCache.set(comment.id, comment); + const requestSub = request$.subscribe({ + next: (comments) => { + if (!this.isActiveLoad(task, generation)) { + return; } - } - for (const cachedComment of task.comments) { - if (!comments.find((c) => c.id === cachedComment.id)) { - // This comment is in cache but not in the latest comments list - task.commentCache.delete(cachedComment.id); + this.mapComments(task, comments); + this.loading = false; + + if (useCache && fetchAfterCache) { + // Cached history can paint immediately, but a notification can refer to + // a comment that is not in that cache. Start the authoritative request + // now and only fulfil a pending landing when that response has rendered. + this.loadComments(task, false, false, generation); + return; } - } - task.refreshCommentData(); + this.freshHistoryReady = true; + this.tryRevealLatestMessagesAndComposer(); + }, + error: () => { + if (!this.isActiveLoad(task, generation)) { + return; + } - const lastReadComment: TaskComment = task.comments - .slice() - .reverse() - .find( - (comment: TaskComment) => comment.recipientReadTime != null && !comment.recipientIsMe, - ); + if (useCache && fetchAfterCache) { + this.loadComments(task, false, false, generation); + return; + } - setTimeout(() => { + // Do not leave the conversation behind an endless spinner. The pending + // landing is deliberately retained so a later successful retry can + // fulfil it. this.loading = false; - this.scrollDown(); - if (useCache && fetchAfterCache) { - this.fetchComments(task, false, false); + }, + }); + this.commentsRequestSub.add(requestSub); + } + + /** + * Public Batch 02 landing hook. It is safe to call before comment history or + * the view is ready; the request stays pending until both are available. + * It scrolls only and never focuses the composer or opens the phone keyboard. + */ + public revealLatestMessagesAndComposer(): void { + this.revealLatestRequested = true; + this.tryRevealLatestMessagesAndComposer(); + } + + /** Backwards-compatible name used by comment creation and template controls. */ + public scrollDown(): void { + this.revealLatestMessagesAndComposer(); + } + + private mapComments(task: Task, comments: TaskComment[]): void { + // Remove task notification + task.numNewComments = 0; + + for (const comment of comments) { + const existingComment = task.commentCache.get(comment.id); + comment.task = task; + if (!existingComment) { + // Update the cache with any new comments + task.commentCache.add(comment); + } else if ( + existingComment.recipientReadTime !== comment.recipientReadTime || + existingComment.text !== comment.text + ) { + // Update cached read receipts or edited messages + task.commentCache.set(comment.id, comment); + } + } + + const responseIds = new Set(comments.map((comment) => comment.id)); + // Deleting from the cache mutates task.comments, so iterate a snapshot. + for (const cachedComment of [...task.comments]) { + if (!responseIds.has(cachedComment.id)) { + // This comment is in cache but not in the latest comments list + task.commentCache.delete(cachedComment.id); + } + } + + task.refreshCommentData(); + + const lastReadComment: TaskComment = task.comments + .slice() + .reverse() + .find((comment: TaskComment) => comment.recipientReadTime != null && !comment.recipientIsMe); + + if (lastReadComment) { + for (const comment of task.comments) { + comment.lastRead = false; + } + lastReadComment.lastRead = true; + } + } + + private tryRevealLatestMessagesAndComposer(): void { + const target = this.currentConversationTarget(); + if (target && this.conversationLanding.pendingFor(target)) { + this.revealLatestRequested = true; + } + + if ( + !this.revealLatestRequested || + !this.freshHistoryReady || + !this.viewInitialised || + !target || + !this.commentsBody?.nativeElement || + !this.conversationEnd?.nativeElement || + !this.commentsFooter?.nativeElement + ) { + return; + } + + this.cancelRevealFrames(); + const generation = this.commentLoadGeneration; + + // A frame is a rendering boundary, unlike the old fixed 50/100ms guesses. + // Repeating on the following frame catches auto-grown text and newly mounted + // media controls that finish their first layout immediately after the list. + this.revealFrame = this.requestFrame(() => { + this.revealFrame = null; + if (!this.canFinishReveal(generation, target)) { + return; + } + + this.performReveal(); + this.settleFrame = this.requestFrame(() => { + this.settleFrame = null; + if (!this.canFinishReveal(generation, target)) { + return; } - }, 100); - if (lastReadComment) { - for (const comment of task.comments) { - comment.lastRead = false; + this.performReveal(); + this.revealLatestRequested = false; + + const completedRequest = this.conversationLanding.pendingFor(target); + if (completedRequest) { + this.conversationLanding.complete(completedRequest); } - lastReadComment.lastRead = true; - } + }); }); + } - if (this.project.unit.currentUserIsStaff) { - this.feedbackTemplateService - .query({contextType: 'task_definitions', contextId: task.definition.id}, {}) - .subscribe({ - error: () => this.alerts.error('Error loading task feedback templates.'), - }); + private performReveal(): void { + const commentsBody = this.commentsBody?.nativeElement; + if (!commentsBody) { + return; } + + commentsBody.scrollTop = commentsBody.scrollHeight; + this.conversationEnd?.nativeElement.scrollIntoView?.({block: 'end', inline: 'nearest'}); + this.commentsFooter?.nativeElement.scrollIntoView?.({block: 'nearest', inline: 'nearest'}); + commentsBody.scrollTop = commentsBody.scrollHeight; } - scrollDown() { - setTimeout(() => { - const element = this.commentsBody.nativeElement; - element.scrollTop = element.scrollHeight; - }, 50); + private canFinishReveal(generation: number, target: ConversationLandingTarget): boolean { + const currentTarget = this.currentConversationTarget(); + return ( + generation === this.commentLoadGeneration && + this.freshHistoryReady && + !!currentTarget && + currentTarget.projectId === target.projectId && + currentTarget.taskDefinitionId === target.taskDefinitionId + ); + } + + private currentConversationTarget(): ConversationLandingTarget | null { + const projectId = this.task?.project?.id ?? this.project?.id; + const taskDefinitionId = this.task?.definition?.id; + if (!projectId || !taskDefinitionId) { + return null; + } + + return {projectId, taskDefinitionId}; + } + + private requestBelongsToCurrentConversation(request: ConversationLandingRequest): boolean { + const target = this.currentConversationTarget(); + return ( + !!target && + request.projectId === target.projectId && + request.taskDefinitionId === target.taskDefinitionId + ); + } + + private commentBelongsToCurrentConversation(comment: TaskComment): boolean { + return this.isCurrentTask(comment?.task); + } + + private isCurrentTask(task: Task | null | undefined): boolean { + return ( + !!task && + !!this.task && + task.project?.id === this.task.project?.id && + task.definition?.id === this.task.definition?.id + ); + } + + private isActiveLoad(task: Task, generation: number): boolean { + return generation === this.commentLoadGeneration && this.isCurrentTask(task); + } + + private cancelCommentLoad(): void { + this.commentsRequestSub.unsubscribe(); + this.commentsRequestSub = new Subscription(); + this.cancelRevealFrames(); + } + + private requestFrame(callback: FrameRequestCallback): number { + if (typeof globalThis.requestAnimationFrame === 'function') { + return globalThis.requestAnimationFrame(callback); + } + + return globalThis.setTimeout(() => callback(performance.now()), 0) as unknown as number; + } + + private cancelFrame(frame: number | null): void { + if (frame === null) { + return; + } + + if (typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(frame); + } else { + globalThis.clearTimeout(frame); + } + } + + private cancelRevealFrames(): void { + this.cancelFrame(this.revealFrame); + this.cancelFrame(this.settleFrame); + this.revealFrame = null; + this.settleFrame = null; } shouldShowReadReceipt() { @@ -215,47 +454,19 @@ export class TaskCommentsViewerComponent implements OnChanges, OnDestroy { return this.task.scormEnabled; } - uploadFiles(event) { - [...event].forEach((file) => { - if ( - [ - 'audio/mpeg', - 'audio/vorbis', - 'audio/mp4', - 'audio/ogg', - 'audio/wav', - 'audio/x-wav', - 'audio/webm', - 'image/png', - 'image/pdf', - 'application/pdf', - 'image/gif', - 'image/jpg', - 'image/jpeg', - ].includes(file.type) || - file.type.startsWith('audio/') || - file.type.startsWith('image/') - ) { - this.postAttachmentComment(file); - } else { - this.alerts.error('I cannot upload that file - only images, audio, and PDFs.', 4000); - } - }); - console.log('implement - check map comments'); - // this.task.comments = this.ts.mapComments(this.task.comments); + uploadFiles(files: ArrayLike) { + this.commentComposer?.uploadFiles(files); } - // # Upload image files as comments to a given task - postAttachmentComment(file) { - this.taskCommentService.addComment(this.task, file, 'file', null).subscribe({ - error: (error) => { - this.alerts.error(error || error?.message, 2000); - }, - }); + downloadAttachment(comment: TaskComment): void { + this.taskCommentService.downloadAttachment(comment); } - scrollToComment(commentID) { - document.querySelector(`#comment-${commentID}`).scrollIntoView(); + scrollToComment(commentID?: number) { + if (!commentID) { + return; + } + document.querySelector(`#comment-${commentID}`)?.scrollIntoView(); } openCommentsModal(comment: TaskComment) { @@ -263,6 +474,10 @@ export class TaskCommentsViewerComponent implements OnChanges, OnDestroy { this.commentsModalRef.show(resourceUrl, comment); } + downloadCommentAttachment(comment: TaskComment): void { + this.taskCommentService.downloadCommentAttachment(comment); + } + shouldShowAuthorIcon(commentType: string) { return !( commentType === 'extension' || diff --git a/src/app/units/states/edit/directives/unit-communications-editor/communication-schedule-modal/communication-schedules.component.html b/src/app/units/states/edit/directives/unit-communications-editor/communication-schedule-modal/communication-schedules.component.html index 1d5dbcf1e8..f863b1f16f 100644 --- a/src/app/units/states/edit/directives/unit-communications-editor/communication-schedule-modal/communication-schedules.component.html +++ b/src/app/units/states/edit/directives/unit-communications-editor/communication-schedule-modal/communication-schedules.component.html @@ -1,71 +1,91 @@ -@if (editor.setPreviewLoading) { - -} - -
      -
      -
      -
      Schedules
      -
      Build one-off or recurring schedules for this set.
      -
      - +
      +
      +

      + Schedules + ({{ (set.schedules || []).length }}) +

      +

      When this set runs on its own, once or on repeat.

      - @if ((set.schedules || []).length) { -
      + @if (editor.setPreviewLoading && !(set.schedules || []).length) { + + } @else if ((set.schedules || []).length) { +
        @for (schedule of set.schedules || []; track editor.scheduleTrackId(schedule)) { -
        -
        -
        -
        {{ schedule.name || 'Untitled schedule' }}
        -
        {{ editor.scheduleSummary(schedule) }}
        +
      • +
        +
        +

        + {{ schedule.name || 'Untitled schedule' }} + + + {{ schedule.active ? 'Active' : 'Paused' }} + +

        +

        {{ editor.scheduleSummary(schedule) }}

        -
        - + -
        -
        -
        -
        Anchor
        -
        {{ editor.scheduleAnchorSummary(schedule) }}
        +
        +
        +
        Starts
        +
        {{ editor.scheduleAnchorSummary(schedule) }}
        -
        -
        Time
        -
        {{ editor.scheduleTimeSummary(schedule) }}
        +
        +
        Time
        +
        {{ editor.scheduleTimeSummary(schedule) }}
        -
        -
        Next Run
        -
        {{ editor.scheduleNextRunSummary(schedule) }}
        +
        +
        Next run
        +
        {{ editor.scheduleNextRunSummary(schedule) }}
        -
        -
        Last Run
        -
        {{ editor.scheduleLastRunSummary(schedule) }}
        +
        +
        Last run
        +
        {{ editor.scheduleLastRunSummary(schedule) }}
        -
        -
        +
        +
      • } -
        +
      } @else { -
      - No schedules yet. Add one to run this set on a fixed date or on a repeating cadence. -
      + }
      diff --git a/src/app/units/states/edit/directives/unit-details-editor/d2l-details-form/d2l-unit-details-form.component.ts b/src/app/units/states/edit/directives/unit-details-editor/d2l-details-form/d2l-unit-details-form.component.ts index cd19b0b701..6e2600995d 100644 --- a/src/app/units/states/edit/directives/unit-details-editor/d2l-details-form/d2l-unit-details-form.component.ts +++ b/src/app/units/states/edit/directives/unit-details-editor/d2l-details-form/d2l-unit-details-form.component.ts @@ -1,5 +1,5 @@ // -// Modal to show Doubtfire version info +// Dialog to link a unit to its D2L course for grade transfer // import {ChangeDetectionStrategy, Component, Inject, Injectable, OnInit} from '@angular/core'; import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from '@angular/material/dialog'; @@ -75,13 +75,12 @@ export class D2lUnitDetailsFormComponent implements OnInit { if (action) { action.subscribe({ next: () => { - this.alertService.success('D2l details saved successfully'); + this.alertService.success('D2L details saved.'); // Close the dialog this.dialogRef.close(); }, error: (err) => { - this.alertService.error(`Failed to save unit ${err}`); - console.error(err); + this.alertService.error(`Could not save the D2L details. ${err}`); }, }); } else { @@ -94,22 +93,21 @@ export class D2lUnitDetailsFormComponent implements OnInit { .delete({unitId: this.data.id, id: this.d2lDataMapping.id}) .subscribe({ next: () => { - this.alertService.success('D2l details deleted successfully'); + this.alertService.success('D2L details removed.'); this.data.d2lMapping = undefined; this.dialogRef.close(); }, error: (err) => { - this.alertService.error(`Failed to delete D2L details: ${err}`); - console.error(err); + this.alertService.error(`Could not remove the D2L details. ${err}`); }, }); } } /** - * The about doubtfire modal service - used to create and show the modal + * Opens the D2L details dialog for a unit. */ -// eslint-disable-next-line max-classes-per-file + @Injectable() export class D2lUnitDetailsModal { constructor(public dialog: MatDialog) {} diff --git a/src/app/units/states/edit/directives/unit-group-set-editor/unit-group-set-editor.component.ts b/src/app/units/states/edit/directives/unit-group-set-editor/unit-group-set-editor.component.ts index f35f861698..be9ca62fc4 100644 --- a/src/app/units/states/edit/directives/unit-group-set-editor/unit-group-set-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-group-set-editor/unit-group-set-editor.component.ts @@ -1,7 +1,9 @@ -import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {Subscription} from 'rxjs'; import {GroupSet, Unit, UnitRole} from 'src/app/api/models/doubtfire-model'; import {GroupSetService} from 'src/app/api/services/group-set.service'; import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; import { CsvResult, CsvResultModalService, @@ -23,7 +25,7 @@ interface GroupSetEditModel { changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class UnitGroupSetEditorComponent implements OnInit { +export class UnitGroupSetEditorComponent implements OnInit, OnDestroy { @Input() unit: Unit; @Input() unitRole: UnitRole; @@ -36,30 +38,66 @@ export class UnitGroupSetEditorComponent implements OnInit { file: {name: 'Group CSV', type: 'csv'}, }; + public readonly columns = [ + 'name', + 'capacity', + 'createGroups', + 'manageGroups', + 'restrictTutorials', + 'actions', + ]; + public editingGroupSetId: number | null = null; public editingGroupSetModel: GroupSetEditModel | null = null; public studentStaffOptions = [ - {value: true, text: 'Staff and Students'}, - {value: false, text: 'Staff Only'}, + {value: true, text: 'Staff and students'}, + {value: false, text: 'Staff only'}, ]; public tutorialOptions = [ - {value: true, text: 'Same Tutorial'}, - {value: false, text: 'Any Tutorial'}, + {value: true, text: 'Same tutorial only'}, + {value: false, text: 'Any tutorial'}, ]; + // Handed to the uploaders once. Binding `fn.bind(this)` in the template made a + // new function on every check, so the uploaders saw a changed input each time. + public readonly handleGroupCSVSuccess = (response: CsvResult) => this.onGroupCSVSuccess(response); + public readonly handleGroupCSVComplete = () => this.onGroupCSVComplete(); + + private groupSetsSub?: Subscription; + constructor( private groupSetService: GroupSetService, private alertService: AlertService, private fileDownloaderService: FileDownloaderService, private csvResultModal: CsvResultModalService, + private confirmationModal: ConfirmationModalService, ) {} ngOnInit(): void { if (this.unit?.groupSets?.length > 0) { this.selectGroupSet(this.unit.groupSets[0]); } + + // Reloading the unit, as a CSV import does, builds new group set objects with + // the new groups in them. Follow the open set to its new object, or the page + // keeps showing the old one without the imported groups. + this.groupSetsSub = this.unit?.groupSetsCache?.values?.subscribe((groupSets) => { + const selected = this.selectedGroupSet; + if (!selected || groupSets.includes(selected)) { + return; + } + this.selectGroupSet(groupSets.find((set) => set.id === selected.id) ?? groupSets[0] ?? null); + }); + } + + ngOnDestroy(): void { + this.groupSetsSub?.unsubscribe(); + } + + public get canSaveGroupSet(): boolean { + return !!this.editingGroupSetModel?.name?.trim(); } addGroupSet(): void { @@ -93,11 +131,22 @@ export class UnitGroupSetEditorComponent implements OnInit { } saveGroupSet(groupSet: GroupSet): void { - if (!this.editingGroupSetModel) { + if (!this.editingGroupSetModel || !this.canSaveGroupSet) { return; } - groupSet.name = this.editingGroupSetModel.name; + // The set is changed before the request so the request carries the new + // values. If it fails, put the old ones back, or the table shows values the + // server never took. + const previous: GroupSetEditModel = { + name: groupSet.name, + allowStudentsToCreateGroups: groupSet.allowStudentsToCreateGroups, + allowStudentsToManageGroups: groupSet.allowStudentsToManageGroups, + keepGroupsInSameClass: groupSet.keepGroupsInSameClass, + capacity: groupSet.capacity, + }; + + groupSet.name = this.editingGroupSetModel.name.trim(); groupSet.allowStudentsToCreateGroups = this.editingGroupSetModel.allowStudentsToCreateGroups; groupSet.allowStudentsToManageGroups = this.editingGroupSetModel.allowStudentsToManageGroups; groupSet.keepGroupsInSameClass = this.editingGroupSetModel.keepGroupsInSameClass; @@ -108,7 +157,10 @@ export class UnitGroupSetEditorComponent implements OnInit { this.alertService.success('Group set updated.', 2000); this.cancelEditGroupSet(); }, - error: (message) => this.alertService.error(`Failed to update group set. ${message}`, 6000), + error: (message) => { + Object.assign(groupSet, previous); + this.alertService.error(`Failed to update group set. ${message}`, 6000); + }, }); } @@ -134,11 +186,23 @@ export class UnitGroupSetEditorComponent implements OnInit { } removeGroupSet(groupSet: GroupSet): void { + // One click used to delete the set and every group in it. + this.confirmationModal.show( + `Delete ${groupSet.name || 'this group set'}`, + 'This deletes the group set and all of its groups. You cannot undo this.', + () => this.deleteGroupSet(groupSet), + ); + } + + private deleteGroupSet(groupSet: GroupSet): void { this.groupSetService.delete(groupSet, {cache: this.unit.groupSetsCache}).subscribe({ next: () => { if (groupSet === this.selectedGroupSet) { this.selectGroupSet(this.unit.groupSets[0] ?? null); } + if (this.editingGroupSetId === groupSet.id) { + this.cancelEditGroupSet(); + } this.alertService.success('Group set deleted.', 2000); }, error: (message) => this.alertService.error(`Failed to delete group set. ${message}`, 6000), @@ -162,7 +226,12 @@ export class UnitGroupSetEditorComponent implements OnInit { onGroupCSVSuccess(response: CsvResult): void { this.csvResultModal.show('Group CSV upload results.', response); - this.selectGroupSet(this.selectedGroupSet); + // The groups arrive with the unit, so reload it. Selecting the same set + // again, as this used to do, changed nothing, and the new groups did not show + // until the page was reloaded. + if ((response?.success?.length ?? 0) > 0) { + this.unit.refresh(); + } } onGroupCSVComplete(): void { diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html index affdead4d1..8b381d71e1 100644 --- a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html @@ -1,160 +1,194 @@ -
      -
      -

      Unit Staff

      -

      Manage unit staff by adding members and assigning them as convenors or tutors.

      +
      +
      +
      + + + + + + @for (person of filteredStaff; track person.id) { + + {{ person.name }} + {{ person.email }} + + } + + +
      +

      + Search by name or email. New staff join as tutors. +

      +
      - - - - - - - - - - - - + + - + + + + + +
      Name -
      - {{ unitRole.user.name }} -
      -
      Role - - Tutor - Convenor - - Main Convenor - @if (unitRole?.role === 'Convenor') { - Actions +
      + + +
      +
      + +
      -
      + diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.spec.ts b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.spec.ts new file mode 100644 index 0000000000..ad54852e9d --- /dev/null +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.spec.ts @@ -0,0 +1,244 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatAutocompleteModule} from '@angular/material/autocomplete'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; +import {MatIconModule} from '@angular/material/icon'; +import {MatInputModule} from '@angular/material/input'; +import {MatSelect, MatSelectModule} from '@angular/material/select'; +import {MatSlideToggleModule} from '@angular/material/slide-toggle'; +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 {of} from 'rxjs'; +import {UnitService} from 'src/app/api/models/doubtfire-model'; +import {Unit} from 'src/app/api/models/unit'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {UnitRoleService} from 'src/app/api/services/unit-role.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {EmptyStateComponent} from 'src/app/common/empty-state/empty-state.component'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {CsvResultModalService} from 'src/app/common/modals/csv-result-modal/csv-result-modal.service'; +import {TutorNotesModalService} from 'src/app/common/modals/tutor-notes-modal/tutor-notes-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {BulkImportStaffModalService} from './bulk-import-staff-modal/bulk-import-staff-modal.service'; +import {UnitStaffEditorComponent} from './unit-staff-editor.component'; + +function role(id: number, name: string, extra: Partial = {}): UnitRole { + return Object.assign(new UnitRole(), { + id, + role: 'Tutor', + user: {id: id * 10, name, displayName: name, email: `${name.toLowerCase()}@uni.edu`}, + ...extra, + }); +} + +function unitWith(staff: UnitRole[], mainConvenor?: UnitRole): Unit { + const unit = Object.assign(new Unit(), {id: 3, code: 'SIT101', name: 'Intro to Things'}); + staff.forEach((unitRole) => unit.staffCache.add(unitRole)); + unit.mainConvenor = mainConvenor; + return unit; +} + +function mocks(currentUserId = 999) { + return { + alerts: {success: vi.fn(), error: vi.fn()}, + unitRoleService: {update: vi.fn(() => of({})), delete: vi.fn(() => of(true))}, + userService: {currentUser: {id: currentUserId}}, + confirmationModal: {show: vi.fn()}, + unitService: {update: vi.fn(() => of({}))}, + }; +} + +// Built directly for the handlers, and through TestBed for the one template case. +function editorFor(unit: Unit, currentUserId?: number) { + const m = mocks(currentUserId); + const component = new UnitStaffEditorComponent( + m.alerts as never, + m.unitRoleService as never, + m.userService as never, + m.confirmationModal as never, + {} as never, // tutorNotesModal + {} as never, // bulkImportStaffModal + {} as never, // csvResultModal + m.unitService as never, + ); + component.unit = unit; + component.staff = []; + component.ngOnInit(); + return {component, ...m}; +} + +describe('UnitStaffEditorComponent', () => { + afterEach(() => TestBed.resetTestingModule()); + + // The observer switch read unit.mainConvenor.id, which threw for a unit with no + // main convenor and left the whole table blank. + it('shows the staff of a unit that has no main convenor', async () => { + const m = mocks(); + const unit = unitWith([role(1, 'Ada'), role(2, 'Grace', {role: 'Convenor'})]); + + await TestBed.configureTestingModule({ + declarations: [UnitStaffEditorComponent], + imports: [ + FormsModule, + MatAutocompleteModule, + MatButtonToggleModule, + MatIconModule, + MatInputModule, + MatSelectModule, + MatSlideToggleModule, + MatTableModule, + MatTooltipModule, + NoopAnimationsModule, + EmptyStateComponent, + ], + providers: [ + {provide: AlertService, useValue: m.alerts}, + {provide: UnitRoleService, useValue: m.unitRoleService}, + {provide: UserService, useValue: m.userService}, + {provide: ConfirmationModalService, useValue: m.confirmationModal}, + {provide: TutorNotesModalService, useValue: {}}, + {provide: BulkImportStaffModalService, useValue: {}}, + {provide: CsvResultModalService, useValue: {}}, + {provide: UnitService, useValue: m.unitService}, + ], + // user-icon draws an avatar and is not what this checks. + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + const fixture = TestBed.createComponent(UnitStaffEditorComponent); + fixture.componentInstance.unit = unit; + fixture.componentInstance.staff = []; + fixture.detectChanges(); + + const rows = fixture.nativeElement.querySelectorAll('tr.mat-mdc-row'); + expect(rows.length).toBe(2); + expect(rows[0].textContent).toContain('Ada'); + // The convenor can be made main, and nobody is marked as main yet. + const mainCells: HTMLElement[] = Array.from( + fixture.nativeElement.querySelectorAll('td.mat-column-main-convenor'), + ); + expect(mainCells.map((cell) => cell.textContent.trim())).toEqual(['', 'Make main']); + // Every icon-only button says what it does. + fixture.nativeElement.querySelectorAll('button[mat-icon-button]').forEach((button) => { + expect(button.getAttribute('aria-label')).toBeTruthy(); + }); + // no mentor is a null option, so the select has to let you pick it + const mentorSelects = fixture.debugElement.queryAll(By.directive(MatSelect)); + expect(mentorSelects.length).toBe(2); + mentorSelects.forEach((select) => { + expect(select.componentInstance.canSelectNullableOptions).toBe(true); + }); + }); + + it('knows who the main convenor is, and that there may be none', () => { + const grace = role(2, 'Grace', {role: 'Convenor'}); + const {component} = editorFor(unitWith([role(1, 'Ada'), grace])); + + expect(component.isMainConvenor(grace)).toBe(false); + + component.unit.mainConvenor = grace; + expect(component.isMainConvenor(grace)).toBe(true); + }); + + it('offers everyone else as a mentor', () => { + const ada = role(1, 'Ada'); + const grace = role(2, 'Grace'); + const {component} = editorFor(unitWith([ada, grace])); + + expect(component.mentorOptions(ada).map((option) => option.id)).toEqual([2]); + }); + + // Older data can have someone set as their own mentor. They stay listed for that + // person so the field shows who it is rather than going blank. + it('keeps someone listed as their own mentor when the data says so', () => { + const ada = role(1, 'Ada', {mentorId: 1}); + const grace = role(2, 'Grace'); + const {component} = editorFor(unitWith([ada, grace])); + + expect(component.mentorOptions(ada).map((option) => option.id)).toEqual([1, 2]); + }); + + // "(None)" used to send an empty string rather than clearing the mentor. + it('clears a mentor with null', () => { + const ada = role(1, 'Ada', {mentorId: 2}); + const {component, unitRoleService} = editorFor(unitWith([ada, role(2, 'Grace')])); + + component.selectMentor(ada, {value: null} as never); + + expect(ada.mentorId).toBeNull(); + expect(unitRoleService.update).toHaveBeenCalledWith(ada); + }); + + // Changing the convenor went through a whole-unit update, which also sent any other + // field of the unit that no longer matched what was first loaded. + it('sends only the new main convenor', () => { + const grace = role(2, 'Grace', {role: 'Convenor'}); + const {component, confirmationModal, unitService} = editorFor(unitWith([grace])); + + component.changeMainConvenor(grace); + (confirmationModal.show.mock.calls[0][2] as () => void)(); + + expect(unitService.update).toHaveBeenCalledWith(component.unit, { + body: {unit: {main_convenor_id: 2}}, + }); + expect(component.unit.mainConvenor).toBe(grace); + }); + + it('puts the old main convenor back when the change is refused', () => { + const ada = role(1, 'Ada', {role: 'Convenor'}); + const grace = role(2, 'Grace', {role: 'Convenor'}); + const {component, confirmationModal, unitService, alerts} = editorFor( + unitWith([ada, grace], ada), + ); + unitService.update.mockReturnValue({ + subscribe: ({error}: {error: (message: string) => void}) => error('Not allowed'), + } as never); + + component.changeMainConvenor(grace); + (confirmationModal.show.mock.calls[0][2] as () => void)(); + + expect(component.unit.mainConvenor).toBe(ada); + expect(alerts.error).toHaveBeenCalledWith('Not allowed', 6000); + }); + + // The reassign button always said "Reassign to me", even when the tutorials were + // going to the main convenor instead. + it('names who the tutorials go to when a tutor is removed', () => { + const ada = role(1, 'Ada'); + const grace = role(2, 'Grace', {role: 'Convenor'}); + const unit = unitWith([ada, grace], grace); + unit.tutorialsCache.add(Object.assign({key: 5, abbreviation: 'LA1-01', tutor: ada.user})); + const {component, confirmationModal} = editorFor(unit, 999); + + component.removeStaff(ada); + + const [, message, , , confirmText] = confirmationModal.show.mock.calls[0]; + expect(message).toContain('LA1-01'); + expect(message).toContain('Grace'); + expect(confirmText).toBe('Remove and give them to Grace'); + }); + + it('offers the tutorials to the person removing the tutor when they teach the unit', () => { + const ada = role(1, 'Ada'); + const me = role(2, 'Grace', {role: 'Convenor'}); + const unit = unitWith([ada, me], me); + unit.tutorialsCache.add(Object.assign({key: 5, abbreviation: 'LA1-01', tutor: ada.user})); + const {component, confirmationModal} = editorFor(unit, me.user.id); + + component.removeStaff(ada); + + expect(confirmationModal.show.mock.calls[0][4]).toBe('Remove and give them to me'); + }); +}); + +it('sets a selected mentor id without changing the unit role', () => { + const ada = role(1, 'Ada'); + const {component, unitRoleService} = editorFor(unitWith([ada, role(2, 'Grace')])); + component.selectMentor(ada, {value: 2} as never); + expect(ada.mentorId).toBe(2); + expect(unitRoleService.update).toHaveBeenCalledWith(ada); +}); diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts index af051c8f14..0f9e7bbf82 100644 --- a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts @@ -1,8 +1,9 @@ -import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {MatButtonToggleChange} from '@angular/material/button-toggle'; import {MatSelectChange} from '@angular/material/select'; import {MatTableDataSource} from '@angular/material/table'; -import {Tutorial, User} from 'src/app/api/models/doubtfire-model'; +import {Subscription} from 'rxjs'; +import {Tutorial, UnitService, User} from 'src/app/api/models/doubtfire-model'; import {Unit} from 'src/app/api/models/unit'; import {UnitRole} from 'src/app/api/models/unit-role'; import {UnitRoleService} from 'src/app/api/services/unit-role.service'; @@ -23,15 +24,13 @@ import {BulkImportStaffModalService} from './bulk-import-staff-modal/bulk-import changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class UnitStaffEditorComponent implements OnInit { +export class UnitStaffEditorComponent implements OnInit, OnDestroy { @Input() unit: Unit; @Input() staff: User[]; - temp = []; - users = []; - unitStaff: UnitRole[]; + unitStaff: UnitRole[] = []; filteredStaff: User[] = []; // Filtered staff members - searchTerm: string = ''; // Search term entered by the user + searchTerm: string | User = ''; // Search term entered by the user, or the person picked displayedColumns: string[] = [ 'name', @@ -44,6 +43,8 @@ export class UnitStaffEditorComponent implements OnInit { ]; dataSource: MatTableDataSource = new MatTableDataSource(); + private subscriptions: Subscription[] = []; + // Inject services here constructor( private alertService: AlertService, @@ -53,14 +54,39 @@ export class UnitStaffEditorComponent implements OnInit { private tutorNotesModal: TutorNotesModalService, private bulkImportStaffModal: BulkImportStaffModalService, private csvResultModal: CsvResultModalService, + private unitService: UnitService, ) {} ngOnInit(): void { // Subscribe to staff cache - this.unit.staffCache.values.subscribe((staff: UnitRole[]) => { - this.unitStaff = staff; - this.dataSource.data = staff; - }); + this.subscriptions.push( + this.unit.staffCache.values.subscribe((staff: UnitRole[]) => { + this.unitStaff = staff; + this.dataSource.data = staff; + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + /** + * A unit can be without a main convenor, for example straight after it is created, + * and reading the id off a missing convenor used to break the whole table. + */ + isMainConvenor(unitRole: UnitRole): boolean { + return !!unitRole?.id && unitRole.id === this.unit?.mainConvenor?.id; + } + + /** + * Everyone who could mentor this person. That is the rest of the staff, plus whoever + * mentors them now, so the field never shows blank. + */ + mentorOptions(unitRole: UnitRole): UnitRole[] { + return this.unitStaff.filter( + (other) => other.id !== unitRole.id || other.id === unitRole.mentorId, + ); } onRoleChange(unitRole: UnitRole, event: MatButtonToggleChange) { @@ -86,7 +112,7 @@ export class UnitStaffEditorComponent implements OnInit { unitRole.roleId = roleId; unitRole.role = role; this.unitRoleService.update(unitRole).subscribe({ - next: () => this.alertService.success('Role changed', 2000), + next: () => this.alertService.success(`${unitRole.user.displayName} is now a ${role}.`, 2000), error: (response) => { // Revert changes on error unitRole.roleId = previousRoleId; @@ -101,7 +127,7 @@ export class UnitStaffEditorComponent implements OnInit { unitRole.observerOnly = !unitRole.observerOnly; unitRole.roleId = unitRole.role === 'Tutor' ? 2 : 3; this.unitRoleService.update(unitRole).subscribe({ - next: () => this.alertService.success('Observer status updated', 2000), + next: () => this.alertService.success('Observer setting updated', 2000), error: (response) => { // Revert changes on error unitRole.observerOnly = previousValue; @@ -115,7 +141,7 @@ export class UnitStaffEditorComponent implements OnInit { unitRole.canMarkOverflowTasks = !unitRole.canMarkOverflowTasks; unitRole.roleId = unitRole.role === 'Tutor' ? 2 : 3; this.unitRoleService.update(unitRole).subscribe({ - next: () => this.alertService.success('Overflow marking permissions updated', 2000), + next: () => this.alertService.success('Overflow marking updated', 2000), error: (response) => { // Revert changes on error unitRole.canMarkOverflowTasks = previousValue; @@ -126,7 +152,8 @@ export class UnitStaffEditorComponent implements OnInit { selectMentor(unitRole: UnitRole, event: MatSelectChange) { const previousValue = unitRole.mentorId; - unitRole.mentorId = event.value; + // "No mentor" is null, which the api reads as removing the mentor. + unitRole.mentorId = event.value ?? null; unitRole.roleId = unitRole.role === 'Tutor' ? 2 : 3; this.unitRoleService.update(unitRole).subscribe({ @@ -138,23 +165,31 @@ export class UnitStaffEditorComponent implements OnInit { }, }); } + /** - * Changes who the `Main Convenor` of the unit is. - * - * @param UnitRole staff - * - * @returns void + * Makes a convenor the main convenor of the unit. Only the convenor is sent, so this + * cannot also send some other field of the unit that differs from what was loaded. */ changeMainConvenor(staff: UnitRole) { this.confirmationModalService.show( - 'Set Main Convenor', - `Do you want to make ${staff.user.name} the main convenor for this unit?`, + 'Change the main convenor?', + `${staff.user.displayName} will become the main convenor of ${this.unit.code}.`, () => { - this.unit.changeMainConvenor(staff).subscribe({ - next: (_response) => this.alertService.success('Main convenor changed', 2000), - error: (response) => this.alertService.error(response, 6000), + const previous = this.unit.mainConvenor; + this.unit.mainConvenor = staff; + this.unitService.update(this.unit, {body: {unit: {main_convenor_id: staff.id}}}).subscribe({ + next: () => { + this.unit.mainConvenor = staff; + this.alertService.success(`${staff.user.displayName} is now the main convenor`, 2000); + }, + error: (response) => { + this.unit.mainConvenor = previous; + this.alertService.error(response, 6000); + }, }); }, + undefined, + 'Change', ); } @@ -169,7 +204,7 @@ export class UnitStaffEditorComponent implements OnInit { if (selectedStaff?.id) { this.unit.addStaff(selectedStaff).subscribe({ next: () => { - this.alertService.success('Staff member added', 2000); + this.alertService.success(`${selectedStaff.name} added as a tutor`, 2000); this.searchTerm = ''; // Clear the input field this.filterStaffList(); // Refilter the list }, @@ -206,10 +241,11 @@ export class UnitStaffEditorComponent implements OnInit { if (typeof this.searchTerm !== 'string') { return; } - this.filteredStaff = this.staff.filter( + const term = this.searchTerm.trim().toLowerCase(); + this.filteredStaff = (this.staff ?? []).filter( (staff) => - staff.matches(this.searchTerm.toLowerCase()) && // Find by name - !this.unit.staff.find((listStaff) => staff.id === listStaff.user.id) && // Not already assigned to the unit + staff.matches(term) && // Find by name + !this.unit.staff.find((listStaff) => staff.id === listStaff.user?.id) && // Not already assigned to the unit // Filter out students from the staff search // NOTE: This is a hotfix to an issue where loading the inbox populates this.staff with students... staff.isStaff, @@ -223,7 +259,10 @@ export class UnitStaffEditorComponent implements OnInit { * * @returns void */ - displayStaffName(staff: User): string { + displayStaffName(staff: User | string): string { + if (typeof staff === 'string') { + return staff; + } return staff ? staff.name : ''; } @@ -242,17 +281,19 @@ export class UnitStaffEditorComponent implements OnInit { if (!targetRole) { this.alertService.error( - 'Unable to reassign tutorials because there is no valid staff member to receive them.', + `${staff.user.displayName} still runs tutorials, and there is no one to give them to. Add another convenor first.`, 6000, ); return; } const tutorialList = assignedTutorials.map((tutorial) => tutorial.abbreviation).join(', '); + const toCurrentUser = targetRole.user?.id === this.userService.currentUser?.id; + const targetName = toCurrentUser ? 'you' : targetRole.user.displayName; this.confirmationModalService.show( - 'Reassign Tutorials', - `You cannot remove ${staff.user.name} from the unit as they tutor the following tutorials: ${tutorialList}.`, + `Remove ${staff.user.displayName}?`, + `${staff.user.displayName} runs ${tutorialList}. Removing them gives these tutorials to ${targetName}.`, () => { this.unitRoleService .delete(staff, { @@ -264,38 +305,43 @@ export class UnitStaffEditorComponent implements OnInit { assignedTutorials.forEach((tutorial) => { tutorial.tutor = targetRole.user; }); - this.alertService.success('Staff member removed and tutorials reassigned', 2000); + this.alertService.success( + `${staff.user.displayName} removed, and their tutorials moved to ${targetName}`, + 2000, + ); }, error: (response) => this.alertService.error(response, 6000), }); }, undefined, - 'Reassign to me', + toCurrentUser ? 'Remove and give them to me' : `Remove and give them to ${targetName}`, 'Cancel', ); return; } this.confirmationModalService.show( - 'Remove staff member', - `Are you sure you want to remove ${staff.user.name} from ${this.unit.code} ${this.unit.name}?`, + `Remove ${staff.user.displayName}?`, + `${staff.user.displayName} will no longer be able to work in ${this.unit.code} ${this.unit.name}.`, () => { this.unitRoleService.delete(staff, {cache: this.unit.staffCache}).subscribe({ - next: () => this.alertService.success('Staff member removed', 2000), + next: () => this.alertService.success(`${staff.user.displayName} removed`, 2000), error: (response) => this.alertService.error(response, 6000), }); }, + undefined, + 'Remove', ); } private tutorialsForUnitRole(unitRole: UnitRole): Tutorial[] { - return this.unit.tutorials.filter((tutorial) => tutorial.tutor?.id === unitRole.user.id); + return this.unit.tutorials.filter((tutorial) => tutorial.tutor?.id === unitRole.user?.id); } private reassignmentTargetFor(unitRole: UnitRole): UnitRole | undefined { const currentUserRole = this.unit.staff.find( (staffRole) => - staffRole.user.id === this.userService.currentUser.id && staffRole.id !== unitRole.id, + staffRole.user?.id === this.userService.currentUser?.id && staffRole.id !== unitRole.id, ); if (currentUserRole) { @@ -309,10 +355,6 @@ export class UnitStaffEditorComponent implements OnInit { return undefined; } - groupSetName(id: number) { - return this.unit.groupSetsCache.get(id).name || 'Individual Work'; - } - openTutorNotes(unitRole: UnitRole) { unitRole.unit = this.unit; // HACK: ensure unit is mapped within the UnitRole this.tutorNotesModal.show(null, unitRole); @@ -328,11 +370,11 @@ export class UnitStaffEditorComponent implements OnInit { const existingStaffEmails: Set = new Set( this.unit.staff - .map((unitRole) => unitRole.user.email?.trim().toLowerCase()) + .map((unitRole) => unitRole.user?.email?.trim().toLowerCase()) .filter((email): email is string => !!email), ); const staffByEmail = new Map( - this.staff + (this.staff ?? []) .filter((staff) => staff.isStaff && staff.email) .map((staff) => [staff.email.trim().toLowerCase(), staff] as const), ); diff --git a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html index 9110f1dac4..09d4241850 100644 --- a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html +++ b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.html @@ -1,173 +1,187 @@ -
      -
      -
      -

      Enrolled Students

      -

      Summary of students enrolled in this unit

      -
      -
      -
      - - Search - +
      +
      +
      + + + +
      +

      + Name, username, email, campus, tutorial or group. +

      +
      + + +
      + + + + + + + @if (loadingStudents) {
      -
      - @for (width of ['10%', '12%', '12%', '20%', '10%', '20%', '8%', '8%']; track $index) { - - } -
      - - @for (row of [0, 1, 2, 3, 4, 5, 6, 7]; track row) { -
      - - - - - @for (column of [0, 1, 2, 3]; track column) { - - } -
      - } + +
      + } @else if (loadError) { +
      + +
      } - - - - - - - - - - - - - - - - - + +
      +
      Username - {{ project.student.username }} - First Name - {{ project.student.firstName }} - Last Name - {{ project.student.lastName }} -
      + + + + - - - - - + + + + - - - - - + + + + - - - - - + + + + - - - - - + + + + - - - - + + - - - - + + + + - - - + - + + + + + + + + + +
      Username + {{ project.student?.username }} + Email - {{ project.student.email }} - First name{{ project.student?.firstName }}Campus - - Last name{{ project.student?.lastName }}Tutorial - - Email{{ project.student?.email }}Enrolled - - Campus + + -
      - + +
      Tutorials + + Enrolled + + - - - - - - + Actions + +
      + - - - - - -
      + @if (filtering) { + + } @else { + + } +
      +
      - - - - -
      + + diff --git a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts index d9af32d5f6..2e10ef9cf9 100644 --- a/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-students-editor/unit-students-editor.component.ts @@ -9,7 +9,7 @@ import { ViewChild, } from '@angular/core'; import {MatPaginator} from '@angular/material/paginator'; -import {MatSort, Sort} from '@angular/material/sort'; +import {MatSort} from '@angular/material/sort'; import {MatTable, MatTableDataSource} from '@angular/material/table'; import {Router} from '@angular/router'; import {Subscription, finalize, timer} from 'rxjs'; @@ -55,6 +55,7 @@ export class UnitStudentsEditorComponent implements OnInit, AfterViewInit, OnDes ]; dataSource: MatTableDataSource = new MatTableDataSource([]); loadingStudents = true; + loadError = false; // Calls the parent's constructor, passing in an object // that maps all of the form controls that this form consists of. @@ -74,6 +75,10 @@ export class UnitStudentsEditorComponent implements OnInit, AfterViewInit, OnDes ngOnInit(): void { this.dataSource.data = this.unit.studentCache.currentValuesClone(); this.dataSource.filterPredicate = (data: Project, filter: string) => data.matches(filter); + // The name and email columns live on the student, not the project, so the table's + // own look-up sorted them on undefined and the order came out scrambled. + this.dataSource.sortingDataAccessor = (project: Project, column: string) => + this.sortValue(project, column); this.subscriptions.push( this.unit.studentCache.values.subscribe((students) => { @@ -94,6 +99,10 @@ export class UnitStudentsEditorComponent implements OnInit, AfterViewInit, OnDes this.subscriptions.forEach((s) => s.unsubscribe()); } + public get filtering(): boolean { + return !!this.dataSource.filter; + } + applyFilter(event: Event) { const filterValue = (event.target as HTMLInputElement).value; this.dataSource.filter = filterValue.trim().toLowerCase(); @@ -102,6 +111,28 @@ export class UnitStudentsEditorComponent implements OnInit, AfterViewInit, OnDes } } + public sortValue(project: Project, column: string): string | number { + switch (column) { + case 'username': + case 'firstName': + case 'lastName': + case 'email': + return (project.student?.[column] ?? '').toString().toLowerCase(); + case 'campus': + return project.campus?.name?.toLowerCase() ?? ''; + case 'enrolled': + return project.enrolled ? 1 : 0; + default: + return ''; + } + } + + public reloadStudents(): void { + this.loadError = false; + this.loadingStudents = true; + this.refreshStudentsAfterRender(); + } + private refreshStudentsAfterRender(): void { this.subscriptions.push( timer(0) @@ -111,37 +142,20 @@ export class UnitStudentsEditorComponent implements OnInit, AfterViewInit, OnDes this.loadingStudents = false; }), ) - .subscribe(() => { - // projects included in unit... + .subscribe({ + // The students arrive through the unit's cache, which the table already follows. + next: () => {}, + error: () => { + this.loadError = true; + }, }), ); } - private sortCompare(aValue: number | string, bValue: number | string, isAsc: boolean) { - return (aValue < bValue ? -1 : 1) * (isAsc ? 1 : -1); - } - - // Sorting function to sort data when sort - // event is triggered - sortTableData(sort: Sort) { - if (!sort.active || sort.direction === '') { - return; - } - this.dataSource.data = this.dataSource.data.sort((a, b) => { - const isAsc = sort.direction === 'asc'; - switch (sort.active) { - case 'username': - case 'firstName': - case 'lastName': - case 'email': - case 'enrolled': - return this.sortCompare(a[sort.active], b[sort.active], isAsc); - case 'campus': - return this.sortCompare(a.campus?.abbreviation, b.campus?.abbreviation, isAsc); - default: - return 0; - } - }); + // Changing the box saves it. This used to run on any click on the box's row area, + // which also sent a save, and a success message, when the box had not changed. + public enrolmentChanged(project: Project): void { + project.updateUnitEnrolment(); } public gotoStudent(student: Project) { diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html index 5958295741..87b9f3ce84 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html @@ -1,175 +1,284 @@ -
      -

      Details for {{ taskDefinition.abbreviation }} - {{ taskDefinition.name }}

      - -
      +
      + +
      +
      +
      +
      + +
      +

      + {{ taskDefinition.name || 'Untitled task' }} +

      +

      + @if (hasChanges && !saving) { + + } + {{ statusLabel }} +

      +
      +
      -
      -
      - +
      -
      -
      -

      Task Details

      -

      Name the task and set target grade

      - -
      - +
      +
      +

      + {{ sectionLabel('task-details') }} +

      +

      The name, code, grade and weight students see.

      +
      + +
      -
      -

      Task Learning Outcomes

      -

      Add learning outcomes for this task

      - -
      - +
      +
      +

      + {{ sectionLabel('task-learning-outcomes') }} +

      +

      How well this task shows each learning outcome of the unit.

      +
      + +
      -
      -

      Inbox

      -

      - Who assesses {{ unit.hasGroupwork() ? 'and submits ' : '' }}this task? +

      +
      +

      {{ sectionLabel('inbox') }}

      +

      + @if (unit.hasGroupwork()) { + Who submits this task, and which tutors give feedback on it. + } @else { + Which tutors give feedback on this task. + }

      - -
      - - -
      -

      Due Dates

      -

      When is the task due?

      - -
      - + + +
      -
      -

      Upload Requirements

      -

      What do students need to upload?

      - -
      - +
      +
      +

      + {{ sectionLabel('due-dates') }} +

      +

      + When students should start, when they should finish, and the last day for feedback. +

      +
      + +
      -
      -

      Task Resources

      -

      Upload task descriptions and resources

      - -
      - +
      +
      +

      + {{ sectionLabel('upload-requirements') }} +

      +

      The files students upload when they submit this task.

      +
      + +
      -
      -

      Prerequisite Tasks

      -

      - Select which tasks need to be submitted before - {{ taskDefinition.abbreviation }} {{ taskDefinition.name }} - can be submitted +

      +
      +

      + {{ sectionLabel('task-resources') }} +

      +

      + The task sheet and any starter files students download. These save as soon as they upload.

      - - -
      - + + +
      -
      -

      Discussion Prompts

      -

      - Discussion prompts for tutors to use when discussing student tasks in class +

      +
      +

      + {{ sectionLabel('prerequisite-tasks') }} +

      +

      + Tasks students must get to a set status before they can submit + {{ taskDefinition.abbreviation }}. These save straight away.

      - - -
      - - - @if (overseerEnabled) { -
      -

      Task Assessment Automation

      -

      Configure automated assessment

      - -
      - - } + + + +
      -
      -

      SCORM Test

      -

      - Upload the corresponding SCORM 2004 test (e.g. Numbas) +

      +
      +

      + {{ sectionLabel('discussion-prompts') }} +

      +

      + Questions tutors can ask when they discuss this task with a student in class.

      - -
      - + + + +
      + @if (overseerEnabled) {
      -

      Optional Settings

      -

      Apply other options

      - +
      +

      + {{ sectionLabel('task-assessment-automation') }} +

      +

      + Run Overseer scripts on each submission and report the results to the student. +

      +
      +
      + } - - - -
      -
      -
      -
      - -
      +
      +
      +

      + {{ sectionLabel('scorm-test') }} +

      +

      + A SCORM 2004 test students take as part of this task, for example one made in Numbas. +

      +
      + +
      + +
      +
      +

      + {{ sectionLabel('optional-settings') }} +

      +

      Extra rules for how this task is marked.

      +
      + +
      diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss index e69de29bb2..f28f99abbe 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.scss @@ -0,0 +1,51 @@ +:host { + display: block; + min-width: 0; + color: var(--ot-color-text); +} + +// Each part of the task is its own card, the same card the student pages use. +// Written once here instead of as eleven copies of the same utility list. +.task-section { + min-width: 0; + padding: 1.25rem; + border: 1px solid var(--ot-color-divider); + border-radius: var(--ot-radius-lg); + background: var(--ot-color-surface); + box-shadow: var(--ot-elevation-1); + color: var(--ot-color-text); + + @media (min-width: 640px) { + padding: 1.5rem; + } + + // Focus arrives here from the section links. Show it to keyboard users only. + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; + } +} + +.task-section__header { + margin-bottom: 1rem; +} + +.task-section__title { + margin: 0; + color: var(--ot-color-text); + font-size: 1rem; + font-weight: 600; + line-height: 1.5rem; +} + +.task-section__hint { + max-width: 70ch; + margin: 0.25rem 0 0; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + line-height: 1.25rem; +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html index a239a50f97..7f653d0b80 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.html @@ -1,114 +1,121 @@ -
      -
      -
      -

      Task List

      -

      Plan the list of tasks for students to complete.

      +
      +
      +
      +

      Tasks

      +

      + {{ taskCountLabel }}. Pick a task to change its details, dates, files and rules. +

      -
      -
      - -
      - - -
      -
      -
      - -
      - - -
      -
      + +
      + + + + + + + + + + @if (unit.allowFlexibleDates) { + + } + +
      -
      -
      - @if (unit.allowFlexibleDates) { - - } - -
      +
      - @if (unit.allowFlexibleDates) { - @if (manageDueDates) { -
      - -
      -
      -

      Manage Target Dates

      -

      Modify or add target dates for each target grade

      + @if (unit.allowFlexibleDates && manageDueDates) { +
      +
      +
      +

      + Dates by grade +

      +

      + Give students aiming for each grade their own start and target dates. A date in grey + italics is the task's own date, used until you pick another. Each change saves straight + away. +

      +
      +
      -
      - + + +
      +
      - @for (grade of gradeColumns; track grade.value) { - - + @@ -117,147 +124,173 @@

      Manage Target Dates

      + + +
      Task - {{ td.abbreviation }} {{ td.name }} + + {{ td.abbreviation }} + + {{ td.name }} + -
      - -
      -
      -
      - - Start Date +
      {{ grade.label }} +
      + + Start - + - - Target Date + + Target - @if (isStartAfterTarget(td, grade)) { - - Target date must be after start date - - } - + + Target is before start
      + +
      - } +
      } -
      +
      -
      +
      @if (selectedTaskDefinition) { } @else { -
      -

      Select a task definition

      -

      Pick a task from the left list to edit its details.

      +
      + +
      + +
      }
      diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss index da9f77f5b0..b3b1de1718 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.scss @@ -1,16 +1,4 @@ -::ng-deep input.mat-mdc-input-element.fallback-date { - font-style: italic; - color: #bfbfbf !important; - -webkit-text-fill-color: #bfbfbf !important; /* fixes some WebKit cases */ -} - -::ng-deep .date-error .mdc-notched-outline__leading, -::ng-deep .date-error .mdc-notched-outline__notch, -::ng-deep .date-error .mdc-notched-outline__trailing { - border-color: #ef4444 !important; // tailwind red-500 -} - -::ng-deep .date-error .mat-mdc-input-element { - color: #ef4444 !important; - -webkit-text-fill-color: #ef4444 !important; +:host { + display: block; + min-width: 0; } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.spec.ts b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.spec.ts new file mode 100644 index 0000000000..0b1b634461 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.spec.ts @@ -0,0 +1,307 @@ +import {describe, expect, it, vi} from 'vitest'; +import {BehaviorSubject, of} from 'rxjs'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {UnitTaskEditorComponent} from './unit-task-editor.component'; + +// The component is constructed directly rather than through TestBed. The three +// handlers under test touch only the selected task definition, the filter and +// the confirmation modal, so a real component tree buys nothing here. +function editorWith(selected: unknown) { + const confirmationModal = {show: vi.fn()}; + const taskDefinitionService = {mapping: {}}; + + const component = new UnitTaskEditorComponent( + taskDefinitionService as never, + {query: () => ({subscribe: () => {}})} as never, // feedbackTemplateService + {error: () => {}} as never, // alerts + {} as never, // csvResultModalService + {} as never, // csvUploadModal + confirmationModal as never, + {} as never, // unitService + ); + + component.selectedTaskDefinition = selected as never; + return {component, confirmationModal}; +} + +// A saved task that has not been edited. hasChanges is what the component asks. +function savedTask(abbreviation: string) { + return { + id: 1, + abbreviation, + isNew: false, + hasOriginalSaveData: true, + hasChanges: () => false, + setOriginalSaveData: () => {}, + }; +} + +// A task built by Add Task and never saved. isNew is true and hasChanges is +// false, because nothing ever recorded original save data for it. +function unsavedTask() { + return { + id: undefined as number | undefined, + abbreviation: '1.1P', + isNew: true, + hasOriginalSaveData: false, + hasChanges: () => false, + setOriginalSaveData: () => {}, + }; +} + +describe('UnitTaskEditorComponent unsaved task guard', () => { + it('keeps an unsaved task on screen while the list is filtered', () => { + const {component} = editorWith(unsavedTask()); + const task = component.selectedTaskDefinition; + + component.taskDefinitionSource.filter = ''; + component.applyFilter('a'); + + expect(component.taskDefinitionSource.filter).toBe('a'); + expect(component.selectedTaskDefinition).toBe(task); + }); + + it('asks before another task replaces an unsaved one', () => { + const {component, confirmationModal} = editorWith(unsavedTask()); + const task = component.selectedTaskDefinition; + const other = savedTask('2.1C'); + + component.selectTaskDefinition(other as never); + + expect(confirmationModal.show).toHaveBeenCalledTimes(1); + expect(confirmationModal.show).toHaveBeenCalledWith( + 'Discard unsaved changes', + 'This task has unsaved changes. If you continue, they will be lost.', + expect.any(Function), + ); + expect(component.selectedTaskDefinition).toBe(task); + + // Running the callback is what the convenor choosing to discard looks like. + const proceed = confirmationModal.show.mock.calls[0][2] as () => void; + proceed(); + + expect(component.selectedTaskDefinition).toBe(other); + }); + + it('asks before a second Add Task discards the first one', () => { + const {component, confirmationModal} = editorWith(unsavedTask()); + + component.createTaskDefinition(); + + expect(confirmationModal.show).toHaveBeenCalledTimes(1); + }); + + // The guard must not get in the way of ordinary use. + it('does not ask when nothing is selected', () => { + const {component, confirmationModal} = editorWith(null); + const other = savedTask('2.1C'); + + component.selectTaskDefinition(other as never); + + expect(confirmationModal.show).not.toHaveBeenCalled(); + expect(component.selectedTaskDefinition).toBe(other); + }); + + it('does not ask when the selected task is saved and untouched', () => { + const {component, confirmationModal} = editorWith(savedTask('1.1P')); + const other = savedTask('2.1C'); + + component.selectTaskDefinition(other as never); + + expect(confirmationModal.show).not.toHaveBeenCalled(); + expect(component.selectedTaskDefinition).toBe(other); + }); +}); + +// A task with real fields, so a discard has something to put back. +function editableTask(): TaskDefinition { + const task = new TaskDefinition({} as never); + task.id = 7; + task.abbreviation = '1.1P'; + task.name = 'Hello world'; + task.targetGrade = 0; + task.startDate = new Date(2026, 2, 2); + task.targetDate = new Date(2026, 2, 16); + task.uploadRequirements = [{key: 'file0', name: 'Code', type: 'code'}]; + return task; +} + +function editorForRealTask(unitService: object = {}) { + const confirmationModal = {show: vi.fn()}; + const alerts = {success: vi.fn(), error: vi.fn()}; + const mapping = new TaskDefinitionService({} as never, {} as never, {} as never, {} as never) + .mapping; + + const component = new UnitTaskEditorComponent( + {mapping} as never, + {query: () => ({subscribe: () => {}})} as never, + alerts as never, + {} as never, + {} as never, + confirmationModal as never, + unitService as never, + ); + component.unit = {deleteTaskDefinition: vi.fn()} as never; + return {component, confirmationModal, alerts}; +} + +describe('UnitTaskEditorComponent discard', () => { + it('puts the saved values back when the convenor discards their edits', () => { + const task = editableTask(); + const {component, confirmationModal} = editorForRealTask(); + component.selectTaskDefinition(task); + + task.name = 'Changed name'; + task.targetDate.setDate(20); + task.uploadRequirements[0].name = 'Report'; + expect(component.taskDefinitionHasChanges(task)).toBe(true); + + component.discardTaskDefinitionChanges(); + const proceed = confirmationModal.show.mock.calls[0][2] as () => void; + proceed(); + + expect(task.name).toBe('Hello world'); + expect(task.targetDate.getDate()).toBe(16); + expect(task.uploadRequirements[0].name).toBe('Code'); + expect(component.taskDefinitionHasChanges(task)).toBe(false); + expect(component.selectedTaskDefinition).toBe(task); + }); + + it('undoes the edits to a task that is left for another one', () => { + const task = editableTask(); + const other = editableTask(); + other.id = 8; + const {component, confirmationModal} = editorForRealTask(); + component.selectTaskDefinition(task); + + task.name = 'Changed name'; + component.selectTaskDefinition(other); + (confirmationModal.show.mock.calls[0][2] as () => void)(); + + expect(component.selectedTaskDefinition).toBe(other); + expect(task.name).toBe('Hello world'); + }); +}); + +describe('UnitTaskEditorComponent discard after leaving the tab', () => { + it('goes back to the saved values, not to edits left from the last visit', () => { + const task = editableTask(); + const first = editorForRealTask(); + first.component.selectTaskDefinition(task); + task.name = 'Changed name'; + first.component.ngOnDestroy(); + + // The tab is built again when the convenor comes back to it. + const second = editorForRealTask(); + second.component.selectTaskDefinition(task); + second.component.discardTaskDefinitionChanges(); + (second.confirmationModal.show.mock.calls[0][2] as () => void)(); + + expect(task.name).toBe('Hello world'); + expect(second.component.taskDefinitionHasChanges(task)).toBe(false); + }); +}); + +describe('UnitTaskEditorComponent after an import', () => { + it('treats what the reload brought in as saved, so Discard does not undo the import', () => { + const task = editableTask(); + const unitService = { + fetch: vi.fn(() => { + // The reload writes the server's copy over the task in place. + task.name = 'Imported name'; + return of({}); + }), + }; + const csvUploadModal = {show: vi.fn()}; + const {component, confirmationModal} = editorForRealTask(unitService); + (component as unknown as {csvUploadModal: object}).csvUploadModal = csvUploadModal; + (component as unknown as {csvResultModalService: object}).csvResultModalService = { + show: vi.fn(), + }; + component.unit = { + id: 5, + taskDefinitions: [task], + getTaskDefinitionBatchUploadUrl: () => 'upload', + } as never; + component.selectTaskDefinition(task); + + component.uploadTaskDefinitionsCsv(); + const onSuccess = csvUploadModal.show.mock.calls[0][4] as (response: object) => void; + onSuccess({success: [{}]}); + + expect(component.taskDefinitionHasChanges(task)).toBe(false); + task.name = 'Edited after the import'; + component.discardTaskDefinitionChanges(); + (confirmationModal.show.mock.calls[0][2] as () => void)(); + expect(task.name).toBe('Imported name'); + }); + + it('asks before an import writes over unsaved edits to the open task', () => { + const task = editableTask(); + const csvUploadModal = {show: vi.fn()}; + const {component, confirmationModal} = editorForRealTask(); + (component as unknown as {csvUploadModal: object}).csvUploadModal = csvUploadModal; + component.selectTaskDefinition(task); + task.name = 'Unsaved'; + + component.uploadTaskResourcesZip(); + + expect(confirmationModal.show).toHaveBeenCalled(); + expect(csvUploadModal.show).not.toHaveBeenCalled(); + }); +}); + +describe('UnitTaskEditorComponent delete', () => { + it('drops a task that was never saved without asking the server to delete it', () => { + const {component, confirmationModal} = editorWith(unsavedTask()); + const unit = {deleteTaskDefinition: vi.fn()}; + component.unit = unit as never; + + component.deleteTaskDefinition(component.selectedTaskDefinition); + (confirmationModal.show.mock.calls[0][2] as () => void)(); + + expect(unit.deleteTaskDefinition).not.toHaveBeenCalled(); + expect(component.selectedTaskDefinition).toBeNull(); + }); + + it('closes the editor when the open task is deleted from the unit', () => { + const task = savedTask('1.1P'); + const other = savedTask('2.1C'); + const values: BehaviorSubject = new BehaviorSubject([task, other]); + const {component} = editorWith(null); + component.unit = {taskDefinitionCache: {values}} as never; + component.ngOnInit(); + component.selectTaskDefinition(task as never); + + values.next([other]); + + expect(component.selectedTaskDefinition).toBeNull(); + component.ngOnDestroy(); + }); +}); + +describe('UnitTaskEditorComponent dates by grade', () => { + const firstGrade = {value: 0, label: 'Pass'} as never; + + it('does not save when the first grade date is cleared while typing', () => { + const {component} = editorWith(null); + const task = {setGradeStartDate: vi.fn(), save: vi.fn()}; + + component.setGradeStartDate(task as never, firstGrade, null); + + expect(task.setGradeStartDate).not.toHaveBeenCalled(); + expect(task.save).not.toHaveBeenCalled(); + }); + + it('flags a cell whose start is after its target', () => { + const {component} = editorWith(null); + const task = editableTask(); + task.startDate = new Date(2026, 2, 20); + + const matcher = component.dateOrderMatcher(task, firstGrade); + + expect(matcher.isErrorState(null, null)).toBe(true); + expect(component.dateOrderMatcher(task, firstGrade)).toBe(matcher); + }); +}); diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts index fb571c5ea7..974ade8536 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts @@ -1,11 +1,21 @@ import {addWeeks} from 'date-fns'; -import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import {ErrorStateMatcher} from '@angular/material/core'; import {MatTableDataSource} from '@angular/material/table'; import {Subscription} from 'rxjs'; import {TaskDefinition} from 'src/app/api/models/task-definition'; import {GradeDefinition, Unit} from 'src/app/api/models/unit'; import {FeedbackTemplateService} from 'src/app/api/services/feedback-template.service'; import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {UnitService} from 'src/app/api/services/unit.service'; import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; import { CsvResult, @@ -13,6 +23,11 @@ import { } from 'src/app/common/modals/csv-result-modal/csv-result-modal.service'; import {CsvUploadModalService} from 'src/app/common/modals/csv-upload-modal/csv-upload-modal.service'; import {AlertService} from 'src/app/common/services/alert.service'; +import { + rememberSavedTaskDefinition, + restoreTaskDefinition, + savedTaskDefinitionCopy, +} from './task-definition-snapshot'; @Component({ selector: 'f-unit-task-editor', @@ -23,21 +38,20 @@ import {AlertService} from 'src/app/common/services/alert.service'; }) export class UnitTaskEditorComponent implements OnInit, OnDestroy { @Input() unit: Unit; + @ViewChild('editorColumn') editorColumn?: ElementRef; public taskDefinitionSource: MatTableDataSource = new MatTableDataSource([]); public filter: string = ''; public selectedTaskDefinition: TaskDefinition; public isTaskListCollapsed: boolean = false; - - public dueDateSource: MatTableDataSource = new MatTableDataSource([]); + public savingTaskDefinition: boolean = false; public manageDueDates: boolean = false; - protected get gradeNames(): Record { - return Object.fromEntries( - this.unit.gradeDefinitions.map((definition) => [definition.value, definition.label]), - ); - } + // One matcher per date cell, kept so the field is not handed a new object on + // every check, which would make Material recompute its error state each time. + private readonly dateOrderMatchers: WeakMap> = + new WeakMap(); public get gradeColumns(): GradeDefinition[] { return this.unit.gradeDefinitions.filter((definition) => definition.value >= 0); @@ -50,10 +64,19 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { ]; } + public get taskCountLabel(): string { + const count = this.unit?.taskDefinitions.length ?? 0; + return count === 1 ? '1 task' : `${count} tasks`; + } + public gradeColumnId(grade: GradeDefinition): string { return `grade-${grade.value}`; } + public gradeLabel(taskDefinition: TaskDefinition): string { + return this.unit.gradeLabel(taskDefinition.targetGrade) ?? ''; + } + isStartAfterTarget(td: TaskDefinition, grade: GradeDefinition): boolean { const start = this.getGradeStartDate(td, grade); const target = this.getGradeDueDate(td, grade); @@ -63,6 +86,25 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { return new Date(start).getTime() > new Date(target).getTime(); } + /** + * Puts both date fields of a cell into Material's error state when the start is + * after the target, so the field draws its own red outline and label. + */ + public dateOrderMatcher(td: TaskDefinition, grade: GradeDefinition): ErrorStateMatcher { + let matchers = this.dateOrderMatchers.get(td); + if (!matchers) { + matchers = new Map(); + this.dateOrderMatchers.set(td, matchers); + } + + let matcher = matchers.get(grade.value); + if (!matcher) { + matcher = {isErrorState: () => this.isStartAfterTarget(td, grade)}; + matchers.set(grade.value, matcher); + } + return matcher; + } + getGradeStartDate(td: TaskDefinition, grade: GradeDefinition): Date | null { return grade.value === 0 ? td.startDate : (td.gradeStartDate(grade.value) ?? td.startDate); } @@ -72,6 +114,11 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { } setGradeStartDate(td: TaskDefinition, grade: GradeDefinition, value: Date | null): void { + // The first grade holds the task's own dates, which cannot be empty. Clearing + // a later grade is fine: it goes back to using the task's own date. + if (grade.value === 0 && !value) { + return; + } td.setGradeStartDate(grade.value, value); this.saveTaskDefinition(td); } @@ -85,6 +132,9 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { } setGradeDueDate(td: TaskDefinition, grade: GradeDefinition, value: Date | null): void { + if (grade.value === 0 && !value) { + return; + } td.setGradeTargetDate(grade.value, value); this.saveTaskDefinition(td); } @@ -96,27 +146,47 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { private csvResultModalService: CsvResultModalService, private csvUploadModal: CsvUploadModalService, private confirmationModal: ConfirmationModalService, + private unitService: UnitService, ) { + // A task with no name or code yet must not stop the search from working. this.taskDefinitionSource.filterPredicate = (data: TaskDefinition, filter: string) => - data.matches(filter); + [data.abbreviation, data.name].some((value) => (value ?? '').toLowerCase().includes(filter)); } ngOnInit(): void { this.subscriptions.push( this.unit.taskDefinitionCache.values.subscribe((taskDefinitions) => { this.taskDefinitionSource.data = taskDefinitions; + + // A deleted task must not stay open in the editor. + const selected = this.selectedTaskDefinition; + if (selected && !selected.isNew && !taskDefinitions.includes(selected)) { + this.selectedTaskDefinition = null; + } }), ); } public saveTaskDefinition(taskDefinition: TaskDefinition) { + const isSelected = taskDefinition === this.selectedTaskDefinition; + if (isSelected) { + this.savingTaskDefinition = true; + } + taskDefinition.save().subscribe({ next: () => { - this.alerts.success('Task Saved'); + this.alerts.success('Task saved'); taskDefinition.setOriginalSaveData(this.taskDefinitionService.mapping); + rememberSavedTaskDefinition(taskDefinition); + if (taskDefinition === this.selectedTaskDefinition) { + this.savingTaskDefinition = false; + } }, error: (error) => { - this.alerts.error(`Failed to update task: ${error}`, 6000); + if (taskDefinition === this.selectedTaskDefinition) { + this.savingTaskDefinition = false; + } + this.alerts.error(`Failed to save the task: ${error}`, 6000); }, }); } @@ -127,22 +197,105 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { } public selectTaskDefinition(taskDefinition: TaskDefinition) { + // Clicking the task that is already open is not a discard, so it must not + // ask. Keep this ahead of the guard. if (this.selectedTaskDefinition === taskDefinition) { return; } + this.confirmDiscardingUnsavedTask(() => this.applySelectedTaskDefinition(taskDefinition)); + } + + private applySelectedTaskDefinition(taskDefinition: TaskDefinition) { this.selectedTaskDefinition = taskDefinition; + this.savingTaskDefinition = false; // Record original save data if none present if (!this.selectedTaskDefinition.hasOriginalSaveData) { this.selectedTaskDefinition.setOriginalSaveData(this.taskDefinitionService.mapping); } + // Only the first time: after that the copy is the last saved state, and this + // task may still carry edits from an earlier visit to the tab. + if (!savedTaskDefinitionCopy(taskDefinition)) { + rememberSavedTaskDefinition(taskDefinition); + } this.feedbackTemplateService .query({contextType: 'task_definitions', contextId: this.selectedTaskDefinition.id}, {}) .subscribe({ error: () => this.alerts.error('Error loading task feedback templates.'), }); + + this.revealEditorOnSmallScreens(); + } + + // On a phone the editor sits below the list, so picking a task scrolls to it. + private revealEditorOnSmallScreens() { + if (typeof window === 'undefined' || !window.matchMedia?.('(max-width: 767px)').matches) { + return; + } + setTimeout(() => this.editorColumn?.nativeElement.scrollIntoView({block: 'start'})); + } + + // A task being edited is unsaved if it has never been saved at all, or if it + // has been changed since it was loaded. + // + // isNew has to be tested and it has to come first. TaskDefinition.hasChanges + // returns false when there is no originalSaveData, and only selectTaskDefinition + // and a successful save ever set that, so a brand new task with every field + // filled in reports no changes whatsoever. A guard built on hasChanges alone + // protects the case that was already safe and leaves this one exactly as it was. + private hasUnsavedTaskDefinition(): boolean { + const selected = this.selectedTaskDefinition; + return !!selected && (selected.isNew || this.taskDefinitionHasChanges(selected)); + } + + public get selectedTaskDefinitionHasChanges(): boolean { + return this.hasUnsavedTaskDefinition(); + } + + private confirmDiscardingUnsavedTask(proceed: () => void) { + if (!this.hasUnsavedTaskDefinition()) { + proceed(); + return; + } + + this.confirmationModal.show( + 'Discard unsaved changes', + 'This task has unsaved changes. If you continue, they will be lost.', + () => { + // The edits live on the task object itself, so they have to be undone + // here. Without this they stayed in memory, showed up elsewhere in the + // app and went to the server with the next save of this task. + this.revertSelectedTaskDefinition(); + proceed(); + }, + ); + } + + private revertSelectedTaskDefinition() { + const selected = this.selectedTaskDefinition; + const savedCopy = selected && !selected.isNew && savedTaskDefinitionCopy(selected); + if (!savedCopy) { + return; + } + + restoreTaskDefinition(selected, savedCopy); + selected.setOriginalSaveData(this.taskDefinitionService.mapping); + } + + /** Throw away the edits to the open task, or the whole task if it was never saved. */ + public discardTaskDefinitionChanges(): void { + const selected = this.selectedTaskDefinition; + if (!selected) { + return; + } + + this.confirmDiscardingUnsavedTask(() => { + if (selected.isNew) { + this.selectedTaskDefinition = null; + } + }); } public isSelectedTaskDefinition(taskDefinition: TaskDefinition): boolean { @@ -160,6 +313,15 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { this.taskDefinitionSource.filter = filterValue.trim().toLowerCase(); + // No dialog here on purpose. The box is [(ngModel)] bound and this runs from + // ngModelChange, so the character is already typed by the time we see it, and + // cancelling would mean writing the text back one keystroke at a time while + // the convenor answers a modal per letter. Filtering the list underneath an + // open editor is the behaviour that was wanted anyway. + if (this.hasUnsavedTaskDefinition()) { + return; + } + this.selectedTaskDefinition = null; } @@ -183,9 +345,16 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { } public deleteTaskDefinition(taskDefinition: TaskDefinition) { + // A task that was never saved has nothing on the server to delete. Asking the + // server anyway sent a request with no id and showed an error. + if (taskDefinition.isNew) { + this.discardTaskDefinitionChanges(); + return; + } + this.confirmationModal.show( - `Delete Task ${taskDefinition.abbreviation}`, - 'Are you sure you want to delete this task? This action is final and will delete student work associated with this task.', + `Delete task ${taskDefinition.abbreviation}`, + 'This deletes the task and all the work students have submitted for it. You cannot undo this.', () => { this.unit.deleteTaskDefinition(taskDefinition); //TODO: reinstate ProgressModal.show "Deleting Task #{task.abbreviation}", 'Please wait while student projects are updated.', promise @@ -193,39 +362,65 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { ); } + // An import reloads the unit, and the reload writes the server's copy over every + // task, the open one included. So any unsaved edits go first, with a warning. public uploadTaskDefinitionsCsv() { - this.csvUploadModal.show( - 'Upload Task Definitions as CSV', - 'Upload a CSV of task definitions.', - {file: {name: 'Task Definition CSV Data', type: 'csv'}}, - this.unit.getTaskDefinitionBatchUploadUrl(), - (response: CsvResult) => { - // at least one student? - this.csvResultModalService.show('Task Definition Import Results', response); - if (response.success.length > 0) { - this.unit.refresh(); - } - }, + this.confirmDiscardingUnsavedTask(() => + this.csvUploadModal.show( + 'Upload task list', + 'Upload a CSV of task definitions.', + {file: {name: 'Task Definition CSV Data', type: 'csv'}}, + this.unit.getTaskDefinitionBatchUploadUrl(), + (response: CsvResult) => { + // at least one student? + this.csvResultModalService.show('Task list import results', response); + if (response.success.length > 0) { + this.reloadUnitAfterImport(); + } + }, + ), ); } public uploadTaskResourcesZip() { - this.csvUploadModal.show( - 'Upload Task Sheets and Resources as Zip', - 'Upload a ZIP of task sheets and resources.', - {file: {name: 'Task Sheets and Resources', type: 'zip'}}, - this.unit.taskUploadUrl, - (response: CsvResult) => { - // at least one student? - this.csvResultModalService.show('Task Sheet and Resources Import Results', response); - if (response.success.length > 0) { - this.unit.refresh(); + this.confirmDiscardingUnsavedTask(() => + this.csvUploadModal.show( + 'Upload task sheets and resources', + 'Upload a ZIP of task sheets and resources.', + {file: {name: 'Task Sheets and Resources', type: 'zip'}}, + this.unit.taskUploadUrl, + (response: CsvResult) => { + // at least one student? + this.csvResultModalService.show('Task sheet and resource import results', response); + if (response.success.length > 0) { + this.reloadUnitAfterImport(); + } + }, + ), + ); + } + + // The reload updates each task in place with what the server now holds, so + // that is the new saved state: the copy Discard goes back to, and what unsaved + // changes are measured against. Without this Discard put back the values from + // before the import. + private reloadUnitAfterImport() { + this.unitService.fetch(this.unit.id).subscribe({ + next: () => { + for (const taskDefinition of this.unit.taskDefinitions) { + taskDefinition.setOriginalSaveData(this.taskDefinitionService.mapping); + rememberSavedTaskDefinition(taskDefinition); } }, - ); + error: (message) => this.alerts.error(message, 6000), + }); } public createTaskDefinition() { + this.confirmDiscardingUnsavedTask(() => this.buildNewTaskDefinition()); + } + + private buildNewTaskDefinition() { const abbr = this.guessTaskAbbreviation(); const task = new TaskDefinition(this.unit); @@ -244,5 +439,7 @@ export class UnitTaskEditorComponent implements OnInit, OnDestroy { task.tutorialStream = this.unit.tutorialStreams[0]; this.selectedTaskDefinition = task; + this.savingTaskDefinition = false; + this.revealEditorOnSmallScreens(); } } diff --git a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html index 965230a659..6cc535e241 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html +++ b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.html @@ -1,278 +1,360 @@ -
      -
      +
      +
      @if (stream) { -
      - @if (!editingStream) { -
      -

      {{ stream.name }} - {{ stream.abbreviation }}

      - + @if (!editingStream) { +
      +

      + {{ stream.name }} +

      +

      + {{ stream.abbreviation }} · + {{ tutorials.length === 1 ? '1 tutorial' : tutorials.length + ' tutorials' }} +

      +
      + + + } @else { +

      Rename {{ stream.name }}

      +
      +
      + + + +
      - } @else { - - Name - - - - - - Abbreviation - - - - - - - } -
      +
      + + + + +
      +
      +
      + + +
      + } } @else { -

      Tutorials without a stream

      +
      +

      + Tutorials without a stream +

      +

      + Older tutorials that are not in any stream. New tutorials go in a stream. +

      +
      } -
      +
      - - - - - + + + + + @if (canAddTutorials) { + + } +
      Abbreviation - @if (!editing(tutorial)) { -
      - {{ tutorial.abbreviation }} -
      - } @else { - - + +
      + + + + + - - + + - - - - + + - - + + - - - - + + - - + + - - - - + + - - + + - - - - + + - - + + - - - - + + - - + + - - - - + + - - + + - - - - + + - - + + - - - -
      Tutorial + @if (!editing(tutorial)) { + {{ tutorial.abbreviation }} + } @else { + + + + } + + + - } - - - - - Campus - @if (!editing(tutorial)) { -
      - {{ tutorial.campus ? tutorial.campus.name : '' }} -
      - } @else { - - Campus - - Not Specified - @for (campus of campuses; track campus) { - - {{ campus.name }} - + +
      Campus + @if (!editing(tutorial)) { + {{ tutorial.campus?.name }} + } @else { + + + Any campus + @for (campus of campuses; track campus.id) { + {{ campus.name }} + } + + + } + + + + Any campus + @for (campus of campuses; track campus.id) { + {{ campus.name }} } - } - - - Campus - - Not Specified - @for (campus of campuses; track campus) { - - {{ campus.name }} - - } - - - Location - @if (!editing(tutorial)) { -
      + +
      Location + @if (!editing(tutorial)) { {{ tutorial.meetingLocation }} - - } @else { - - + } @else { + + + + } + + + - } - - - - - Day - @if (!editing(tutorial)) { -
      + +
      Day + @if (!editing(tutorial)) { {{ tutorial.meetingDay }} - - } @else { - - Day - + } @else { + + + @for (day of days; track day) { + {{ day }} + } + + + } + + + @for (day of days; track day) { - - {{ day }} - + {{ day }} } - } - - - Day - - @for (day of days; track day) { - - {{ day }} - - } - - - Time - @if (!editing(tutorial)) { -
      + +
      Time + @if (!editing(tutorial)) { {{ tutorial.meetingTime }} - - } @else { - + } @else { + + + + } + + - } - - - - - Tutor - @if (!editing(tutorial)) { -
      + +
      Tutor + @if (!editing(tutorial)) { {{ tutorial.tutor?.name }} - - } @else { - - Tutor - - @for (tutor of unit.staffUsers; track tutor) { - - {{ tutor.name }} - + } @else { + + + @for (tutor of unit.staffUsers; track tutor.id) { + {{ tutor.name }} + } + + + } + + + + @for (tutor of unit.staffUsers; track tutor.id) { + {{ tutor.name }} } - } - - - Tutor - - @for (tutor of unit.staffUsers; track tutor) { - - {{ tutor.name }} - - } - - - Capacity - @if (!editing(tutorial)) { -
      {{ tutorial.numStudents }} / {{ tutorial.capacity }}
      - } @else { - - + +
      Students + @if (!editing(tutorial)) { + {{ capacityLabel(tutorial) }} + } @else { + + + + } + + + - } - - - - - - @if (!editing(tutorial)) { -
      - + +
      + Actions + +
      + @if (!editing(tutorial)) { + + } @else { + + + + }
      - } @else { -
      - - -
      +
      +
      - } -
      -
      - -
      -
      -
      +
      + +
      +
      + diff --git a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts index a8ff6e0091..e93c197669 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts +++ b/src/app/units/states/edit/directives/unit-tutorials-list/unit-tutorials-list.component.ts @@ -9,7 +9,7 @@ import { ViewChild, } from '@angular/core'; import {UntypedFormControl, Validators} from '@angular/forms'; -import {MatSort, Sort} from '@angular/material/sort'; +import {MatSort} from '@angular/material/sort'; import {MatTable, MatTableDataSource} from '@angular/material/table'; import {Subscription} from 'rxjs'; import { @@ -95,6 +95,23 @@ export class UnitTutorialsListComponent }, 'Tutorial', ); + + // Sort on what each column shows. The column ids are not the property names, so the + // table's default look-up sorted location, day and time on undefined. + this.dataSource.sortingDataAccessor = (tutorial: Tutorial, column: string) => + this.sortValue(tutorial, column); + } + + /** + * Tutorials without a stream are older ones. The api only creates tutorials inside a + * stream, so that list has no row for adding one. + */ + public get canAddTutorials(): boolean { + return !!this.stream; + } + + public get headingId(): string { + return `tutorial-stream-${this.stream?.abbreviation ?? 'none'}`; } ngOnInit(): void { @@ -103,9 +120,13 @@ export class UnitTutorialsListComponent this.origName = this.stream.name; } - this.campusService.query().subscribe((campuses) => { - this.campuses.push(...campuses); - }); + this.dataSource.sort = this.sort; + + this.subscriptions.push( + this.campusService.query().subscribe((campuses) => { + this.campuses = [...campuses]; + }), + ); this.filterTutorials(); @@ -118,6 +139,45 @@ export class UnitTutorialsListComponent this.subscriptions.forEach((subscription) => subscription.unsubscribe()); } + // A capacity of -1 means the tutorial has no limit. + public capacityLabel(tutorial: Tutorial): string { + const enrolled = tutorial.numStudents ?? 0; + if (Number(tutorial.capacity) === -1) { + return `${enrolled}, no limit`; + } + return `${enrolled} of ${tutorial.capacity ?? 0}`; + } + + public sortValue(tutorial: Tutorial, column: string): string | number { + switch (column) { + case 'campus': + return tutorial.campus?.name ?? ''; + case 'location': + return tutorial.meetingLocation ?? ''; + case 'day': { + const index = this.days.indexOf(tutorial.meetingDay); + return index >= 0 ? index : this.days.length; + } + case 'time': + return this.minutesIntoDay(tutorial.meetingTime); + case 'tutor': + return tutorial.tutor?.name ?? ''; + case 'capacity': + return Number(tutorial.capacity) || 0; + default: + return tutorial.abbreviation ?? ''; + } + } + + // Times are typed by hand, so "9:30" has to sort before "10:00". + private minutesIntoDay(time: string): number { + const match = /^(\d{1,2}):(\d{2})/.exec(time ?? ''); + if (!match) { + return Number.MAX_SAFE_INTEGER; + } + return Number(match[1]) * 60 + Number(match[2]); + } + private filterTutorials(): void { this.tutorials = this.unit.tutorials.filter( (tutorial) => @@ -127,22 +187,43 @@ export class UnitTutorialsListComponent } public saveStream(): void { + const previousAbbreviation = this.origStreamAbbr; this.tutorialStreamService - .update({abbreviation: this.origStreamAbbr, unit_id: this.unit.id}, {entity: this.stream}) + .update({abbreviation: previousAbbreviation, unit_id: this.unit.id}, {entity: this.stream}) .subscribe({ next: (stream: TutorialStream) => { this.stream = stream; this.origStreamAbbr = stream.abbreviation; this.origName = stream.name; this.editingStream = false; - this.alerts.success('Stream updated successfully', 2000); + if (stream.abbreviation !== previousAbbreviation) { + this.rekeyStream(stream, previousAbbreviation); + } + this.alerts.success(`${stream.name} saved`, 2000); }, error: (error: HttpErrorResponse) => { - this.alerts.error('Something went wrong - ' + JSON.stringify(error.error), 6000); + this.alerts.error(`Could not save the stream. ${this.errorText(error)}`, 6000); }, }); } + /** + * The unit keeps its streams under their short name. After a rename the old name + * still pointed at the stream, so deleting it afterwards could not find it and it + * stayed on the page. The streams are filed again in their order, so the renamed one + * does not jump to the bottom. A stream with an unsaved rename of its own is left + * where it is, since the name it is filed under is not known from here, and filing + * it again would list it twice. + */ + private rekeyStream(stream: TutorialStream, previousAbbreviation: string): void { + const cache = this.unit.tutorialStreamsCache; + const ordered = cache.currentValuesClone(); + cache.delete(previousAbbreviation); + const refile = ordered.filter((other) => other === stream || cache.get(other.key) === other); + refile.filter((other) => other !== stream).forEach((other) => cache.delete(other)); + refile.forEach((other) => cache.add(other)); + } + public setEditStream(value: boolean): void { if (!value) { this.stream.abbreviation = this.origStreamAbbr; @@ -153,38 +234,35 @@ export class UnitTutorialsListComponent // This method is passed to the submit method on the parent // and is only run when an entity is successfully created or updated - onSuccess(response: Tutorial, isNew: boolean): void { - if (isNew) { - this.pushToTable(response); - } - } - - // Push the values that will be displayed in the table - // to the datasource - private pushToTable(value: Tutorial | Tutorial[]) { - if (!value) { - return; - } - if (value instanceof Array) { - this.tutorials.push(...value); - } else { - this.tutorials.push(value); - } - this.renderTable(); + onSuccess(_response: Tutorial, _isNew: boolean): void { + // A new tutorial reaches the unit's cache before this runs, so reading the list + // again picks it up once. Pushing it here as well showed it twice. + this.filterTutorials(); } // Handle the removal of a tutorial public deleteTutorial(tutorial: Tutorial): void { - this.tutorialService.delete(tutorial, this.optionsOnRequest('delete')).subscribe((_result) => { - this.cancelEdit(); - this.filterTutorials(); - this.renderTable(); - }); - } - - private renderTable() { - this.dataSource.sort = this.sort; - this.table.renderRows(); + this.confirmationModal.show( + `Delete ${tutorial.abbreviation}?`, + 'Students in this tutorial will no longer be enrolled in it. This cannot be undone.', + () => { + this.tutorialService.delete(tutorial, this.optionsOnRequest('delete')).subscribe({ + next: () => { + this.cancelEdit(); + this.filterTutorials(); + this.alerts.success(`${tutorial.abbreviation} deleted`, 2000); + }, + error: (error) => { + this.alerts.error( + `Could not delete ${tutorial.abbreviation}. ${this.errorText(error)}`, + 6000, + ); + }, + }); + }, + undefined, + 'Delete', + ); } // This method is called when the form is submitted, @@ -193,28 +271,33 @@ export class UnitTutorialsListComponent super.submit(this.tutorialService, this.alerts, this.onSuccess.bind(this)); } - protected formDataToNewObject(endPointKey: string, _associations?: object): object { - this.selected = new Tutorial(this.unit); - this.copyChangesFromForm(); - this.selected.tutorialStream = this.stream; - super.formDataToNewObject(endPointKey); - return this.selected; + /** + * The new tutorial is built on the side, not as the selected row. The base form + * treats a selected row as one being edited, so a create that failed used to leave + * the add row hidden until the page was reloaded. + */ + protected formDataToNewObject(_endPointKey: string, _associations?: object): object { + const tutorial = new Tutorial(this.unit); + for (const key of Object.keys(this.formData.controls)) { + tutorial[key] = this.formData.get(key).value; + } + tutorial.tutorialStream = this.stream; + return tutorial; } - // This comparison function is required to determine what campus or user - // to render in the associated mat-select components when editing a - // tutorial. The function is bound to the compareFn attribute on the related - // mat-selects. - // See: https://angular.io/api/forms/SelectControlValueAccessor - compareSelection(aEntity: User | Campus | {user_id: number}, bEntity: User | Campus) { + // Which campus or tutor the selects show when a row is edited. A tutorial's tutor is + // a user, so users and campuses both match on id. The user_id form is kept for any + // older value still shaped that way. + compareSelection( + aEntity: User | Campus | {user_id: number} | null, + bEntity: User | Campus | {user_id: number} | null, + ): boolean { if (!aEntity || !bEntity) { - return; - } - if (bEntity instanceof User) { - return 'user_id' in aEntity && aEntity.user_id === bEntity.id; - } else { - return 'id' in aEntity && aEntity.id === bEntity.id; + return aEntity === bEntity; } + const idOf = (entity: object) => + 'user_id' in entity ? entity.user_id : (entity as {id?: number}).id; + return idOf(aEntity) === idOf(bEntity); } // Handle the deletion of a stream @@ -222,21 +305,23 @@ export class UnitTutorialsListComponent const stream: TutorialStream = this.stream; this.confirmationModal.show( - `Delete Tutorial Stream ${stream.abbreviation}`, - 'Are you sure you want to delete this tutorial stream? This action is final and will delete all associated tutorials.', + `Delete ${stream.name}?`, + `This deletes the ${stream.abbreviation} stream and every tutorial in it. This cannot be undone.`, () => this.unit.deleteStream(stream).subscribe({ next: (response: boolean) => { if (response) { - this.alerts.success(`Deleted stream. ${stream.abbreviation}`, 8000); + this.alerts.success(`${stream.name} deleted`, 4000); } else { - this.alerts.error(`Failed to delete stream.`, 8000); + this.alerts.error(`Could not delete ${stream.name}.`, 8000); } }, error: (message) => { - this.alerts.error(`Failed to delete stream. ${message}`, 8000); + this.alerts.error(`Could not delete ${stream.name}. ${message}`, 8000); }, }), + undefined, + 'Delete', ); } @@ -252,34 +337,10 @@ export class UnitTutorialsListComponent }; } - // Sorting function to sort data when sort - // event is triggered - sortTableData(sort: Sort): void { - if (!sort.active || sort.direction === '') { - return; - } - switch (sort.active) { - case 'abbreviation': - case 'location': - case 'day': - case 'time': - case 'capacity': - return super.sortTableData(sort); + private errorText(error: unknown): string { + if (error instanceof HttpErrorResponse) { + return error.error?.error ?? error.message; } - this.dataSource.data = this.dataSource.data.sort((a, b) => { - const isAsc = sort.direction === 'asc'; - switch (sort.active) { - case 'campus': - return this.sortCompare( - a.campus ? a.campus.abbreviation : '', - b.campus ? b.campus.abbreviation : '', - isAsc, - ); - case 'tutor': - return this.sortCompare(a.tutor.name, b.tutor.name, isAsc); - default: - return 0; - } - }); + return `${error ?? ''}`; } } diff --git a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html index b0ccf6bd49..d2ad831ea5 100644 --- a/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html +++ b/src/app/units/states/edit/directives/unit-tutorials-manager/unit-tutorials-manager.component.html @@ -1,29 +1,53 @@ -
      - +
      +
      +

      + {{ + unit.tutorialStreams.length === 1 ? '1 stream' : unit.tutorialStreams.length + ' streams' + }} + + {{ tutorialCount === 1 ? '1 tutorial' : tutorialCount + ' tutorials' }} +

      +
      + @if (!loadingActivityTypes && activityTypes.length === 0) { + + There are no activity types to make a stream from. An admin can add them. + + } + +
      +
      + @for (stream of unit.tutorialStreams; track stream) { - } @empty { -
      -
      - groups -

      No Tutorials

      -

      - There are no tutorials yet. Create a tutorial stream to begin adding tutorials. -

      -
      -
      } -
      - -
      + + @if (hasTutorialsWithoutStream) { + + } + + @if (unit.tutorialStreams.length === 0 && !hasTutorialsWithoutStream) { +
      + +
      + } - @for (activityType of activityTypes; track activityType) { - } diff --git a/src/app/units/states/edit/unit-admin-state.component.html b/src/app/units/states/edit/unit-admin-state.component.html index fe46cc34fe..5449a51efd 100644 --- a/src/app/units/states/edit/unit-admin-state.component.html +++ b/src/app/units/states/edit/unit-admin-state.component.html @@ -1,125 +1,146 @@ -
      - - @for (tab of tabs; track tab.routeSegment) { - + +
      +
      +

      + Unit administration +

      +

      + @if (unit) { + {{ unit.code }} {{ unit.name }} + @if (unit.teachingPeriod?.name) { + {{ unit.teachingPeriod.name }} + } + } @else if (loadingUnit) { + Loading the unit… + } @else { + Set up how a unit runs, who teaches it and who is enrolled. + } +

      +
      + + @if (unit) { + } - +
      -
      - @if (loadingUnit) { -
      -
      - - -
      + @if (unit) { + + + } + -
      - - - - -
      + + @if (loadingUnit) { + +
      + +
      - } @else { - @switch (currentTab.routeSegment) { - @case ('details') { - @if (unit) { -
      - -
      - } - } - @case ('learning-outcomes') { - @if (unit) { -
      -
      -

      Unit Learning Outcomes

      -

      Manage unit outcomes and related feedback templates.

      -
      - -
      - } - } - @case ('staff') { - @if (unit) { -
      - -
      - } - } - @case ('tutorials') { - @if (unit) { -
      -
      -

      Tutorials

      -

      Manage tutorial streams and classes for this unit.

      -
      - -
      - } - } - @case ('students') { - @if (unit) { -
      - -
      - } - } - @case ('tasks') { - @if (unit) { -
      - -
      - } - } - @case ('groups') { - @if (unit) { -
      -
      -

      Groups

      -

      Configure group sets and manage team-based activities.

      -
      - -
      - } - } - @case ('communication') { - @if (unit) { -
      -
      -

      Communication System

      -

      - Create communication sets and rules to target students and automate follow-up - actions. -

      -
      - -
      - } - } +
      + } @else if (!unit) { + +
      + + +
      +
      + } @else { + @if (currentTab.description) { + +
      +

      + {{ currentTab.label }} +

      +

      + {{ currentTab.description }} +

      +
      +
      + } + + @switch (currentTab.routeSegment) { + @case ('details') { + + + + } + @case ('learning-outcomes') { + + + + } + @case ('staff') { + + + + } + @case ('tutorials') { + + + + } + @case ('students') { + + + + } + @case ('tasks') { + + + + + } + @case ('groups') { + + + + } + @case ('communication') { + + + } } -
      -
      + } + diff --git a/src/app/units/states/groups/unit-groups/unit-groups.component.html b/src/app/units/states/groups/unit-groups/unit-groups.component.html index 9696d512a3..f08a655e42 100644 --- a/src/app/units/states/groups/unit-groups/unit-groups.component.html +++ b/src/app/units/states/groups/unit-groups/unit-groups.component.html @@ -1,21 +1,50 @@ -@if (unit.hasGroupwork()) { -
      - - -
      -} @else { -
      - groups + +
      +
      +

      Groups

      +

      + Create groups, choose who is in each one, and lock a group once it is final. +

      +
      -

      No Groupwork Enabled

      + @if (canManageGroupSets && unit?.hasGroupwork()) { + + + Manage group sets + + } +
      -

      - There is no group work enabled for this unit. Convenors should create at least one group set - first for groups to be created. This can be found under the groups tab in the unit - administration settings. -

      - -
      -} + @if (unit?.hasGroupwork()) { + + } @else if (unit) { +
      + + @if (canManageGroupSets) { + + Set up group work + + } +
      + } +
      diff --git a/src/app/units/states/groups/unit-groups/unit-groups.component.ts b/src/app/units/states/groups/unit-groups/unit-groups.component.ts index 16d8612a5c..90fd682fce 100644 --- a/src/app/units/states/groups/unit-groups/unit-groups.component.ts +++ b/src/app/units/states/groups/unit-groups/unit-groups.component.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; -import {Observable, Subscription, of} from 'rxjs'; +import {Observable, Subscription, distinctUntilChanged, of} from 'rxjs'; import {GroupSet, Unit, UnitRole, UserService} from 'src/app/api/models/doubtfire-model'; import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; @@ -9,7 +9,6 @@ import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global @Component({ selector: 'f-unit-groups', templateUrl: './unit-groups.component.html', - styleUrl: './unit-groups.component.scss', changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) @@ -30,21 +29,32 @@ export class UnitGroupsComponent implements OnInit, OnDestroy { ngOnInit(): void { this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); - this.unitSub = this.unit$?.subscribe((unit) => { - if (!unit) { - return; - } + this.unitSub = this.unit$ + ?.pipe(distinctUntilChanged((a, b) => a?.id === b?.id)) + .subscribe((unit) => { + if (!unit) { + return; + } - this.unit = unit; - this.unitRole = this.findUnitRole(unit.id); - this.selectedGroupSet = this.selectedGroupSet ?? unit.groupSets?.[0]; - }); + this.unit = unit; + this.unitRole = this.findUnitRole(unit.id); + this.selectedGroupSet = + this.selectedGroupSet?.unit?.id === unit.id ? this.selectedGroupSet : unit.groupSets?.[0]; + }); } ngOnDestroy(): void { this.unitSub?.unsubscribe(); } + /** + * Group sets are made in unit administration, which only convenors, admins and + * auditors can open. Tutors are not offered a link they would be turned away from. + */ + public get canManageGroupSets(): boolean { + return !!this.unit?.currentUserCanViewUnitAdmin; + } + private findUnitRole(unitId: number): UnitRole { const currentView = this.globalStateService.currentViewAndEntitySubject$.value; @@ -55,8 +65,10 @@ export class UnitGroupsComponent implements OnInit, OnDestroy { } } + // Same null-safe lookup as the route guard: a role whose unit is not filled in yet + // used to throw here and leave the page blank. let unitRole = this.globalStateService.loadedUnitRoles.currentValues.find( - (role) => role.unit.id === unitId, + (role) => role.unit?.id === unitId, ); if ( diff --git a/src/app/units/states/rollover/rollover.component.html b/src/app/units/states/rollover/rollover.component.html index 1ad9d9c595..8ebf851b84 100644 --- a/src/app/units/states/rollover/rollover.component.html +++ b/src/app/units/states/rollover/rollover.component.html @@ -1,67 +1,114 @@ -
      - - - Copy {{ unit?.code }} {{ unit?.nameAndPeriod }} - - - -

      - Duplicate this unit by copying it to the indicated teaching period, or a custom start and - end date. + +

      +
      +

      + Roll over unit +

      +

      + @if (unit) { + Make a copy of {{ unit.code }} {{ unit.name }} for a new teaching period. + } @else { + Loading the unit... + }

      +
      +
      -
      - - Teaching Period + @if (!unit) { +
      + Loading the unit + +
      + } @else { +
      +
      +
      +

      + When the new unit runs +

      +

      + Pick a teaching period, or set your own start and end dates. +

      +
      + + + Teaching period - Custom Date @for (period of teachingPeriods; track period) { {{ period.year }} {{ period.period }} } + Custom dates + @if (!teachingPeriods.length) { + No teaching periods are coming up, so set the dates yourself + } -
      - @if (!teachingPeriod) { + @if (teachingPeriod) { +
      +
      +
      Starts
      +
      {{ teachingPeriod.startDate | date }}
      +
      +
      +
      Ends
      +
      {{ teachingPeriod.endDate | date }}
      +
      +
      + } @else { +
      - Start Date - - DD/MM/YYYY + Start date + + Pick a start date - End Date - - DD/MM/YYYY + End date + + Pick an end date - } @else { - - {{ teachingPeriod.name }} Start Date - - - - {{ teachingPeriod.name }} End Date - - +
      + @if (customDatesOutOfOrder) { + } -
      + }
      - - - - - -
      + +
      + +
      + + } + diff --git a/src/app/units/states/rollover/rollover.component.spec.ts b/src/app/units/states/rollover/rollover.component.spec.ts new file mode 100644 index 0000000000..f9d9da4600 --- /dev/null +++ b/src/app/units/states/rollover/rollover.component.spec.ts @@ -0,0 +1,163 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {BehaviorSubject, NEVER, of} from 'rxjs'; +import {TeachingPeriod} from 'src/app/api/models/teaching-period'; +import {RolloverComponent, asUtcDay} from './rollover.component'; + +function rollover() { + const router = {navigate: vi.fn(), navigateByUrl: vi.fn()}; + const alerts = {success: vi.fn(), error: vi.fn()}; + const component = new RolloverComponent( + {} as never, + {} as never, + alerts as never, + router as never, + {} as never, + {} as never, + ); + const unit = {rolloverTo: vi.fn((_body: object) => NEVER)}; + component.unit = unit as never; + component.teachingPeriod = false; + return {component, unit, router}; +} + +describe('RolloverComponent', () => { + it('sends the picked days as UTC dates, so the server keeps the same days', () => { + const {component, unit} = rollover(); + component.newStartDate = new Date(2027, 2, 1); + component.newEndDate = new Date(2027, 5, 30); + + component.createUnit(); + + const body = unit.rolloverTo.mock.calls[0][0] as {start_date: Date; end_date: Date}; + expect(body.start_date.toISOString()).toBe('2027-03-01T00:00:00.000Z'); + expect(body.end_date.toISOString()).toBe('2027-06-30T00:00:00.000Z'); + }); + + it('makes one unit however many times Create unit is clicked', () => { + const {component, unit} = rollover(); + component.newStartDate = new Date(2027, 2, 1); + component.newEndDate = new Date(2027, 5, 30); + + component.createUnit(); + component.createUnit(); + + expect(unit.rolloverTo).toHaveBeenCalledTimes(1); + }); + + it('will not create a unit that ends before it starts', () => { + const {component, unit} = rollover(); + component.newStartDate = new Date(2027, 5, 30); + component.newEndDate = new Date(2027, 2, 1); + + component.createUnit(); + + expect(component.customDatesOutOfOrder).toBe(true); + expect(unit.rolloverTo).not.toHaveBeenCalled(); + }); + + it('opens the new unit once it is made', () => { + const {component, unit, router} = rollover(); + unit.rolloverTo.mockReturnValue(of({id: 42}) as never); + component.teachingPeriod = {id: 7} as never; + + component.createUnit(); + + expect(unit.rolloverTo).toHaveBeenCalledWith({teaching_period_id: 7}); + expect(router.navigate).toHaveBeenCalledWith(['/units', 42, 'admin']); + }); + + it('keeps the calendar day when making a UTC date', () => { + expect(asUtcDay(new Date(2027, 0, 31, 23, 30)).toISOString()).toBe('2027-01-31T00:00:00.000Z'); + }); +}); + +function period(id: number, startDate: Date, endDate = new Date(2027, 0, 1)): TeachingPeriod { + return Object.assign(new TeachingPeriod(), {id, startDate, endDate}); +} + +describe('RolloverComponent teaching-period defaults', () => { + let component: RolloverComponent; + let periods: BehaviorSubject; + let query: ReturnType; + + beforeEach(() => { + vi.useFakeTimers({toFake: ['Date']}); + vi.setSystemTime(new Date(2026, 8, 21, 13)); + periods = new BehaviorSubject([]); + query = vi.fn(); + component = new RolloverComponent( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {cache: {values: periods}, query} as never, + ); + component.initUnit(); + }); + + afterEach(() => { + periods.complete(); + vi.useRealTimers(); + }); + + it('sorts non-ended periods without mutating the shared cache and selects the next start', () => { + const current = period(1, new Date(2026, 6, 1)); + const next = period(2, new Date(2026, 9, 1)); + const later = period(3, new Date(2026, 10, 1)); + const ended = period(4, new Date(2026, 0, 1), new Date(2026, 8, 20)); + const cachedOrder = [next, ended, current, later]; + + periods.next(cachedOrder); + + expect(component.teachingPeriods).toEqual([current, next, later]); + expect(component.teachingPeriod).toBe(next); + expect(cachedOrder).toEqual([next, ended, current, later]); + expect(query).not.toHaveBeenCalled(); + }); + + it('treats a period starting today as the next start throughout the local calendar day', () => { + const today = period(1, new Date(2026, 8, 21)); + const tomorrow = period(2, new Date(2026, 8, 22)); + + periods.next([today, tomorrow]); + + expect(component.teachingPeriod).toBe(today); + }); + + it('falls back to the most recently started current period when no upcoming period exists', () => { + const older = period(1, new Date(2026, 6, 1)); + const recent = period(2, new Date(2026, 8, 1)); + + periods.next([recent, older]); + + expect(component.teachingPeriods).toEqual([older, recent]); + expect(component.teachingPeriod).toBe(recent); + }); + + it('uses the period id to keep equal start dates independent of cache insertion order', () => { + const first = period(1, new Date(2026, 9, 1)); + const second = period(2, new Date(2026, 9, 1)); + + periods.next([second, first]); + expect(component.teachingPeriods).toEqual([first, second]); + expect(component.teachingPeriod).toBe(first); + + periods.next([first, second]); + expect(component.teachingPeriods).toEqual([first, second]); + expect(component.teachingPeriod).toBe(first); + }); + + it('clears the selected period when a later cache update contains no eligible period', () => { + const upcoming = period(1, new Date(2026, 9, 1)); + const ended = period(2, new Date(2026, 0, 1), new Date(2026, 8, 20)); + periods.next([upcoming]); + expect(component.teachingPeriod).toBe(upcoming); + + periods.next([ended]); + + expect(component.teachingPeriods).toEqual([]); + // Cleared to false, which the page reads as custom dates. + expect(component.teachingPeriod).toBe(false); + }); +}); diff --git a/src/app/units/states/rollover/rollover.component.ts b/src/app/units/states/rollover/rollover.component.ts index a9c255c5bd..b03dccec78 100644 --- a/src/app/units/states/rollover/rollover.component.ts +++ b/src/app/units/states/rollover/rollover.component.ts @@ -1,5 +1,6 @@ -import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; +import {Subscription} from 'rxjs'; import {TeachingPeriod} from 'src/app/api/models/teaching-period'; import {Unit} from 'src/app/api/models/unit'; import {TeachingPeriodService} from 'src/app/api/services/teaching-period.service'; @@ -7,6 +8,15 @@ import {UnitService} from 'src/app/api/services/unit.service'; import {AlertService} from 'src/app/common/services/alert.service'; import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; +/** + * The same calendar day at midnight UTC. The server keeps only the date part of + * what it is sent, read in UTC, and a local midnight east of Greenwich is still + * the day before in UTC, so the new unit started and ended a day early. + */ +export function asUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); +} + @Component({ selector: 'f-rollover', templateUrl: './rollover.component.html', @@ -14,18 +24,24 @@ import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class RolloverComponent implements OnInit { +export class RolloverComponent implements OnInit, OnDestroy { @Input() unitId: number; public unit: Unit; public teachingPeriods: TeachingPeriod[] = []; - public teachingPeriod: TeachingPeriod; + /** The chosen teaching period, or false for custom dates. */ + public teachingPeriod: TeachingPeriod | false; public newStartDate: Date; public newEndDate: Date; + /** True while the new unit is being made, so a second click cannot make two. */ + public creating = false; + + private periodsSub?: Subscription; + constructor( private globalStateService: GlobalStateService, private unitService: UnitService, @@ -53,32 +69,69 @@ export class RolloverComponent implements OnInit { }); } + ngOnDestroy(): void { + this.periodsSub?.unsubscribe(); + } + initUnit() { - this.teachingPeriodService.cache.values.subscribe((periods) => { - this.teachingPeriods = periods; - this.teachingPeriods = periods.filter((p) => p.endDate.getTime() > Date.now()); - if (this.teachingPeriods.length) { - this.teachingPeriod = this.teachingPeriods[this.teachingPeriods.length - 1]; - } + this.periodsSub?.unsubscribe(); + this.periodsSub = this.teachingPeriodService.cache.values.subscribe((periods) => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + this.teachingPeriods = periods + .filter((period) => period.endDate.getTime() > now.getTime()) + .sort( + (left, right) => + left.startDate.getTime() - right.startDate.getTime() || left.id - right.id, + ); + + // Prefer the next local calendar start, including today. If every eligible period + // has already started, use the most recently started one. + this.teachingPeriod = + this.teachingPeriods.find((period) => period.startDate.getTime() >= today) ?? + this.teachingPeriods.at(-1) ?? + false; }); } + public get customDatesMissing(): boolean { + return !this.newStartDate || !this.newEndDate; + } + + public get customDatesOutOfOrder(): boolean { + return ( + !this.customDatesMissing && + asUtcDay(this.newEndDate).getTime() <= asUtcDay(this.newStartDate).getTime() + ); + } + + public get canCreate(): boolean { + if (!this.unit || this.creating) { + return false; + } + if (this.teachingPeriod) { + return true; + } + return !this.customDatesMissing && !this.customDatesOutOfOrder; + } + createUnit() { + if (!this.canCreate) { + return; + } + const body = this.teachingPeriod ? {teaching_period_id: this.teachingPeriod.id} - : {start_date: this.newStartDate, end_date: this.newEndDate}; + : {start_date: asUtcDay(this.newStartDate), end_date: asUtcDay(this.newEndDate)}; + this.creating = true; this.unit.rolloverTo(body).subscribe({ next: (response) => { this.alertService.success(`Unit created`, 2000); - if (this.route.parent?.snapshot.data.unit) { - this.router.navigate(['/units', response.id, 'admin']); - return; - } - this.router.navigate(['/units', response.id, 'admin']); }, error: (error) => { + this.creating = false; this.alertService.error(`Error creating unit: ${error}`, 6000); }, }); diff --git a/src/app/units/states/students-list/students-list.component.html b/src/app/units/states/students-list/students-list.component.html index 71abe3d1fb..1137e6c7a4 100644 --- a/src/app/units/states/students-list/students-list.component.html +++ b/src/app/units/states/students-list/students-list.component.html @@ -1,222 +1,365 @@ -
      -
      -
      -

      Students

      -

      Browse progress, filter tutorial groups, and manage enrolments.

      + +
      +
      +

      Students

      +

      + See how each student is going, and change their campus or tutorial. +

      - - Search for students or tutors... +
      + + +
      +
      + + -
      - All Tutorials - My Tutorials + + All tutorials + + My tutorials -
      - @if (loadingStudents) { -
      -
      - @for (width of ['4%', '10%', '16%', '28%', '8%', '8%', '8%', '10%', '10%']; track $index) { - + @if (!loadingStudents && !loadError && totalStudents > 0) { +

      + @if (shownStudents === totalStudents) { + {{ totalStudents }} {{ totalStudents === 1 ? 'student' : 'students' }} + } @else { + {{ shownStudents }} of {{ totalStudents }} students } -

      +

      + } +
      - @for (row of [0, 1, 2, 3, 4, 5, 6, 7]; track row) { -
      - - - - - @for (column of [0, 1, 2, 3, 4]; track column) { - +
      + @if (loadingStudents) { +
      + Loading students... + - } -
      - } +
      + } @else if (loadError) { + + } @else if (emptyState !== 'none') { +
      + @switch (emptyState) { + @case ('no-match') { + + + } + @case ('no-mine') { + + + } + @default { + + + } + } +
      + } -
      - +
      - -
      - - - - - - - +
      - - Username - {{ project.student?.username || 'N/A' }} -
      + + + + - - - - + + + + - - - + - - - - - - - - - - - - - - - - + + + + - - - + - + > + + - - - - + + + + - - + + + + - - - -
      Name + + Name - {{ project.student?.name || 'N/A' }} - Username + {{ project.student?.username || 'Not set' }} + Stats -
      - @for (bar of project.taskStats; track bar.key) { + +
      Progress + @let progress = progressFor(project); +
      - } -
      -
      Target Grade - - Portfolio - @if (project.hasPortfolio) { - menu_book - } - Similarity - @if (project.similarityFlag) { - visibility - } - Campus -
      - +
      Target grade + - - Tutorial -
      - -
      -
      Portfolio + @if (project.hasPortfolio) { + + + {{ + project.compilePortfolio ? 'Portfolio is being created' : 'Portfolio submitted' + }} + + } +
      Similarity + @if (project.similarityFlag) { + + Similarity found in a submission + } +
      - No students were found using the filters specified. -
      -
      + + Campus + + + + None + @for (campus of campuses; track campus.id) { + {{ campus.name }} + } + + + + -
      -
      - - + + Tutorial + + + + @for (group of tutorialGroupsFor(project); track group.label) { + + @for (tutorial of group.tutorials; track tutorial.id) { + + {{ tutorial.abbreviation }} + + } + + } + + + + + + + +
      -
      -
      + + diff --git a/src/app/units/states/students-list/students-list.component.spec.ts b/src/app/units/states/students-list/students-list.component.spec.ts new file mode 100644 index 0000000000..33420e1b83 --- /dev/null +++ b/src/app/units/states/students-list/students-list.component.spec.ts @@ -0,0 +1,412 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatAutocompleteModule} from '@angular/material/autocomplete'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; +import {MatOptionSelectionChange} from '@angular/material/core'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +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 {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {ActivatedRoute, Router} from '@angular/router'; +import {BehaviorSubject, Observable, Subject, of, throwError} from 'rxjs'; +import {Project} from 'src/app/api/models/project'; +import {Tutorial} from 'src/app/api/models/tutorial/tutorial'; +import {Unit} from 'src/app/api/models/unit'; +import {CampusService} from 'src/app/api/services/campus.service'; +import {ProjectService} from 'src/app/api/services/project.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {EmptyStateComponent} from 'src/app/common/empty-state/empty-state.component'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {UnitStudentEnrolmentModalService} from '../../modals/unit-student-enrolment-modal/unit-student-enrolment-modal.service'; +import {UnitRootStateComponent} from '../../unit-root-state.component'; +import {StudentsListComponent} from './students-list.component'; + +function studentStub(name: string, inMyTutorial = true): Project { + return { + student: {name, displayName: name, username: name.toLowerCase()}, + hasTutor: () => inMyTutorial, + matches: (text: string) => name.toLowerCase().includes(text), + } as unknown as Project; +} + +function unitStub(id: number, students: Project[], myRole = 'Convenor'): Unit { + return { + id, + myRole, + students, + studentFilterTypeAheadData: students.map((project) => project.student.name), + studentCache: {values: of(students)}, + } as unknown as Unit; +} + +function namedUnit(id: number, studentNames: string[]): Unit { + return unitStub( + id, + studentNames.map((name) => studentStub(name)), + ); +} + +describe('StudentsListComponent', () => { + const unitA = namedUnit(1, ['Ana Amos']); + const unitB = namedUnit(2, ['Bo Barnes']); + let routeData: BehaviorSubject<{unit: Unit}>; + let loadStudents: ReturnType Observable>>; + + beforeEach(async () => { + routeData = new BehaviorSubject<{unit: Unit}>({unit: unitA}); + loadStudents = vi.fn<(unit: Unit) => Observable>(() => of([])); + + await TestBed.configureTestingModule({ + declarations: [UnitRootStateComponent, StudentsListComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: {data: routeData, parent: {snapshot: {data: {}}}}, + }, + {provide: Router, useValue: {}}, + {provide: UserService, useValue: {currentUser: {id: 1}}}, + {provide: ProjectService, useValue: {loadStudents}}, + {provide: CampusService, useValue: {query: () => of([])}}, + {provide: AlertService, useValue: {success: () => {}, error: () => {}}}, + {provide: UnitStudentEnrolmentModalService, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(UnitRootStateComponent, {set: {template: ''}}) + .overrideComponent(StudentsListComponent, {set: {template: ''}}) + .compileComponents(); + }); + + function createList(unit$?: Observable): StudentsListComponent { + const component = TestBed.createComponent(StudentsListComponent).componentInstance; + component.paginator = {firstPage: () => {}} as unknown as MatPaginator; + + if (unit$) { + component.unit$ = unit$; + } + + component.ngOnInit(); + return component; + } + + it('shows the students of the unit the route resolved, and follows it to another unit', () => { + const root = TestBed.createComponent(UnitRootStateComponent).componentInstance; + root.ngOnInit(); + + const component = TestBed.createComponent(StudentsListComponent).componentInstance; + component.paginator = {firstPage: () => {}} as unknown as MatPaginator; + + // The outlet hands the child its stream before the child runs ngOnInit. + root.onActivate(component); + component.ngOnInit(); + + expect(component.unit.id).toBe(unitA.id); + expect(component.dataSource.data.map((project) => project.student.displayName)).toEqual([ + 'Ana Amos', + ]); + + routeData.next({unit: unitB}); + + expect(component.unit.id).toBe(unitB.id); + expect(component.dataSource.data.map((project) => project.student.displayName)).toEqual([ + 'Bo Barnes', + ]); + }); + + it('does not rebuild the list when the route resolves the same unit again', () => { + const root = TestBed.createComponent(UnitRootStateComponent).componentInstance; + root.ngOnInit(); + + const component = TestBed.createComponent(StudentsListComponent).componentInstance; + component.paginator = {firstPage: () => {}} as unknown as MatPaginator; + + root.onActivate(component); + component.ngOnInit(); + + const firstData = component.dataSource.data; + + routeData.next({unit: namedUnit(unitA.id, ['Ana Amos'])}); + + expect(component.unit.id).toBe(unitA.id); + expect(component.dataSource.data).toBe(firstData); + }); + + it('shows a finished student as complete, not as all grey', () => { + const component = createList(of(unitA)); + // The project mapping reads a grey share of 0 as 100, so a student with every + // task complete arrives with both bars at 100. + const finished = { + 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}, + ], + } as unknown as Project; + + const progress = component.progressFor(finished); + + expect(progress.complete).toBe(100); + expect(progress.segments.map((segment) => [segment.key, segment.value])).toEqual([ + ['complete', 100], + ]); + }); + + it('works out the grey share from what is left over', () => { + const component = createList(of(unitA)); + const partway = { + taskStats: [ + {key: 'fail', value: 5}, + {key: 'not_started', value: 100}, + {key: 'working_on_it', value: 10}, + {key: 'ready_for_feedback', value: 15}, + {key: 'complete', value: 40}, + ], + } as unknown as Project; + + const progress = component.progressFor(partway); + + expect(progress.segments.map((segment) => [segment.key, segment.value])).toEqual([ + ['complete', 40], + ['ready_for_feedback', 15], + ['working_on_it', 10], + ['fail', 5], + ['not_started', 30], + ]); + expect(progress.segments[0].color).toBe('var(--ot-status-complete-graphic)'); + expect(progress.label).toContain('Complete 40%'); + }); + + it('treats a student with no stats yet as not started instead of throwing', () => { + const component = createList(of(unitA)); + + const progress = component.progressFor({} as Project); + + expect(progress.complete).toBe(0); + expect(progress.segments.map((segment) => segment.key)).toEqual(['not_started']); + }); + + it('records a failed load so the page can offer a retry', () => { + loadStudents.mockReturnValueOnce(throwError(() => new Error('offline'))); + + const component = createList(of(unitA)); + + expect(component.loadingStudents).toBe(false); + expect(component.loadError).toBe(true); + + component.loadStudents(); + + expect(loadStudents).toHaveBeenCalledTimes(2); + expect(component.loadError).toBe(false); + expect(component.loadingStudents).toBe(false); + }); + + it('says when a tutor has no students in their tutorials, and when a search finds nobody', () => { + const unit = unitStub(3, [studentStub('Cy Cole', false)], 'Tutor'); + const component = createList(of(unit)); + + expect(component.staffFilter).toBe('mine'); + expect(component.emptyState).toBe('no-mine'); + + component.setStaffFilter('all'); + expect(component.emptyState).toBe('none'); + + component.searchText = 'zz'; + component.onSearchChange(); + expect(component.emptyState).toBe('no-match'); + expect(component.noMatchMessage).toBe('No students match "zz"'); + + component.clearSearch(); + expect(component.emptyState).toBe('none'); + }); + + it('skips blank suggestions instead of throwing on them', () => { + const unit = { + ...namedUnit(4, ['Di Dean']), + studentFilterTypeAheadData: ['Di Dean', undefined, ''], + } as unknown as Unit; + const component = createList(of(unit)); + + component.searchText = 'd'; + expect(() => component.onSearchChange()).not.toThrow(); + expect(component.filteredSuggestions).toEqual(['Di Dean']); + }); + + it('changes a tutorial only for a pick the user made, from the mouse or the keyboard', () => { + const component = createList(of(unitA)); + const switchToTutorial = vi.fn(); + const project = {switchToTutorial} as unknown as Project; + const tutorial = {id: 7} as Tutorial; + + component.onTutorialOptionChange( + {isUserInput: false} as MatOptionSelectionChange, + project, + tutorial, + ); + expect(switchToTutorial).not.toHaveBeenCalled(); + + component.onTutorialOptionChange( + {isUserInput: true} as MatOptionSelectionChange, + project, + tutorial, + ); + expect(switchToTutorial).toHaveBeenCalledWith(tutorial); + }); + + it('puts the campus back when the change is refused', () => { + const component = createList(of(unitA)); + const original = {id: 1, name: 'Burwood'}; + const project = { + campus: original, + switchToCampus(campus: unknown) { + this.campus = campus; + return throwError(() => 'Not allowed'); + }, + } as unknown as Project; + + component.changeCampus(project, {id: 2, name: 'Geelong'} as never); + + expect(project.campus).toBe(original); + }); +}); + +describe('StudentsListComponent empty state', () => { + afterEach(() => vi.restoreAllMocks()); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [StudentsListComponent], + imports: [ + FormsModule, + MatAutocompleteModule, + MatButtonToggleModule, + MatFormFieldModule, + MatInputModule, + MatPaginatorModule, + MatSelectModule, + MatSortModule, + MatTableModule, + NoopAnimationsModule, + EmptyStateComponent, + ], + providers: [ + {provide: ActivatedRoute, useValue: {parent: {snapshot: {data: {}}}}}, + {provide: Router, useValue: {}}, + {provide: UserService, useValue: {currentUser: {id: 1}}}, + {provide: ProjectService, useValue: {loadStudents: () => of([])}}, + {provide: CampusService, useValue: {query: () => of([])}}, + {provide: AlertService, useValue: {success: () => {}, error: () => {}}}, + {provide: UnitStudentEnrolmentModalService, useValue: {}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + it('renders the empty state only while the filtered list has no rows', () => { + const fixture = TestBed.createComponent(StudentsListComponent); + const component = fixture.componentInstance; + component.unit$ = of(unitStub(1, [])); + + fixture.detectChanges(); + + const emptyState = fixture.nativeElement.querySelector('f-empty-state') as HTMLElement; + const table = fixture.nativeElement.querySelector('table') as HTMLTableElement; + const tableScrollContainer = table.parentElement as HTMLDivElement; + + expect(emptyState).toBeTruthy(); + expect(emptyState.closest('table')).toBeNull(); + expect(tableScrollContainer.hidden).toBe(true); + + component.dataSource.data = [ + { + student: {name: 'Cy Cole', displayName: 'Cy Cole', username: 'cycole'}, + hasTutor: () => true, + matches: () => true, + taskStats: [], + } as unknown as Project, + ]; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeFalsy(); + expect(tableScrollContainer.hidden).toBe(false); + }); + + it('shows a retryable error instead of claiming that a failed load is empty', () => { + const projectService = TestBed.inject(ProjectService); + const loadStudents = vi + .spyOn(projectService, 'loadStudents') + .mockReturnValueOnce(throwError(() => new Error('network unavailable'))) + .mockReturnValue(of([])); + const fixture = TestBed.createComponent(StudentsListComponent); + fixture.componentInstance.unit$ = of(unitStub(1, [])); + + fixture.detectChanges(); + + // The failure is shown with the shared empty state inside the alert, so check that + // the enrolment message is not shown instead of checking for no empty state at all. + const errorState = fixture.nativeElement.querySelector('[role="alert"]') as HTMLElement; + expect(errorState.textContent).toContain('Students could not be loaded'); + expect(fixture.nativeElement.textContent).not.toContain('No students enrolled yet'); + + const retry = Array.from( + fixture.nativeElement.querySelectorAll('button') as NodeListOf, + ).find((button) => button.textContent.includes('Try again')); + retry.click(); + fixture.detectChanges(); + + expect(loadStudents).toHaveBeenCalledTimes(2); + expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeTruthy(); + expect(fixture.nativeElement.textContent).toContain('No students enrolled yet'); + }); + it('shows skeleton rows until an empty student response has resolved', () => { + const result: Subject = new Subject(); + vi.spyOn(TestBed.inject(ProjectService), 'loadStudents').mockReturnValue(result); + const fixture = TestBed.createComponent(StudentsListComponent); + fixture.componentInstance.unit$ = of(unitStub(1, [])); + fixture.detectChanges(); + // The loading rows are drawn in the template inside one busy status region. + expect(fixture.nativeElement.querySelector('[aria-busy="true"][role="status"]')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + result.next([]); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[aria-busy="true"][role="status"]')).toBeNull(); + expect(fixture.nativeElement.querySelector('f-empty-state')).not.toBeNull(); + }); + + it('does not announce an empty list before a unit has resolved', () => { + const fixture = TestBed.createComponent(StudentsListComponent); + fixture.componentInstance.unit$ = of(null); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + expect(fixture.nativeElement.querySelector('[aria-busy="true"][role="status"]')).not.toBeNull(); + }); + it('ignores an obsolete request after changing units and cancels on destroy', () => { + const first: Subject = new Subject(); + const second: Subject = new Subject(); + vi.spyOn(TestBed.inject(ProjectService), 'loadStudents') + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + const units: BehaviorSubject = new BehaviorSubject(unitStub(1, [])); + const fixture = TestBed.createComponent(StudentsListComponent); + fixture.componentInstance.unit$ = units; + fixture.detectChanges(); + units.next(unitStub(2, [])); + fixture.detectChanges(); + expect(first.observed).toBe(false); + first.error(new Error('Obsolete unit request')); + fixture.detectChanges(); + expect(fixture.componentInstance.loadingStudents).toBe(true); + expect(fixture.componentInstance.loadError).toBe(false); + expect(fixture.nativeElement.querySelector('f-empty-state')).toBeNull(); + expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeNull(); + fixture.destroy(); + expect(second.observed).toBe(false); + }); +}); diff --git a/src/app/units/states/students-list/students-list.component.ts b/src/app/units/states/students-list/students-list.component.ts index 46b36bcde5..98ad270d2c 100644 --- a/src/app/units/states/students-list/students-list.component.ts +++ b/src/app/units/states/students-list/students-list.component.ts @@ -7,25 +7,62 @@ import { OnInit, ViewChild, } from '@angular/core'; +import {MatOptionSelectionChange} from '@angular/material/core'; import {MatPaginator} from '@angular/material/paginator'; import {MatSort, Sort} from '@angular/material/sort'; import {MatTableDataSource} from '@angular/material/table'; import {ActivatedRoute, Router} from '@angular/router'; -import {Observable, Subscription, finalize, first, of} from 'rxjs'; +import {Observable, Subscription, distinctUntilChanged, first, of} from 'rxjs'; import { + Campus, + CampusService, Project, ProjectService, - TaskService, + TaskStatus, TaskStatusEnum, + Tutorial, + TutorialStream, Unit, UserService, } from 'src/app/api/models/doubtfire-model'; +import {AlertService} from 'src/app/common/services/alert.service'; import {UnitStudentEnrolmentModalService} from '../../modals/unit-student-enrolment-modal/unit-student-enrolment-modal.service'; +export interface StudentProgressSegment { + key: TaskStatusEnum; + label: string; + value: number; + color: string; +} + +export interface StudentProgress { + complete: number; + segments: StudentProgressSegment[]; + label: string; +} + +export interface TutorialOptionGroup { + label: string; + tutorials: Tutorial[]; +} + +export type StudentsEmptyState = 'none' | 'no-students' | 'no-mine' | 'no-match'; + +// The API sends five buckets, and each one covers several task statuses, so the +// status names alone would mislabel them. These say what each bucket holds. +const PROGRESS_BUCKETS: {key: TaskStatusEnum; label: string}[] = [ + {key: 'complete', label: 'Complete'}, + {key: 'ready_for_feedback', label: 'Ready for feedback'}, + {key: 'working_on_it', label: 'Needs more work'}, + {key: 'fail', label: 'Failed or out of time'}, + {key: 'not_started', label: 'Not started or working on it'}, +]; + // State for both convenors and tutors to access student list @Component({ selector: 'f-students-list', templateUrl: './students-list.component.html', + styleUrl: './students-list.scss', changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) @@ -33,12 +70,11 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { @Input() unit$: Observable; @ViewChild(MatSort) sort: MatSort; - @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatPaginator, {static: true}) paginator: MatPaginator; displayedColumns: string[] = [ - 'avatar', - 'username', 'name', + 'username', 'stats', 'grade', 'portfolio', @@ -52,9 +88,17 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { staffFilter: 'all' | 'mine' = 'all'; filteredSuggestions: string[] = []; loadingStudents = true; + loadError = false; + campuses: Campus[] = []; unit: Unit; private subscriptions: Subscription[] = []; + private studentCacheSub?: Subscription; + private loadSub?: Subscription; + private progressCache: WeakMap< + Project, + {stats: Project['taskStats']; progress: StudentProgress} + > = new WeakMap(); public sortState: Sort = {active: 'name', direction: 'asc'}; constructor( @@ -62,40 +106,45 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { private router: Router, private route: ActivatedRoute, private userService: UserService, - private taskService: TaskService, private projectService: ProjectService, + private campusService: CampusService, + private alerts: AlertService, ) {} ngOnInit(): void { this.unit$ = this.unit$ ?? of(this.route.parent.snapshot.data.unit); this.subscriptions.push( - this.unit$?.pipe(first()).subscribe((unit) => { + this.unit$?.pipe(distinctUntilChanged((a, b) => a?.id === b?.id)).subscribe((unit) => { + this.loadSub?.unsubscribe(); + this.studentCacheSub?.unsubscribe(); if (!unit) { - this.loadingStudents = false; + this.loadingStudents = true; + this.loadError = false; return; } this.unit = unit; this.staffFilter = unit.myRole === 'Tutor' ? 'mine' : 'all'; - this.subscriptions.push( - this.unit.studentCache.values.subscribe(() => { - this.updateSuggestions(); - this.updateDataSource(); - }), - ); - - this.updateSuggestions(); - this.updateDataSource(); - this.projectService - .loadStudents(this.unit) - .pipe( - first(), - finalize(() => { - this.loadingStudents = false; - }), - ) - .subscribe(); + this.studentCacheSub = this.unit.studentCache.values.subscribe(() => { + this.updateSuggestions(); + this.updateDataSource(); + }); + + this.updateDataSource(true); + this.loadStudents(); + }), + ); + + // Every row offers the same campus list, so ask for it once for the page. + this.subscriptions.push( + this.campusService.query().subscribe({ + next: (campuses) => { + this.campuses = campuses; + }, + error: () => { + this.campuses = []; + }, }), ); } @@ -106,7 +155,41 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { - this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + this.loadSub?.unsubscribe(); + this.studentCacheSub?.unsubscribe(); + this.subscriptions.forEach((subscription) => subscription?.unsubscribe()); + } + + /** + * Fetch the unit's students. A failed request used to leave the page saying no + * students matched the filters, so it now records the failure and offers a retry. + */ + public loadStudents(): void { + if (!this.unit) { + return; + } + + // Drop a request for a unit the page has already left, so its late reply cannot + // clear the loading state of the unit now on screen. + this.loadSub?.unsubscribe(); + this.loadingStudents = true; + this.loadError = false; + + this.loadSub = this.projectService + .loadStudents(this.unit) + .pipe(first()) + .subscribe({ + next: () => { + this.loadingStudents = false; + }, + error: () => { + this.loadingStudents = false; + this.loadError = true; + }, + complete: () => { + this.loadingStudents = false; + }, + }); } public onSearchChange(): void { @@ -114,6 +197,11 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { this.updateDataSource(true); } + public clearSearch(): void { + this.searchText = ''; + this.onSearchChange(); + } + public setStaffFilter(filter: 'all' | 'mine'): void { this.staffFilter = filter; this.updateDataSource(true); @@ -137,6 +225,35 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { this.enrolModal.show(this.unit); } + public get totalStudents(): number { + return this.unit?.students.length ?? 0; + } + + public get shownStudents(): number { + return this.dataSource.data.length; + } + + /** Which empty message to show, so each one can offer the step that gets past it. */ + public get emptyState(): StudentsEmptyState { + if (this.shownStudents > 0) { + return 'none'; + } + + if (this.searchText.trim()) { + return 'no-match'; + } + + if (this.totalStudents > 0 && this.staffFilter === 'mine') { + return 'no-mine'; + } + + return 'no-students'; + } + + public get noMatchMessage(): string { + return `No students match "${this.searchText.trim()}"`; + } + public exportCsv(): void { const rows = [ this.csvHeader(), @@ -155,21 +272,125 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { URL.revokeObjectURL(link.href); } - public statusColor(status: TaskStatusEnum): string { - return this.taskService.statusColors.get(status) || '#cbd5e1'; + /** + * The progress bar for a row. The bar used to take the grey share straight from + * the API, which the project mapping turns from 0 into 100 for a student who has + * submitted everything, so a finished student showed as all grey. The grey share + * is what is left over, so it is worked out here from the other four. + */ + public progressFor(project: Project): StudentProgress { + const cached = this.progressCache.get(project); + if (cached && cached.stats === project.taskStats) { + return cached.progress; + } + + const progress = this.buildProgress(project.taskStats); + this.progressCache.set(project, {stats: project.taskStats, progress}); + return progress; + } + + public sameEntity(a: {id: number} | null, b: {id: number} | null): boolean { + return a === b || (!!a && !!b && a.id === b.id); } - public statusLabel(status: TaskStatusEnum): string { - return this.taskService.statusLabels.get(status) || status; + public changeCampus(project: Project, campus: Campus | null): void { + const originalCampus = project.campus; + + project.switchToCampus(campus).subscribe({ + next: (updated: Project) => { + this.alerts.success(`Campus changed for ${updated.student.displayName}`, 2000); + }, + error: (message) => { + project.campus = originalCampus; + this.alerts.error(message, 6000); + }, + }); } - public showBarLabel(value: number): boolean { - return Number.isFinite(value) && value >= 10; + /** + * Option events fire for keyboard and mouse picks alike, where a click handler on + * the option only ever saw the mouse. Programmatic changes (the select catching up + * with the new enrolments) are not user input, so they are ignored. + */ + public onTutorialOptionChange( + event: MatOptionSelectionChange, + project: Project, + tutorial: Tutorial, + ): void { + if (event.isUserInput) { + project.switchToTutorial(tutorial); + } + } + + /** Tutorials the student can join, grouped by stream, limited to their campus. */ + public tutorialGroupsFor(project: Project): TutorialOptionGroup[] { + const groups: TutorialOptionGroup[] = []; + const unstreamed = this.tutorialsFor(project); + + if (unstreamed.length > 0) { + groups.push({label: 'No stream', tutorials: unstreamed}); + } + + (this.unit?.tutorialStreams ?? []).forEach((stream) => { + const tutorials = this.tutorialsFor(project, stream); + if (tutorials.length > 0) { + groups.push({label: stream.name, tutorials}); + } + }); + + return groups; + } + + private tutorialsFor(project: Project, stream?: TutorialStream): Tutorial[] { + return (this.unit?.tutorials ?? []).filter((tutorial) => { + const sameCampus = + project.campus == null || + tutorial.campus == null || + project.campus.id === tutorial.campus.id; + if (!sameCampus) { + return false; + } + + if (tutorial.tutorialStream && stream) { + return tutorial.tutorialStream.abbreviation === stream.abbreviation; + } + + return !tutorial.tutorialStream && !stream; + }); + } + + private buildProgress(stats: Project['taskStats']): StudentProgress { + const valueFor = (key: TaskStatusEnum): number => { + const value = stats?.find((stat) => stat.key === key)?.value; + return Number.isFinite(value) ? Math.max(0, value) : 0; + }; + + const values: Map = new Map(); + PROGRESS_BUCKETS.filter((bucket) => bucket.key !== 'not_started').forEach((bucket) => + values.set(bucket.key, valueFor(bucket.key)), + ); + const submitted = Array.from(values.values()).reduce((sum, value) => sum + value, 0); + values.set('not_started', Math.max(0, 100 - submitted)); + + const segments = PROGRESS_BUCKETS.map((bucket) => ({ + key: bucket.key, + label: bucket.label, + value: values.get(bucket.key), + color: `var(--ot-status-${TaskStatus.statusClass(bucket.key)}-graphic)`, + })).filter((segment) => segment.value > 0); + + return { + complete: values.get('complete'), + segments, + label: `Progress: ${segments.map((segment) => `${segment.label} ${segment.value}%`).join(', ')}`, + }; } private updateSuggestions(): void { const searchValue = this.searchText.trim().toLowerCase(); - const suggestions = Array.from(new Set(this.unit?.studentFilterTypeAheadData ?? [])); + const suggestions = Array.from(new Set(this.unit?.studentFilterTypeAheadData ?? [])).filter( + (item): item is string => typeof item === 'string' && item.length > 0, + ); this.filteredSuggestions = suggestions .filter((item) => !searchValue || item.toLowerCase().includes(searchValue)) @@ -177,13 +398,7 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { } private updateDataSource(resetPagination: boolean = false): void { - if (!this.paginator) { - return; - } - - const students = this.filteredProjects(); - - this.dataSource.data = students; + this.dataSource.data = this.filteredProjects(); if (resetPagination) { this.paginator?.firstPage(); @@ -202,7 +417,7 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { private matchesSearch(project: Project, searchValue: string): boolean { return ( - project.matches(searchValue) || project.student.username?.toLowerCase().includes(searchValue) + project.matches(searchValue) || project.student?.username?.toLowerCase().includes(searchValue) ); } @@ -229,9 +444,9 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { private sortValue(project: Project, active: string): number | string { switch (active) { case 'username': - return project.student.username?.toLowerCase() || ''; + return project.student?.username?.toLowerCase() || ''; case 'name': - return project.student.name?.toLowerCase() || ''; + return project.student?.displayName?.toLowerCase() || ''; case 'stats': return project.orderScale ?? 0; case 'grade': @@ -245,7 +460,7 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { case 'tutorial': return project.shortTutorialDescription().toLowerCase(); default: - return project.student.name?.toLowerCase() || ''; + return project.student?.displayName?.toLowerCase() || ''; } } @@ -263,9 +478,9 @@ export class StudentsListComponent implements OnInit, AfterViewInit, OnDestroy { private csvRow(project: Project): string[] { const row = [ - project.student.username || '', - project.student.name || '', - project.student.email || '', + project.student?.username || '', + project.student?.displayName || '', + project.student?.email || '', String(project.portfolioStatus ?? ''), ]; diff --git a/src/app/units/states/tasks/definition/definition.coffee b/src/app/units/states/tasks/definition/definition.coffee deleted file mode 100644 index 9dbdc249ea..0000000000 --- a/src/app/units/states/tasks/definition/definition.coffee +++ /dev/null @@ -1,31 +0,0 @@ -angular.module('doubtfire.units.states.tasks.definition', [ -]) - -# -# Mark tasks by task definition ID -# -.config(($stateProvider) -> - $stateProvider.state 'units/tasks/definition', { - parent: 'units/tasks' - url: '/definition/{taskKey:any}' - # We can recycle the task inbox, switching the data source scope variable - templateUrl: "units/states/tasks/inbox/inbox.tpl.html" - controller: "TaskDefinitionStateCtrl" - params: - taskKey: dynamic: true - data: - task: "Task Explorer" - pageTitle: "_Home_" - roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] - } -) - -.controller('TaskDefinitionStateCtrl', ($scope, newTaskService) -> - $scope.taskData.source = newTaskService.queryTasksForTaskExplorer.bind(newTaskService) - $scope.viewType = 'explorer' - $scope.taskData.taskDefMode = true - $scope.showSearchOptions = true - $scope.filters = { - taskDefinitionIdSelected: _.first($scope.unit.taskDefinitions)?.id - } -) diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html index 1b680fae11..0974f7033b 100644 --- a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html +++ b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html @@ -1,43 +1,24 @@ -

      -
      -
      - @if (action === 'show_less') { - thumb_up - } @else if (action === 'show_more') { - flag - } @else if (action === 'dismiss_ok') { - check_circle - } @else if (action === 'dismiss_good') { - task_alt - } @else if (action === 'overturn') { - gavel - } @else if (action === 'upheld') { - verified - } -
      -
      -
      {{ title }}
      - - Tutor: {{ task.tutor.user.name }} -
      -
      -

      +

      + + + + {{ title }} + Tutor: {{ tutorName }} + + +

      -

      +

      {{ description }}

      @if (showDismissAll) { -

      - - Apply this action to all {{ task.definition.abbreviation }} {{ task.definition.name }} tasks - from {{ task.tutor?.user.name ?? 'N/A' }}, currently waiting for moderation - -

      + + Apply this to every {{ task?.definition?.abbreviation }} {{ task?.definition?.name }} task + from {{ tutorName }} that is waiting for moderation + }
      - - - + + + diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index cd358db7f2..81a9243409 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-shadow, @typescript-eslint/no-shadow */ import {HotkeysService} from '@ngneat/hotkeys'; import { ChangeDetectionStrategy, @@ -13,7 +12,7 @@ import { } from '@angular/core'; import {MatDialog} from '@angular/material/dialog'; import {ActivatedRoute, Router} from '@angular/router'; -import {Observable} from 'rxjs'; +import {Observable, Subscription} from 'rxjs'; import { Project, Task, @@ -39,6 +38,14 @@ import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; import {BatchFeedbackWorkflowDialogComponent} from './batch-feedback-workflow-dialog/batch-feedback-workflow-dialog.component'; +export interface StaffTaskListEmptyState { + icon: string; + message: string; + hint: string; + action: 'clear-search' | 'all-students' | 'refresh' | null; + actionLabel?: string; +} + @Component({ selector: 'df-staff-task-list', templateUrl: './staff-task-list.component.html', @@ -49,6 +56,8 @@ import {BatchFeedbackWorkflowDialogComponent} from './batch-feedback-workflow-di export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { @ViewChild('searchDialog') searchDialog: TemplateRef; + private taskRequestSub?: Subscription; + @Input() task: Task; @Input() project: Project; @@ -104,7 +113,13 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { panelOpenState = false; loading = true; - skeletonRows = Array.from({length: 12}, (_, index) => index); + /** The last task query failed, so the list offers a retry instead of a blank panel. */ + loadError = false; + + // The list is drawn twice, once for wide screens and once for phones, so each copy + // needs its own id for the filter toggle to point at. + private static nextInstanceId = 0; + readonly filtersPanelId = `staff-task-filters-${StaffTaskListComponent.nextInstanceId++}`; definedTasksPipe = new TasksOfTaskDefinitionPipe(); tasksInTutorialsPipe = new TasksInTutorialsPipe(); @@ -115,9 +130,9 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { // for selecting tasks by task definitions states = [ - {sort: 'default', icon: 'horizontal_rule'}, - {sort: 'ascending', icon: 'arrow_upward'}, - {sort: 'descending', icon: 'arrow_downward'}, + {sort: 'default', icon: 'swap_vert', label: 'Sort by task'}, + {sort: 'ascending', icon: 'arrow_upward', label: 'Sorted by task, first to last'}, + {sort: 'descending', icon: 'arrow_downward', label: 'Sorted by task, last to first'}, ]; taskDefSort = 0; @@ -154,22 +169,21 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.syncSelectedTaskFromTaskKey(); } - if (!this.isTaskDefMode || !this.filters) { - return; - } - const unitChanged = !!changes.unit && !changes.unit.isFirstChange() && changes.unit.currentValue?.id && changes.unit.previousValue?.id !== changes.unit.currentValue?.id; - if (unitChanged) { - this.refreshData(); + // This used to sit behind an isTaskDefMode guard, so the inbox kept the previous + // unit's students, tutors and tasks on screen after a unit switch. + if (unitChanged && this.unit && this.unitRole) { + this.initialiseForUnit(); } } ngOnDestroy(): void { + this.taskRequestSub?.unsubscribe(); this.hotkeys.removeShortcuts('control.shift.arrowdown'); this.hotkeys.removeShortcuts('control.shift.arrowup'); } @@ -201,6 +215,15 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.allowHover = false; } + this.initialiseForUnit(); + } + + // The filter defaults, the student and tutor lists and the task query are all built + // from the routed unit, so they have to be rebuilt when it changes. The router reuses + // this component across a unit switch, so ngOnInit does not run a second time. + private initialiseForUnit(): void { + this.fetchedAllTasks = false; + // Does the current user have any tutorials? this.userHasTutorials = this.unit.tutorialsForUserName(this.userService.currentUser.name)?.length > 0; @@ -236,10 +259,10 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.studentFilter = [ ...[ - {id: 'all', inboxDescription: 'All Students', abbreviation: '__all', forceStream: false}, + {id: 'all', inboxDescription: 'All students', abbreviation: '__all', forceStream: false}, { id: 'mine', - inboxDescription: 'My Students', + inboxDescription: 'My students', abbreviation: '__mine', forceStream: !this.isTaskDefMode, }, @@ -258,9 +281,9 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { ...(mentored.length > 0 ? [ { - label: 'My Tutors (Mentoring)', + label: 'Tutors you mentor', options: [ - {id: 'mentoring_all', inboxDescription: 'Show All Mine'}, + {id: 'mentoring_all', inboxDescription: 'All tutors you mentor'}, ...mentored.map((ur) => ({ id: ur.id, inboxDescription: ur.user?.name, @@ -270,9 +293,9 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { ] : []), { - label: 'All Tutors', + label: 'All tutors', options: [ - {id: 'all', inboxDescription: 'Show All'}, + {id: 'all', inboxDescription: 'Any tutor'}, ...allTutors.map((ur) => ({ id: ur.id, inboxDescription: ur.user?.name, @@ -295,6 +318,170 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { return this.taskData.taskDefMode; } + /** The page title, which names the queue the list is showing. */ + public get listTitle(): string { + switch (this.viewType) { + case 'explorer': + return 'Task explorer'; + case 'moderation': + return 'Moderation'; + case 'overflow': + return 'Overflow'; + case 'inbox': + default: + return 'Task inbox'; + } + } + + /** The muted line under the title: how many tasks the list holds right now. */ + public get listSummary(): string { + if (this.loading) { + return 'Loading tasks'; + } + + if (this.loadError || !this.filteredTasks) { + return 'No tasks loaded'; + } + + const shown = this.filteredTasks.length; + const total = this.tasks?.length ?? shown; + const noun = (count: number) => (count === 1 ? 'task' : 'tasks'); + + return shown === total ? `${shown} ${noun(shown)}` : `${shown} of ${total} ${noun(total)}`; + } + + /** What an empty list says, and the one thing it offers to do about it. */ + public get emptyState(): StaffTaskListEmptyState { + if (this.hasSearchText) { + return { + icon: 'search_off', + message: 'No tasks match your search', + hint: 'Try a different name or task code.', + action: 'clear-search', + actionLabel: 'Clear search', + }; + } + + const someStudentsOnly = this.filters?.tutorialIdSelected !== 'all'; + + switch (this.viewType) { + case 'explorer': + if (!this.unit?.taskDefinitions?.length) { + return { + icon: 'assignment', + message: 'This unit has no tasks yet', + hint: 'Tasks show up here once they are added to the unit.', + action: null, + }; + } + return someStudentsOnly + ? { + icon: 'group_off', + message: 'No students to show', + hint: 'None of the students in this filter have this task.', + action: 'all-students', + actionLabel: 'Show all students', + } + : { + icon: 'group_off', + message: 'No students to show', + hint: 'No students are enrolled in this unit yet.', + action: null, + }; + case 'moderation': + return { + icon: 'verified', + message: 'Nothing to moderate', + hint: 'Tasks picked for moderation show up here.', + action: 'refresh', + actionLabel: 'Refresh', + }; + case 'overflow': + return { + icon: 'more_time', + message: 'No overflow tasks', + hint: 'Tasks that have waited too long for feedback show up here.', + action: 'refresh', + actionLabel: 'Refresh', + }; + case 'inbox': + default: + return someStudentsOnly + ? { + icon: 'done_all', + message: 'You are all caught up', + hint: 'None of these students have tasks waiting for you.', + action: 'all-students', + actionLabel: 'Show all students', + } + : { + icon: 'done_all', + message: 'You are all caught up', + hint: 'No tasks are waiting for feedback.', + action: 'refresh', + actionLabel: 'Refresh', + }; + } + } + + public runEmptyStateAction(action: StaffTaskListEmptyState['action']): void { + switch (action) { + case 'clear-search': + this.clearSearch(); + break; + case 'all-students': + this.showAllStudents(); + break; + case 'refresh': + this.refreshTasks(); + break; + } + } + + public get hasSearchText(): boolean { + return !!this.filters?.studentName?.trim(); + } + + public get taskSortLabel(): string { + return this.states[this.taskDefSort].label; + } + + public clearSearch(): void { + this.filters.studentName = null; + this.applyFilters(); + } + + public showAllStudents(): void { + this.tutorialIdChanged(true, 'all'); + } + + /** + * The collapsed list shows only the avatar, so its button carries the student, the + * task and anything that needs attention in its name and tooltip. + */ + public narrowRowLabel(task: Task): string { + const student = task.project?.student; + const parts = [student?.displayName || student?.name, task.definition?.abbreviation]; + if (task.numNewComments > 0) { + parts.push(`${task.numNewComments} new comment${task.numNewComments === 1 ? '' : 's'}`); + } + if (task.similaritiesDetected) { + parts.push('similarities detected'); + } + + return parts.filter(Boolean).join(', '); + } + + /** How long the task has waited, for the warning beside the student's name. */ + public waitingLabel(task: Task): string { + const days = task.daysSinceSubmission(); + const waited = `Waiting ${days} ${days === 1 ? 'day' : 'days'} for feedback`; + + return this.getWarningIcon(task) === 'overflow' + ? `${waited}. Feedback is overdue.` + : `${waited}. Feedback is due soon.`; + } + downloadSubmissionPdfs() { const taskDef = this.filters.taskDefinition; this.taskDefinitionService.zipSubmissionPdfs(taskDef).subscribe({ @@ -345,7 +532,7 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { const taskDefinition = this.filters.taskDefinition ?? undefined; if (!taskDefinition) { - this.alertService.error('Select a task definition before uploading batch feedback.', 5000); + this.alertService.error('Choose a task before uploading batch feedback.', 5000); return; } @@ -453,18 +640,6 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } } - openTaskDefs() { - // Automatically "open" the task definition select element if in task def mode - const selectEl = document.querySelector( - 'select[ng-model="filters.taskDefinitionIdSelected"]', - ); - if (!selectEl) { - return; - } - selectEl.size = 10; - selectEl.focus(); - } - unitRoleIdChanged(attemptRefreshData: boolean = true): void { this.applyFilters(); @@ -542,6 +717,11 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.unit.taskDefinitionCache.currentValues.find( (x) => x.abbreviation === taskKey?.taskDefAbbr, ) || this.unit.taskDefinitionCache.currentValues[0]; + // A unit with no tasks yet has nothing to explore, and reading the id of the + // missing first task used to stop the explorer before it could say so. + if (!taskDef) { + return; + } this.filters.taskDefinitionIdSelected = taskDef.id; this.filters.taskDefinition = taskDef; } @@ -571,15 +751,32 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { private refreshData() { const fetchMyStudentsOnly = this.filters.tutorialIdSelected === 'mine'; + // The explorer asks for one task's submissions. A unit with no tasks has none to + // ask for, and the request it used to send named a task that does not exist. + if (this.isTaskDefMode && !this.filters?.taskDefinitionIdSelected) { + this.taskRequestSub?.unsubscribe(); + this.tasks = []; + this.applyFilters(); + this.loading = false; + this.loadError = false; + return; + } + this.loading = true; + this.loadError = false; + // A unit or filter change can start a second query before the previous one + // returns. Cancel the older query so it cannot land late and put stale tasks + // back on screen after the component has moved to the new unit. + this.taskRequestSub?.unsubscribe(); // Tasks for feedback or tasks for task, depending on the data source - this.taskData + this.taskRequestSub = this.taskData .source(this.unit, this.filters?.taskDefinitionIdSelected, fetchMyStudentsOnly) .subscribe({ next: (response) => { this.tasks = response; this.applyFilters(); this.loading = false; + this.loadError = false; this.fetchedAllTasks = !fetchMyStudentsOnly && !this.isTaskDefMode; @@ -595,10 +792,65 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { error: (message) => { this.alertService.error(message, 6000); this.loading = false; + this.loadError = true; }, }); } + /** + * The task whose row actions are being held open by keyboard focus, if any. Focus is + * tracked separately from task.hover so that neither path can close the other: tabbing + * away used to run the same handler as mouseout and would fade the options button out + * from under a pointer that was still sitting on the row. + */ + focusedTaskId: number | null = null; + + /** + * Reveal the row actions because the pointer is over the row. Touch devices opt out + * of hover entirely via allowHover, which is why this is not simply `true`. + */ + showTaskActionsForPointer(task: Task) { + task.hover = this.allowHover; + } + + /** + * Hide the row actions again once the pointer leaves. This is the original mouseout + * behaviour and it deliberately touches nothing the keyboard owns. + */ + hideTaskActions(task: Task) { + task.hover = task.optionsOpened; + } + + /** + * Reveal the row actions because the submission options button took keyboard focus. + * Unlike the pointer path this always applies, since a keyboard is usable on a touch + * device even when hover is not. + */ + showTaskActionsForFocus(task: Task) { + this.focusedTaskId = task.id; + } + + /** + * Release the keyboard's hold on the row. Another row may already have claimed focus + * by the time this runs, so only the row that took it can give it back. + */ + hideTaskActionsForFocus(task: Task) { + if (this.focusedTaskId === task.id) { + this.focusedTaskId = null; + } + } + + /** + * Whether the row is showing its submission options in place of the pin indicator. Any + * one of the three reasons is enough: the pointer is on the row, the keyboard is on the + * options button, or the overflow menu it opened is still up. The menu case has to be + * here as well as in the pointer path, because opening the menu from the keyboard moves + * focus into the menu and so blurs the button that opened it. + */ + rowActionsShown(task: Task): boolean { + return task.hover || task.optionsOpened || this.focusedTaskId === task.id; + } + setSelectedTask(task: Task) { this.selectedTaskService.setSelectedTask(task); this.taskData.selectedTask = task; @@ -647,8 +899,12 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } previousTask(): void { + // The shortcut is live before the first query returns. + if (!this.filteredTasks) { + return; + } const currentTaskIndex = this.filteredTasks.findIndex((task) => this.isSelectedTask(task)); - if (currentTaskIndex === 0) { + if (currentTaskIndex <= 0) { return; } const newTask = this.filteredTasks[currentTaskIndex - 1]; diff --git a/src/app/units/states/tasks/inbox/inbox.component.spec.ts b/src/app/units/states/tasks/inbox/inbox.component.spec.ts index 408a72a359..c7bbbf6c8c 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.spec.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.spec.ts @@ -1,6 +1,6 @@ import {HotkeysService} from '@ngneat/hotkeys'; -import {MediaObserver} from 'ng-flex-layout'; -import {beforeEach, describe, expect, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {BreakpointObserver} from '@angular/cdk/layout'; import {NO_ERRORS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {MatDialog} from '@angular/material/dialog'; @@ -17,39 +17,111 @@ const selectedTaskServiceStub = { selectedTask$: EMPTY, }; const hotkeysServiceStub = { + getHotkeys: () => [], + addShortcut: () => EMPTY, + registerHelpModal: () => {}, removeShortcuts: () => {}, }; const emptyProvider = {}; +/** jsdom ships no ResizeObserver, so stand one in and deliver its entries by hand. */ +class FakeResizeObserver { + public static last: FakeResizeObserver | null = null; + public readonly observed: Element[] = []; + public disconnected = false; + + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.last = this; + } + + public observe(element: Element): void { + this.observed.push(element); + } + + public unobserve(): void {} + + public disconnect(): void { + this.disconnected = true; + } + + public report(width: number): void { + const entries = [{contentRect: {width}} as ResizeObserverEntry]; + this.callback(entries, this as unknown as ResizeObserver); + } +} + describe('InboxComponent', () => { let component: InboxComponent; let fixture: ComponentFixture; - beforeEach(async () => { + // The real template branches on a breakpoint and pulls in the whole task list, so each + // test sets the smallest markup that carries the ref and the binding it is about. + const create = async (template = '') => { await TestBed.configureTestingModule({ declarations: [InboxComponent], providers: [ {provide: HotkeysService, useValue: hotkeysServiceStub}, {provide: SelectedTaskService, useValue: selectedTaskServiceStub}, - {provide: MediaObserver, useValue: emptyProvider}, {provide: FileDownloaderService, useValue: emptyProvider}, {provide: Router, useValue: emptyProvider}, {provide: MatDialog, useValue: emptyProvider}, {provide: UserService, useValue: emptyProvider}, {provide: DoubtfireConstants, useValue: emptyProvider}, + {provide: BreakpointObserver, useValue: {observe: () => EMPTY, isMatched: () => false}}, ], schemas: [NO_ERRORS_SCHEMA], }) - .overrideComponent(InboxComponent, {set: {template: ''}}) + .overrideComponent(InboxComponent, {set: {template}}) .compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(InboxComponent); component = fixture.componentInstance; + }; + + beforeEach(() => { + FakeResizeObserver.last = null; + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); - it('should create', () => { + it('should create', async () => { + await create(); expect(component).toBeTruthy(); }); + + it('never measures the task list panel while Angular is checking it', async () => { + await create('
      '); + const measure = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + + // Measuring from the binding forced a reflow every check, and the list it feeds could + // change the answer between the check and the pass that verifies it, which is NG0100. + expect(() => fixture.detectChanges()).not.toThrow(); + fixture.detectChanges(); + + expect(measure).not.toHaveBeenCalled(); + }); + + it('takes the narrow list layout from a resize, and lets the observer go on destroy', async () => { + await create('
      '); + fixture.detectChanges(); + + const observer = FakeResizeObserver.last; + expect(observer.observed).toEqual([fixture.nativeElement.querySelector('div')]); + expect(component.narrowTaskInbox).toBe(false); + + observer.report(44); + fixture.detectChanges(); + expect(component.narrowTaskInbox).toBe(true); + + observer.report(350); + fixture.detectChanges(); + expect(component.narrowTaskInbox).toBe(false); + + fixture.destroy(); + expect(observer.disconnected).toBe(true); + }); }); diff --git a/src/app/units/states/tasks/inbox/inbox.component.ts b/src/app/units/states/tasks/inbox/inbox.component.ts index e0e107c4e3..aeb1530f70 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.ts @@ -1,19 +1,19 @@ import {HotkeysHelpComponent, HotkeysService} from '@ngneat/hotkeys'; -import {MediaObserver} from 'ng-flex-layout'; -import {CdkDragEnd, CdkDragMove, CdkDragStart} from '@angular/cdk/drag-drop'; import {BreakpointObserver} from '@angular/cdk/layout'; import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, ElementRef, Input, + NgZone, OnDestroy, OnInit, ViewChild, } from '@angular/core'; import {MatDialog} from '@angular/material/dialog'; import {Router} from '@angular/router'; -import {Observable, Subject, auditTime, merge, of, takeUntil, tap, withLatestFrom} from 'rxjs'; +import {Observable, Subject, takeUntil} from 'rxjs'; import {Tutorial} from 'src/app/api/models/doubtfire-model'; import {Task} from 'src/app/api/models/task'; import {TaskDefinition} from 'src/app/api/models/task-definition'; @@ -57,18 +57,24 @@ export class InboxComponent implements OnInit, OnDestroy { taskDefinitionIdSelected: number | TaskDefinition; }>; @Input() showSearchOptions: boolean; - @ViewChild('inboxpanel') inboxPanel: ElementRef; - @ViewChild('commentspanel') commentspanel: ElementRef; - @Input() viewType: 'inbox' | 'explorer' | 'moderation' | 'overflow'; + /** The desktop layout and the phone one each carry this ref, so it moves at the breakpoint. */ + @ViewChild('inboxpanel', {read: ElementRef}) + set inboxPanel(panel: ElementRef | undefined) { + const element = panel?.nativeElement ?? null; + if (element === this.measuredPanel) { + return; + } + this.measuredPanel = element; + this.observeInboxWidth(); + } - subs$: Observable; + @Input() viewType: 'inbox' | 'explorer' | 'moderation' | 'overflow'; - private inboxStartSize$: Subject = new Subject(); - private dragMove$: Subject<{event: CdkDragMove; div: HTMLDivElement}> = new Subject(); - private dragMoveAudited$; private readonly destroy$: Subject = new Subject(); private readonly commentsBreakpoint = '(max-width: 999.98px)'; + /** The legacy `xs` breakpoint retained for the mobile inbox. */ + private readonly mobileBreakpoint = '(max-width: 599.98px)'; // protected filters; // protected showSearchOptions; @@ -76,15 +82,35 @@ export class InboxComponent implements OnInit, OnDestroy { public taskSelected = false; public isCommentsNarrow = false; public commentsCollapsed = false; + /** The panel shown full screen, and the one shown when the panels stack. */ + public fullscreenPanel: string | null = null; + public activePanel: string | null = 'list'; visiblePdfUrl: string; - get narrowTaskInbox(): boolean { - return this.inboxPanel?.nativeElement.getBoundingClientRect().width < 150; - } + /** + * The task list panel is down to its rail, so the rows show as icons only. A + * ResizeObserver pushes this. Read as a getter it measured the panel on every check, + * and the list it feeds is what changes that measurement, so the check and the dev-mode + * pass that verifies it could get different answers. + */ + public narrowTaskInbox = false; + + /** Below this the list has no room for a row's text beside its avatar. */ + private readonly narrowInboxWidth = 150; + private measuredPanel: HTMLElement | null = null; + private inboxResizeObserver: ResizeObserver | null = null; get isMobileView(): boolean { - return this.mediaObserver.isActive('xs'); + return this.breakpointObserver.isMatched(this.mobileBreakpoint); + } + + /** The narrowest each desktop panel goes; see the project dashboard for why. */ + public readonly panelMinWidths = {list: 280, task: 420, comments: 320} as const; + + /** Explorer, moderation and the inbox each remember their own panels. */ + get panelPage(): string { + return `task-${this.viewType || 'inbox'}`; } get commentsPanelCollapsed(): boolean { @@ -94,13 +120,14 @@ export class InboxComponent implements OnInit, OnDestroy { constructor( private hotkeys: HotkeysService, private selectedTask: SelectedTaskService, - public mediaObserver: MediaObserver, public fileDownloader: FileDownloaderService, private router: Router, public dialog: MatDialog, private userService: UserService, private constants: DoubtfireConstants, private breakpointObserver: BreakpointObserver, + private changeDetector: ChangeDetectorRef, + private zone: NgZone, ) { this.selectedTask.currentPdfUrl$.subscribe((url) => { this.visiblePdfUrl = url; @@ -108,6 +135,10 @@ export class InboxComponent implements OnInit, OnDestroy { this.selectedTask.selectedTask$.subscribe((task) => { this.taskSelected = task != null; + this.activePanel = task ? 'task' : 'list'; + if (!task) { + this.fullscreenPanel = null; + } }); } @@ -174,39 +205,12 @@ export class InboxComponent implements OnInit, OnDestroy { }); } - this.dragMoveAudited$ = this.dragMove$.pipe( - withLatestFrom(this.inboxStartSize$), - auditTime(30), - tap(([moveEvent, startSize]) => { - window.dispatchEvent(new Event('resize')); - - let newWidth: number; - let width: number; - if (moveEvent.div.id === 'inboxpanel') { - newWidth = startSize + moveEvent.event.distance.x; - - // if width is belo 250, snap to 50px - if (newWidth < 250 && newWidth > 100) { - width = 250; - } else if (newWidth < 150) { - width = 50; - } else { - width = Math.min(newWidth, 500); - } - } else { - newWidth = startSize - moveEvent.event.distance.x; - width = Math.min(Math.max(newWidth, 250), 500); - } - moveEvent.div.style.width = `${width}px`; - moveEvent.event.source.reset(); - }), - ); - this.subs$ = merge(this.dragMoveAudited$, of(true)); window.dispatchEvent(new Event('resize')); } ngOnDestroy(): void { - document.body.classList.remove('split-pane-resizing'); + this.inboxResizeObserver?.disconnect(); + this.inboxResizeObserver = null; this.destroy$.next(); this.destroy$.complete(); this.hotkeys.removeShortcuts('control.shift.d'); @@ -215,27 +219,34 @@ export class InboxComponent implements OnInit, OnDestroy { this.hotkeys.removeShortcuts('shift.?'); } - public toggleCommentsPanel(): void { - this.commentsCollapsed = !this.commentsCollapsed; - window.dispatchEvent(new Event('resize')); - } - - startedDragging(event: CdkDragStart, div: HTMLDivElement) { - document.body.classList.add('split-pane-resizing'); - event.source.element.nativeElement.classList.add('hovering'); - const w = div.getBoundingClientRect().width; - this.inboxStartSize$.next(w); + private observeInboxWidth(): void { + this.inboxResizeObserver?.disconnect(); + this.inboxResizeObserver = null; + if (!this.measuredPanel || typeof ResizeObserver === 'undefined') { + return; + } + // Observing delivers a first entry by itself, so nothing here has to measure the + // panel, and the callback runs after layout rather than inside a check. + this.inboxResizeObserver = new ResizeObserver((entries) => { + const width = entries[entries.length - 1]?.contentRect.width; + if (typeof width === 'number') { + this.zone.run(() => this.setNarrowTaskInbox(width < this.narrowInboxWidth)); + } + }); + this.inboxResizeObserver.observe(this.measuredPanel); } - dragging(event: CdkDragMove, div: HTMLDivElement) { - this.dragMove$.next({event, div}); - event.source.reset(); + private setNarrowTaskInbox(narrow: boolean): void { + if (this.narrowTaskInbox === narrow) { + return; + } + this.narrowTaskInbox = narrow; + this.changeDetector.markForCheck(); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - stoppedDragging(event: CdkDragEnd, _div: HTMLDivElement) { - document.body.classList.remove('split-pane-resizing'); - event.source.element.nativeElement.classList.remove('hovering'); + public toggleCommentsPanel(): void { + this.commentsCollapsed = !this.commentsCollapsed; + window.dispatchEvent(new Event('resize')); } goToStudent(): void { diff --git a/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.spec.ts b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.spec.ts new file mode 100644 index 0000000000..27c5515f65 --- /dev/null +++ b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.spec.ts @@ -0,0 +1,186 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {ActivatedRoute, ParamMap, Router, convertToParamMap} from '@angular/router'; +import {Observable, ReplaySubject, Subject, of} from 'rxjs'; +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 {UnitService} from 'src/app/api/services/unit.service'; +import {UserService} from 'src/app/api/services/user.service'; +import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {UnitTaskInboxStateComponent} from './unit-task-inbox-state.component'; + +function unitStub(id: number): Unit { + return {id, taskDefinitions: []} as unknown as Unit; +} + +describe('UnitTaskInboxStateComponent', () => { + const emptyParamMap = convertToParamMap({}); + let fetchedUnitIds: number[]; + let unitFetches: Map>; + let holdUnitFetches: boolean; + let paramMap$: Subject; + let queryParamMap$: Subject; + + // Answers straight away unless a test holds the response back, so it can stage a + // request for the unit the user has already navigated away from. + function fetchUnit(unitId: number): Observable { + fetchedUnitIds.push(unitId); + const response: ReplaySubject = new ReplaySubject(1); + unitFetches.set(unitId, response); + + if (!holdUnitFetches) { + response.next(unitStub(unitId)); + } + + return response; + } + + beforeEach(async () => { + fetchedUnitIds = []; + unitFetches = new Map(); + holdUnitFetches = false; + // Subjects rather than of(...), which completes on subscribe and so can never + // show a leak, which is what the teardown test below is for. + paramMap$ = new Subject(); + queryParamMap$ = new Subject(); + + await TestBed.configureTestingModule({ + declarations: [UnitTaskInboxStateComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: {routeMode: 'inbox'}, + paramMap: emptyParamMap, + queryParamMap: emptyParamMap, + }, + paramMap: paramMap$, + queryParamMap: queryParamMap$, + parent: {parent: {snapshot: {data: {}}}}, + }, + }, + {provide: Router, useValue: {navigate: () => {}}}, + { + provide: UnitService, + useValue: { + fetch: (unitId: number) => fetchUnit(unitId), + get: (unitId: number) => fetchUnit(unitId), + }, + }, + {provide: ProjectService, useValue: {loadStudents: () => of([])}}, + { + provide: TaskService, + useValue: {queryTasksForTaskInbox: () => of([]), taskKeyFromString: () => null}, + }, + {provide: UserService, useValue: {currentUser: {id: 1, role: 'Convenor'}}}, + { + provide: GlobalStateService, + useValue: { + setInboxState: () => {}, + setNotInboxState: () => {}, + setView: () => {}, + currentViewAndEntitySubject$: {value: null}, + loadedUnitRoles: {currentValues: []}, + }, + }, + {provide: SelectedTaskService, useValue: {setSelectedTask: () => {}}}, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(UnitTaskInboxStateComponent, {set: {template: ''}}) + .compileComponents(); + }); + + it('loads the new unit when the route resolves a different unit', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(101)); + expect(fetchedUnitIds).toEqual([101]); + expect(component.unit.id).toBe(101); + + unit$.next(unitStub(102)); + expect(fetchedUnitIds).toEqual([101, 102]); + expect(component.unit.id).toBe(102); + }); + + it('does not reload when the route resolves the same unit again', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(201)); + unit$.next(unitStub(201)); + + expect(fetchedUnitIds).toEqual([201]); + }); + + // Gates card step 7. The two route subscriptions used to be left running for the + // rest of the session, and unit$ alone cannot show that because first() closed it. + it('stops listening to the route once it is destroyed', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(301)); + expect(paramMap$.observed).toBe(true); + expect(queryParamMap$.observed).toBe(true); + + component.ngOnDestroy(); + unit$.next(unitStub(302)); + + expect(fetchedUnitIds).toEqual([301]); + expect(unit$.observed).toBe(false); + expect(paramMap$.observed).toBe(false); + expect(queryParamMap$.observed).toBe(false); + }); + + // Gates the out-of-order case: the request for the unit we left must be dropped, not + // allowed to land late and put that unit back on screen. + it('drops a slow request for the unit that was navigated away from', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + holdUnitFetches = true; + + unit$.next(unitStub(401)); + unit$.next(unitStub(402)); + expect(fetchedUnitIds).toEqual([401, 402]); + + unitFetches.get(402).next(unitStub(402)); + expect(component.unit.id).toBe(402); + + // Unit 401's request finally answers, long after the user moved on. + unitFetches.get(401).next(unitStub(401)); + + expect(component.unit.id).toBe(402); + }); + + it('drops filters that refer to the previous unit', () => { + const component = TestBed.createComponent(UnitTaskInboxStateComponent).componentInstance; + const unit$: Subject = new Subject(); + component.unit$ = unit$; + component.ngOnInit(); + + unit$.next(unitStub(501)); + component.filters = { + tutorialIdSelected: 5010, + tutorials: [{id: 5010}] as never, + unitRoleIdSelected: 5011, + }; + + unit$.next(unitStub(502)); + + expect(component.filters).toEqual({}); + }); +}); diff --git a/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.ts b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.ts index 05f22ae741..252117de24 100644 --- a/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.ts +++ b/src/app/units/states/tasks/inbox/unit-task-inbox-state.component.ts @@ -1,6 +1,15 @@ import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; -import {Observable, first, of, tap} from 'rxjs'; +import { + Observable, + Subject, + distinctUntilChanged, + first, + of, + switchMap, + takeUntil, + tap, +} from 'rxjs'; import { ProjectService, TaskDefinition, @@ -82,6 +91,8 @@ export class UnitTaskInboxStateComponent implements OnInit, OnDestroy { taskDefMode: false, }; + private readonly destroy$: Subject = new Subject(); + constructor( private taskService: TaskService, private globalStateService: GlobalStateService, @@ -106,22 +117,31 @@ export class UnitTaskInboxStateComponent implements OnInit, OnDestroy { this.unit$ = of(routeUnit); } - this.unit$.pipe(first()).subscribe((routeUnit) => { - this.loadUnit(routeUnit.id) - .pipe(first()) - .subscribe((unit) => this.loadInboxData(unit)); - }); - - this.route.paramMap.subscribe(() => { + // switchMap so a slow request for the unit we just left is cancelled rather than + // landing late and putting that unit back on screen. + this.unit$ + .pipe( + distinctUntilChanged((a, b) => a?.id === b?.id), + tap(() => { + this.studentsLoaded = false; + }), + switchMap((currentUnit) => this.loadUnit(currentUnit.id).pipe(first())), + takeUntil(this.destroy$), + ) + .subscribe((unit) => this.loadInboxData(unit)); + + this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe(() => { this.setTaskKeyFromRoute(); }); - this.route.queryParamMap.subscribe(() => { + this.route.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe(() => { this.setTaskKeyFromRoute(); }); } ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); this.globalStateService.setNotInboxState(); this.clearSelectedTask(); } @@ -203,14 +223,15 @@ export class UnitTaskInboxStateComponent implements OnInit, OnDestroy { this.globalStateService.setView(ViewType.UNIT, unit); } - this.filters = { - ...this.filters, - ...this.getFilterOverrides(unit), - }; + // Filter objects contain tutorials and role ids from one unit. Reusing the + // previous object after a route change can leave the new unit filtered by a + // tutorial that does not exist there, making a successfully loaded inbox + // appear empty. Start from the new route's overrides for each unit instead. + this.filters = {...this.getFilterOverrides(unit)}; this.projectService .loadStudents(unit) - .pipe(first()) + .pipe(first(), takeUntil(this.destroy$)) .subscribe({ next: () => { this.studentsLoaded = true; diff --git a/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.html b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.html index ffa6367f9b..8303e06264 100644 --- a/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.html +++ b/src/app/units/task-viewer/directives/task-details-view/task-details-view.component.html @@ -1,21 +1,12 @@ -
      -
      - - - Task Details - - {{ panelOpenState() ? 'Hide' : 'Show' }} task details - - - - +@if (taskDef) { + + + Task details + + + +} @else { +
      +
      - -
      - subtitles_off -
      -
      +} diff --git a/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.html b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.html index 1a475cc9cf..1ea1787f0b 100644 --- a/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.html +++ b/src/app/units/task-viewer/directives/task-sheet-view/task-sheet-view.component.html @@ -1,17 +1,16 @@ -
      - @switch (taskDef) { - @case (taskDef) { - @if (taskDef?.hasTaskSheet) { - - } @else { -
      - subtitles_off No Task Sheet -
      - } - } +
      + @if (taskDef?.hasTaskSheet) { + + } @else { +
      + +
      } - - - -
      diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html index 3bde7ab01b..1ba839a13e 100644 --- a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html @@ -1,82 +1,125 @@ + + @let dueState = + task.inFinalState() + ? null + : task.isPastDeadline() + ? 'past-deadline' + : task.betweenDueDateAndDeadlineDate() + ? 'past-due' + : task.isDueSoon() + ? 'due-soon' + : null; + @let showGrade = task.hasGrade(); + @let showQuality = task.hasQualityPoints();
      - + @if (dueState || showGrade || showQuality) { +
      + @if (showGrade) { + + {{ task.gradeDesc() }} + + } + @if (showQuality) { + @let qualityLabel = + 'Quality ' + task.qualityPts + ' out of ' + task.definition.maxQualityPts; + + {{ task.qualityPts }}/{{ task.definition.maxQualityPts }} + + } + @switch (dueState) { + @case ('due-soon') { + + + + } + @case ('past-due') { + + + + } + + @case ('past-deadline') { + + + + + } + } +
      + } -
      + +
      + @if (task.numNewComments > 0) { {{ task.numNewComments }} } @if (task.similaritiesDetected) { - + }
      - -
      - @if (task.hasGrade()) { - - - {{ task.gradeDesc() }} - - - } - @if (task.hasQualityPoints()) { - - {{ task.qualityPts }} - ⁄ - {{ task.definition.maxQualityPts }} - - } - @if (task.isDueSoon() && !task.inFinalState()) { - - - schedule - - - } - @if (task.betweenDueDateAndDeadlineDate() && !task.isPastDeadline() && !task.inFinalState()) { - - - schedule - - - } - @if (task.isPastDeadline() && !task.inFinalState()) { - - - schedule - - ! - - } -
      @@ -88,56 +131,120 @@ }" > @if (!isCollapsed) { -
      -
      -
      - + } + + + + +
      + - search - - + + + @if (activeStatusFilter) { + + } @else { + + } + {{ activeStatusFilterLabel ?? 'All statuses' }} + + + + + + All statuses + + + @for (option of statusOptions; track option.status) { + + + + {{ option.label }} + + + } +
      + - -
      -
      + + +
      + -
      +
      @for (option of sortOptions; track option.value) {
      -
      + -
      - +
      -
      +
      @@ -188,19 +313,34 @@
      + + @if (activeStatusFilterLabel) { +
      + Showing {{ activeStatusFilterLabel }} tasks + +
      + }
      }
      @for (taskDef of filteredTaskDefinitions; track taskDef) { - {{ taskDef.abbreviation }} + {{ taskDef.name }} @if (taskListItem(taskDef); as task) { } @else { -
      -
      -
      -
      -
      {{ taskDef.name }}
      - -
      - @if (taskDef.isGroupTask()) { - groups - } @else { - person - } +
      +
      +
      + {{ taskDef.abbreviation }} + {{ taskDef.name }} +
      -
      - {{ taskDef.abbreviation }} - - - {{ gradeNames[taskDef.targetGrade] }} Task -
      - @if (taskListItem(taskDef); as task) { - @if (taskStartApproaching(task)) { - - event_upcoming - {{ taskStartLabel(task) }} - - } @else if (taskOngoing(task)) { - - event_upcoming - Start now - - } @else if (!task.isBeforeStartDate() && !task.inSubmittedState()) { - - - hourglass_bottom - - {{ task.timeToDue() }} - - } - } -
      + @if (taskListItem(taskDef); as task) { +
      + {{ taskStatusLabel(task) }} + @if (taskStartApproaching(task)) { + + event + {{ taskStartLabel(task) }} + + } @else if (taskOngoing(task)) { + + event + Start now + + } @else if (!task.isBeforeStartDate() && !task.inSubmittedState()) { + + hourglass_bottom + {{ task.timeToDue() }} + + }
      + } + +
      + + {{ gradeNames[taskDef.targetGrade] }} task
      @if (taskListItem(taskDef); as task) { @@ -286,14 +423,33 @@
      } } - + } @empty { -
      No tasks to display
      +
      + + @if (activeStatusFilterLabel || searchText) { + No matching tasks + + Try a different search or clear the + {{ activeStatusFilterLabel ? activeStatusFilterLabel + ' status filter' : 'search' }}. + +
      + @if (searchText) { + + } + @if (activeStatusFilterLabel) { + + } +
      + } @else { + No tasks to display + } +
      } @if (mode === 'project' && project) { @@ -303,16 +459,14 @@ [routerLink]="['/projects', project.id, 'portfolio']" > history_edu + Create portfolio
      - +
      } @else { diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts index dad93b4165..47ae839bf3 100644 --- a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.spec.ts @@ -1,16 +1,21 @@ -import {beforeEach, describe, expect, it} from 'vitest'; -import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {Directive, NO_ERRORS_SCHEMA, SimpleChange} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {ActivatedRoute, Router} from '@angular/router'; -import {Task, TaskDefinition} from 'src/app/api/models/doubtfire-model'; +import {ActivatedRoute, Router, convertToParamMap} from '@angular/router'; +import {BehaviorSubject, Subject} from 'rxjs'; +import {Project, Task, TaskDefinition, TaskStatusEnum} from 'src/app/api/models/doubtfire-model'; +import {UserService} from 'src/app/api/services/user.service'; import {FUnitTaskListComponent} from './unit-task-list.component'; -const emptyProvider = {}; +const flushTaskSelection = async (): Promise => { + await new Promise((resolve) => queueMicrotask(resolve)); +}; const taskDefinition = ( id: number, abbreviation: string, startDate = new Date(2026, 0, id + 1), + targetGrade = 0, ): TaskDefinition => ({ id, @@ -18,24 +23,72 @@ const taskDefinition = ( abbreviation, name: abbreviation, startDate, + targetGrade, + targetGradeText: 'Pass', }) as TaskDefinition; -const taskForDefinition = (definition: TaskDefinition, topWeight: number): Task => +const taskForDefinition = ( + definition: TaskDefinition, + topWeight: number, + numNewComments = 0, + status: TaskStatusEnum = 'not_started', +): Task => ({ definition, topWeight, + numNewComments, + status, }) as Task; +const studentProject = () => + ({ + id: 10, + targetGrade: 0, + unit: {id: 20}, + calcTopTasks: () => undefined, + }) as unknown as Project; + +// Mirrors the wiring ngOnInit does, without the route lookup it also performs. +const openTaskDefinition = ( + component: FUnitTaskListComponent, + taskDef: TaskDefinition, +): BehaviorSubject => { + const selectedTaskDefinition$: BehaviorSubject = new BehaviorSubject(taskDef); + component.selectedTaskDefinition$ = selectedTaskDefinition$; + selectedTaskDefinition$.subscribe((value) => (component.selectedTaskDef = value)); + + return selectedTaskDefinition$; +}; + describe('FUnitTaskListComponent', () => { let component: FUnitTaskListComponent; let fixture: ComponentFixture; + let routeParamMap$: Subject>; + let routeQueryParamMap$: Subject>; + let routerNavigate: ReturnType; beforeEach(async () => { + vi.spyOn(Storage.prototype, 'getItem').mockReturnValue(null); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => undefined); + routeParamMap$ = new Subject>(); + routeQueryParamMap$ = new Subject>(); + routerNavigate = vi.fn().mockResolvedValue(true); + await TestBed.configureTestingModule({ declarations: [FUnitTaskListComponent], providers: [ - {provide: Router, useValue: emptyProvider}, - {provide: ActivatedRoute, useValue: emptyProvider}, + { + provide: Router, + useValue: {navigate: routerNavigate, createUrlTree: vi.fn()}, + }, + {provide: UserService, useValue: {currentUser: {id: 99}}}, + { + provide: ActivatedRoute, + useValue: { + paramMap: routeParamMap$.asObservable(), + queryParamMap: routeQueryParamMap$.asObservable(), + }, + }, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -44,19 +97,188 @@ describe('FUnitTaskListComponent', () => { }); beforeEach(() => { + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }); fixture = TestBed.createComponent(FUnitTaskListComponent); component = fixture.componentInstance; + component.taskDefinitions = []; + component.tasks = []; + component.selectedTaskDefinition$ = new BehaviorSubject(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); + afterEach(() => vi.unstubAllGlobals()); + it('should create', () => { expect(component).toBeTruthy(); }); + it('releases the task list scrollbar to the phone document while preserving desktop styles', () => { + const styles = ( + FUnitTaskListComponent as unknown as { + ɵcmp: {styles: string[]}; + } + ).ɵcmp.styles.join('\n'); + + expect(styles).toMatch( + /@media\s*\(max-width:\s*639\.98px\)[\s\S]*?\.scrollable[^{]*\{[^}]*overflow-y:\s*visible/, + ); + expect(styles).toMatch( + /@media\s*\(max-width:\s*639\.98px\)[\s\S]*?\.scrollable[^{]*\{[^}]*overflow-x:\s*clip/, + ); + expect(styles).toMatch(/\.scrollable[^{]*\{[^}]*overflow-y:\s*scroll/); + }); + + it('follows task route changes without recreating the component', async () => { + const firstTask = taskDefinition(1, '1.1P'); + const secondTask = taskDefinition(2, '2.3P'); + + component.taskDefinitions = [firstTask, secondTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: firstTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(firstTask); + expect(component.selectedTaskDef).toBe(firstTask); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: secondTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(secondTask); + expect(component.selectedTaskDef).toBe(secondTask); + }); + + // The unit resolves progressively, so on a hard refresh of a deep link the + // route parameter arrives while taskDefinitions is still empty. Nothing used to + // re-apply it once the list turned up, and the screen landed on no task. + it('applies a deep linked task once the task definitions arrive', async () => { + const task = taskDefinition(1, '1.1P'); + + component.taskDefinitions = []; + fixture.detectChanges(); + + routeParamMap$.next(convertToParamMap({taskAbbreviation: task.abbreviation})); + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBeNull(); + + component.taskDefinitions = [task]; + component.ngOnChanges({taskDefinitions: {} as never}); + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(task); + }); + + it('clears a stale selection when the route task does not exist', async () => { + const existingTask = taskDefinition(1, '1.1P'); + + component.taskDefinitions = [existingTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: existingTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(existingTask); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: 'does-not-exist', + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBeNull(); + expect(component.selectedTaskDef).toBeNull(); + }); + + it('clears the selection when the task parameter is removed', async () => { + const existingTask = taskDefinition(1, '1.1P'); + + component.taskDefinitions = [existingTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: existingTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(existingTask); + + routeParamMap$.next(convertToParamMap({})); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBeNull(); + expect(component.selectedTaskDef).toBeNull(); + }); + + it('stops following route changes after the component is destroyed', async () => { + const firstTask = taskDefinition(1, '1.1P'); + const secondTask = taskDefinition(2, '2.3P'); + + component.taskDefinitions = [firstTask, secondTask]; + + fixture.detectChanges(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: firstTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(firstTask); + + component.ngOnDestroy(); + + routeParamMap$.next( + convertToParamMap({ + taskAbbreviation: secondTask.abbreviation, + }), + ); + + await flushTaskSelection(); + + expect(component.selectedTaskDefinition$.value).toBe(firstTask); + expect(component.selectedTaskDef).toBe(firstTask); + }); + it('sorts task definitions by task top weight by default', () => { const middlePriorityTask = taskDefinition(0, 'C'); const lowPriorityTask = taskDefinition(1, 'A'); const highPriorityTask = taskDefinition(2, 'B'); + component.taskDefinitions = [lowPriorityTask, middlePriorityTask, highPriorityTask]; + component.tasks = [ taskForDefinition(middlePriorityTask, 1), taskForDefinition(lowPriorityTask, 2), @@ -76,7 +298,9 @@ describe('FUnitTaskListComponent', () => { const middlePriorityTask = taskDefinition(0, 'C'); const lowPriorityTask = taskDefinition(1, 'A'); const highPriorityTask = taskDefinition(2, 'B'); + component.taskDefinitions = [lowPriorityTask, middlePriorityTask, highPriorityTask]; + component.tasks = [ taskForDefinition(middlePriorityTask, 1), taskForDefinition(lowPriorityTask, 2), @@ -84,6 +308,7 @@ describe('FUnitTaskListComponent', () => { ]; component.setSortBy('abbreviation'); + expect(component.filteredTaskDefinitions).toEqual([ lowPriorityTask, highPriorityTask, @@ -91,6 +316,7 @@ describe('FUnitTaskListComponent', () => { ]); component.setSortBy('default'); + expect(component.filteredTaskDefinitions).toEqual([ highPriorityTask, middlePriorityTask, @@ -102,10 +328,496 @@ describe('FUnitTaskListComponent', () => { const firstTask = taskDefinition(0, 'C'); const secondTask = taskDefinition(1, 'A'); const thirdTask = taskDefinition(2, 'B'); + component.taskDefinitions = [thirdTask, firstTask, secondTask]; component.applyFilters(); expect(component.filteredTaskDefinitions).toEqual([firstTask, secondTask, thirdTask]); }); + + it('shows only tasks at or below the project target grade by default', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + const distinctionTask = taskDefinition(3, 'D1', undefined, 2); + component.project = { + id: 10, + targetGrade: 0, + unit: {id: 20}, + } as Project; + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask, distinctionTask]; + component.tasks = []; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([passTask]); + expect(component.activeViewPreferenceCount).toBe(1); + }); + + it('reveals tasks beyond the target grade only when explicitly selected', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + component.project = { + id: 10, + targetGrade: 0, + unit: {id: 20}, + } as Project; + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask]; + component.tasks = []; + + component.toggleShowAboveTargetGrade(true); + + expect(component.filteredTaskDefinitions).toEqual([passTask, creditTask]); + expect(component.activeViewPreferenceCount).toBe(0); + + component.resetViewPreferences(); + + expect(component.filteredTaskDefinitions).toEqual([passTask]); + expect(component.activeViewPreferenceCount).toBe(1); + }); + + it('reapplies target-grade filtering when the selected target changes', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + component.project = { + id: 10, + targetGrade: 0, + unit: {id: 20}, + } as Project; + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask]; + component.tasks = []; + component.applyFilters(); + + component.targetGrade = 1; + component.project.targetGrade = 1; + component.ngOnChanges({targetGrade: new SimpleChange(0, 1, false)}); + + expect(component.filteredTaskDefinitions).toEqual([passTask, creditTask]); + }); + + it('does not grade-filter an all-tasks list without a student project', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const distinctionTask = taskDefinition(2, 'D1', undefined, 2); + component.mode = 'all-tasks'; + component.taskDefinitions = [passTask, distinctionTask]; + component.tasks = []; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([passTask, distinctionTask]); + }); + + it('keeps the open task selected when the search term stops matching it', () => { + const openTask = taskDefinition(1, 'P1'); + const otherTask = taskDefinition(2, 'P2'); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [openTask, otherTask]; + component.tasks = []; + const selectedTaskDefinition$ = openTaskDefinition(component, openTask); + + component.searchText = 'P1'; + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([openTask]); + expect(selectedTaskDefinition$.value).toBe(openTask); + + component.searchText = 'P2'; + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([otherTask]); + expect(selectedTaskDefinition$.value).toBe(openTask); + }); + + it('drops the selection when a view filter hides the open task', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask]; + component.tasks = []; + component.toggleShowAboveTargetGrade(true); + const selectedTaskDefinition$ = openTaskDefinition(component, creditTask); + + component.toggleShowAboveTargetGrade(false); + + expect(component.filteredTaskDefinitions).toEqual([passTask]); + expect(selectedTaskDefinition$.value).toBeNull(); + }); + + it('drops the selection when the open task leaves the task definitions', () => { + const passTask = taskDefinition(1, 'P1'); + const removedTask = taskDefinition(2, 'P2'); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [passTask, removedTask]; + component.tasks = []; + const selectedTaskDefinition$ = openTaskDefinition(component, removedTask); + + component.taskDefinitions = [passTask]; + component.applyFilters(); + + expect(selectedTaskDefinition$.value).toBeNull(); + }); + + // A notification or link into another unit swaps the list while the old unit's task + // is still selected. The url names the task to open there, so the list must follow it + // instead of rewriting the address to the unit overview. + describe('when the list moves to another unit', () => { + let navigateByUrl: ReturnType; + + beforeEach(() => { + navigateByUrl = vi.fn(); + const internals = component as unknown as { + angularRouter: unknown; + route: unknown; + routeTaskAbbreviation: string | null; + }; + internals.angularRouter = { + createUrlTree: vi.fn((commands: unknown[]) => commands.join('/')), + navigateByUrl, + }; + internals.route = { + paramMap: routeParamMap$.asObservable(), + parent: { + snapshot: { + paramMap: convertToParamMap({projectId: '3'}), + data: {project: {}}, + }, + }, + }; + component.project = studentProject(); + component.targetGrade = 0; + component.tasks = []; + }); + + it('opens the linked task in the new unit and keeps it in the url', () => { + const oldUnitTask = taskDefinition(1, 'DUE3'); + const newUnitTask = taskDefinition(11, 'DUE3'); + component.taskDefinitions = [oldUnitTask]; + const selectedTaskDefinition$ = openTaskDefinition(component, oldUnitTask); + (component as unknown as {routeTaskAbbreviation: string}).routeTaskAbbreviation = 'DUE3'; + + component.taskDefinitions = [newUnitTask]; + component.ngOnChanges({taskDefinitions: {} as never}); + + expect(navigateByUrl).not.toHaveBeenCalled(); + expect(selectedTaskDefinition$.value).toBe(newUnitTask); + }); + + it('still drops a filtered-out task from the url', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + component.taskDefinitions = [passTask, creditTask]; + component.toggleShowAboveTargetGrade(true); + const selectedTaskDefinition$ = openTaskDefinition(component, creditTask); + (component as unknown as {routeTaskAbbreviation: string}).routeTaskAbbreviation = 'C1'; + + component.toggleShowAboveTargetGrade(false); + + expect(selectedTaskDefinition$.value).toBeNull(); + expect(navigateByUrl).toHaveBeenCalledWith('/projects/3/dashboard', {replaceUrl: true}); + }); + }); + + it('badges the filter button while tasks beyond the target grade are hidden', () => { + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [taskDefinition(1, 'P1')]; + component.tasks = []; + + component.applyFilters(); + + expect(component.hidingTasksAboveTargetGrade).toBe(true); + expect(component.activeViewPreferenceCount).toBe(1); + expect(component.hasNonDefaultViewPreferences).toBe(false); + + component.toggleShowAboveTargetGrade(true); + + expect(component.hidingTasksAboveTargetGrade).toBe(false); + expect(component.activeViewPreferenceCount).toBe(0); + expect(component.hasNonDefaultViewPreferences).toBe(true); + }); + + it('does not badge target-grade hiding on an all-tasks list', () => { + component.mode = 'all-tasks'; + component.taskDefinitions = [taskDefinition(1, 'P1')]; + component.tasks = []; + + component.applyFilters(); + + expect(component.activeViewPreferenceCount).toBe(0); + }); + + it('still shows a task beyond the target grade when it has unread comments', () => { + const passTask = taskDefinition(1, 'P1', undefined, 0); + const creditTask = taskDefinition(2, 'C1', undefined, 1); + const distinctionTask = taskDefinition(3, 'D1', undefined, 2); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [passTask, creditTask, distinctionTask]; + component.tasks = [ + taskForDefinition(creditTask, 2, 3), + taskForDefinition(distinctionTask, 3, 0), + ]; + + component.applyFilters(); + + expect(component.filteredTaskDefinitions).toEqual([passTask, creditTask]); + }); + + it('intersects canonical status routing with text search and clears either filter', () => { + const completeAlpha = taskDefinition(1, 'A1'); + completeAlpha.name = 'Alpha complete'; + const completeBeta = taskDefinition(2, 'B1'); + completeBeta.name = 'Beta complete'; + const workingAlpha = taskDefinition(3, 'A2'); + workingAlpha.name = 'Alpha working'; + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [completeAlpha, completeBeta, workingAlpha]; + component.tasks = [ + taskForDefinition(completeAlpha, 1, 0, 'complete'), + taskForDefinition(completeBeta, 2, 0, 'complete'), + taskForDefinition(workingAlpha, 3, 0, 'working_on_it'), + ]; + + fixture.detectChanges(); + routeQueryParamMap$.next(convertToParamMap({taskStatus: 'complete'})); + component.searchText = 'Alpha'; + component.applyFilters(); + + expect(component.activeStatusFilter).toBe('complete'); + expect(component.activeStatusFilterLabel).toBe('Complete'); + expect(component.filteredTaskDefinitions).toEqual([completeAlpha]); + + component.clearSearch(); + expect(component.filteredTaskDefinitions).toEqual([completeAlpha, completeBeta]); + + component.setStatusFilter(null); + expect(component.filteredTaskDefinitions).toEqual([completeAlpha, completeBeta, workingAlpha]); + expect(routerNavigate).toHaveBeenLastCalledWith([], { + relativeTo: expect.anything(), + queryParams: {taskStatus: null, taskView: 'tasks'}, + queryParamsHandling: 'merge', + }); + }); + + it('rejects unknown status query values and preserves a zero-result canonical filter', () => { + const task = taskDefinition(1, 'A1'); + component.project = studentProject(); + component.targetGrade = 0; + component.taskDefinitions = [task]; + component.tasks = [taskForDefinition(task, 1, 0, 'not_started')]; + + fixture.detectChanges(); + routeQueryParamMap$.next(convertToParamMap({taskStatus: 'invented-status'})); + expect(component.activeStatusFilter).toBeNull(); + expect(component.filteredTaskDefinitions).toEqual([task]); + + routeQueryParamMap$.next(convertToParamMap({taskStatus: 'fail'})); + expect(component.activeStatusFilter).toBe('fail'); + expect(component.filteredTaskDefinitions).toEqual([]); + }); + + it('stores view preferences under the current user and unit instead of a global key', () => { + component.project = studentProject(); + component.taskDefinitions = [taskDefinition(1, 'A1')]; + + component.setSortBy('abbreviation'); + + expect(globalThis.localStorage.setItem).toHaveBeenCalledWith( + 'ontrack.user.99.unitTaskList.20.viewPreferences', + expect.any(String), + ); + }); +}); + +// A11Y-COLOUR03. The TaskStatusBadges template is what the dashboard and the +// task viewer draw next to every task, and each badge is the only signal a +// student gets that a task has new comments, similarities or a deadline state. +// MatIcon hides the glyph and a tooltip is not a name for a non-interactive +// element, so without a role and label a screen reader got nothing, or a bare +// number for the comment count. These tests render the real template because +// what assistive technology reads is a question about the markup. StubMatMenu +// satisfies `#taskListFiltersMenu="matMenu"` in the filter menu, and +// NO_ERRORS_SCHEMA renders the other mat-* elements as plain markup. +@Directive({selector: 'mat-menu', exportAs: 'matMenu', standalone: false}) +class StubMatMenu {} + +describe('FUnitTaskListComponent task status badges', () => { + let fixture: ComponentFixture; + + const badgeTaskDefinition = { + ...taskDefinition(1, '1.1P'), + isGroupTask: () => false, + } as unknown as TaskDefinition; + + // Every badge condition starts off, so each test turns on only the state it + // is about. Only the members the template reads are present. + const makeTask = (overrides: Record = {}): Task => + ({ + definition: badgeTaskDefinition, + status: 'not_started', + numNewComments: 0, + similaritiesDetected: false, + qualityPts: 0, + hasGrade: () => false, + gradeDesc: () => '', + hasQualityPoints: () => false, + isDueSoon: () => false, + betweenDueDateAndDeadlineDate: () => false, + isPastDeadline: () => false, + inFinalState: () => false, + isBeforeStartDate: () => false, + inSubmittedState: () => false, + daysUntilDueDate: () => 20, + timeToDue: () => '', + localDueDate: (): Date => undefined, + ...overrides, + }) as unknown as Task; + + const render = (task: Task, isCollapsed = false): void => { + fixture = TestBed.createComponent(FUnitTaskListComponent); + const component = fixture.componentInstance; + component.mode = 'all-tasks'; + component.isCollapsed = isCollapsed; + component.taskDefinitions = [badgeTaskDefinition]; + component.tasks = [task]; + component.selectedTaskDefinition$ = new BehaviorSubject(null); + fixture.detectChanges(); + }; + + const badges = (): HTMLElement[] => + Array.from(fixture.nativeElement.querySelectorAll('[role="img"]')); + + const labels = (): string[] => badges().map((badge) => badge.getAttribute('aria-label')); + + beforeEach(async () => { + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }); + + await TestBed.configureTestingModule({ + declarations: [FUnitTaskListComponent, StubMatMenu], + providers: [ + {provide: Router, useValue: {}}, + {provide: UserService, useValue: {currentUser: {id: 99}}}, + { + provide: ActivatedRoute, + useValue: {paramMap: new Subject>()}, + }, + ], + // The real template is rendered on purpose: the fix is in the markup. + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + afterEach(() => vi.unstubAllGlobals()); + + // The last column is how many glyphs the badge draws. The comment badge draws + // only its count, and the past deadline badge adds a "!" after its icon. + it.each([ + ['one new comment', {numNewComments: 1}, '1 new comment', 0], + ['several new comments', {numNewComments: 3}, '3 new comments', 0], + ['similarities', {similaritiesDetected: true}, 'Similarities Detected', 1], + ['due soon', {isDueSoon: () => true}, 'Due soon', 1], + [ + 'due before the deadline', + {betweenDueDateAndDeadlineDate: () => true}, + 'Due, deadline not yet passed', + 1, + ], + ['past deadline', {isPastDeadline: () => true}, 'Past deadline', 2], + ])('names the %s badge and hides its glyph', (_name, overrides, label, glyphCount) => { + render(makeTask(overrides)); + + expect(labels()).toEqual([label]); + + const glyphs: HTMLElement[] = Array.from(badges()[0].querySelectorAll('mat-icon, strong')); + expect(glyphs).toHaveLength(glyphCount); + glyphs.forEach((glyph) => expect(glyph.getAttribute('aria-hidden')).toBe('true')); + }); + + // MatTooltip only skips adding aria-describedby when the tooltip text is + // exactly the aria-label, so any difference makes a screen reader say it twice. + it('gives the similarities badge the same label as its tooltip', () => { + render(makeTask({similaritiesDetected: true})); + + const [badge] = badges(); + expect(badge.getAttribute('aria-label')).toBe(badge.getAttribute('mattooltip')); + }); + + it('keeps the visible comment count as the bare number', () => { + render(makeTask({numNewComments: 4})); + + expect(badges()[0].textContent.trim()).toBe('4'); + expect(labels()).toEqual(['4 new comments']); + }); + + it('uses a different glyph for past deadline than for due, so colour is not the only signal', () => { + render(makeTask({betweenDueDateAndDeadlineDate: () => true})); + const dueGlyph = badges()[0].querySelector('mat-icon').textContent.trim(); + + render(makeTask({isPastDeadline: () => true})); + const pastDeadlineGlyph = badges()[0].querySelector('mat-icon').textContent.trim(); + + expect(dueGlyph).toBe('schedule'); + expect(pastDeadlineGlyph).toBe('event_busy'); + }); + + it('names the badges in the collapsed list as well', () => { + render(makeTask({numNewComments: 2, isDueSoon: () => true}), true); + + expect(labels()).toHaveLength(2); + expect(labels()).toEqual(expect.arrayContaining(['2 new comments', 'Due soon'])); + }); + + // Failure path: a badge whose condition is false is not rendered, so a + // student is never told about a state that does not apply. + it('renders no badges when nothing applies', () => { + render(makeTask()); + + expect(badges()).toEqual([]); + }); + + it('filters by status with a themed picker, not the browser select', () => { + render(makeTask()); + const component = fixture.componentInstance; + const setStatusFilter = vi.spyOn(component, 'setStatusFilter').mockImplementation(() => {}); + + expect(fixture.nativeElement.querySelector('select')).toBeNull(); + const picker: HTMLElement = fixture.nativeElement.querySelector( + 'mat-select[aria-label="Filter tasks by status"]', + ); + expect(picker).not.toBeNull(); + + const options: HTMLElement[] = Array.from(picker.querySelectorAll('mat-option')); + expect(options[0].textContent.trim()).toContain('All statuses'); + expect(options).toHaveLength(component.statusOptions.length + 1); + options.slice(1).forEach((option, index) => { + expect(option.textContent).toContain(component.statusOptions[index].label); + const icon = option.querySelector('status-icon') as HTMLElement & {status?: string}; + expect(icon?.status).toBe(component.statusOptions[index].status); + }); + + picker.dispatchEvent(Object.assign(new Event('selectionChange'), {value: 'complete'})); + expect(setStatusFilter).toHaveBeenCalledWith('complete'); + }); + + it('drops the deadline badges once the task is in a final state', () => { + render( + makeTask({ + isDueSoon: () => true, + isPastDeadline: () => true, + inFinalState: () => true, + }), + ); + + expect(badges()).toEqual([]); + }); }); diff --git a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts index bfce70d27d..8fcaa08e63 100644 --- a/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts +++ b/src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts @@ -4,12 +4,20 @@ import { HostBinding, Input, OnChanges, + OnDestroy, OnInit, SimpleChanges, } from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; -import {BehaviorSubject} from 'rxjs'; -import {Project, Task, TaskDefinition} from 'src/app/api/models/doubtfire-model'; +import {BehaviorSubject, Subject, takeUntil} from 'rxjs'; +import { + Project, + Task, + TaskDefinition, + TaskStatus, + TaskStatusEnum, +} from 'src/app/api/models/doubtfire-model'; +import {UserService} from 'src/app/api/services/user.service'; import {TaskDefinitionNamePipe} from 'src/app/common/filters/task-definition-name.pipe'; type TaskListSortOption = 'default' | 'abbreviation' | 'targetDate' | 'startDate' | 'dueDate'; @@ -20,7 +28,7 @@ interface TaskListViewPreferences { sortBy: TaskListSortOption; sortDirection: TaskListSortDirection; hideCompleted: boolean; - hideAboveTargetGrade: boolean; + showAboveTargetGrade: boolean; } interface TaskListSortOptionView { @@ -33,7 +41,7 @@ const DEFAULT_VIEW_PREFERENCES: TaskListViewPreferences = { sortBy: 'default', sortDirection: 'asc', hideCompleted: false, - hideAboveTargetGrade: false, + showAboveTargetGrade: false, }; const START_APPROACHING_DAYS = 7; @@ -45,9 +53,13 @@ const START_APPROACHING_DAYS = 7; changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class FUnitTaskListComponent implements OnChanges, OnInit { +export class FUnitTaskListComponent implements OnChanges, OnInit, OnDestroy { + private readonly destroy$: Subject = new Subject(); + private routeTaskAbbreviation: string | null = null; + @Input() mode: 'project' | 'all-tasks'; @Input() project: Project; + @Input() targetGrade: number; @Input() taskDefinitions: readonly TaskDefinition[]; @Input() tasks: readonly Task[]; @Input() isCollapsed = false; @@ -62,10 +74,9 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { @Input() selectedTaskDefinition$: BehaviorSubject; selectedTaskDef: TaskDefinition; - // @Output() selectedTask: EventEmitter = new EventEmitter(); - filteredTaskDefinitions: TaskDefinition[]; // list of tasks which match the taskSearch term searchText: string = ''; // task search term from user input + activeStatusFilter: TaskStatusEnum | null = null; taskDefinitionNamePipe = new TaskDefinitionNamePipe(); viewPreferences: TaskListViewPreferences = {...DEFAULT_VIEW_PREFERENCES}; sortOptions: TaskListSortOptionView[] = [ @@ -75,6 +86,10 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { {value: 'dueDate', label: 'Due date', icon: 'event_repeat'}, {value: 'abbreviation', label: 'Abbreviation', icon: 'sort_by_alpha'}, ]; + readonly statusOptions = TaskStatus.PEER_PROGRESS_DISPLAY_ORDER.map((status) => ({ + status, + label: TaskStatus.STATUS_LABELS.get(status) ?? status, + })); protected get gradeNames(): Record { const unit = this.project?.unit ?? this.taskDefinitions?.[0]?.unit; @@ -86,6 +101,7 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { constructor( private angularRouter: Router, private route: ActivatedRoute, + private userService: UserService, ) {} applyFilters() { @@ -97,8 +113,75 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { ); this.filteredTaskDefinitions = matchingTaskDefinitions + .filter((taskDef) => this.matchesStatusFilter(taskDef)) .filter((taskDef) => this.shouldShowTaskDefinition(taskDef)) .sort((a, b) => this.compareTaskDefinitions(a, b)); + + this.deselectHiddenTaskDefinition(); + } + + public clearSearch(): void { + if (!this.searchText) { + return; + } + + this.searchText = ''; + this.applyFilters(); + } + + public setStatusFilter(value: string | TaskStatusEnum | null): void { + const status = TaskStatus.isStatus(value) ? value : null; + if (status === this.activeStatusFilter) { + return; + } + + this.activeStatusFilter = status; + this.applyFilters(); + void this.angularRouter.navigate([], { + relativeTo: this.route, + queryParams: {taskStatus: status, taskView: 'tasks'}, + queryParamsHandling: 'merge', + }); + } + + public get activeStatusFilterLabel(): string | null { + return this.activeStatusFilter + ? (TaskStatus.STATUS_LABELS.get(this.activeStatusFilter) ?? this.activeStatusFilter) + : null; + } + + // The search term only narrows what the list shows, so typing must not close the + // open task. Drop the selection when the task itself has gone or a view filter + // hides it. + private deselectHiddenTaskDefinition(): void { + if (!this.selectedTaskDef) { + return; + } + + const selectedTaskDefinition = this.taskDefinitions?.find( + (taskDef) => taskDef.id === this.selectedTaskDef.id, + ); + + if (selectedTaskDefinition && this.shouldShowTaskDefinition(selectedTaskDefinition)) { + return; + } + + // Following a link or notification into another unit swaps this list while the + // old unit's task is still selected. The url already names the task to open in + // the new unit, so leave it for applyRouteTaskSelection. Rewriting it to the bare + // dashboard here is what used to land those links on the unit overview. + const routeOpensAnotherListedTask = + !!this.routeTaskAbbreviation && + !!this.taskDefinitions?.some( + (taskDef) => + taskDef.abbreviation === this.routeTaskAbbreviation && + taskDef.id !== this.selectedTaskDef.id, + ); + + this.selectedTaskDefinition$?.next(null); + if (!routeOpensAnotherListedTask) { + this.replaceSelectionUrl(null); + } } public setSortBy(sortBy: TaskListSortOption): void { @@ -127,10 +210,10 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { this.applyFilters(); } - public toggleHideAboveTargetGrade(value: boolean): void { + public toggleShowAboveTargetGrade(value: boolean): void { this.viewPreferences = { ...this.viewPreferences, - hideAboveTargetGrade: value, + showAboveTargetGrade: value, }; this.persistViewPreferences(); this.applyFilters(); @@ -170,7 +253,22 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { return ( (this.viewPreferences.sortBy !== 'default' ? 1 : 0) + (this.viewPreferences.hideCompleted ? 1 : 0) + - (this.viewPreferences.hideAboveTargetGrade ? 1 : 0) + (this.hidingTasksAboveTargetGrade ? 1 : 0) + ); + } + + // The list hides tasks beyond the target grade unless the student opts in, so the + // badge has to count the hiding, not the opt-in that switches it off. + public get hidingTasksAboveTargetGrade(): boolean { + return !this.viewPreferences.showAboveTargetGrade && this.effectiveTargetGrade !== null; + } + + public get hasNonDefaultViewPreferences(): boolean { + return ( + this.viewPreferences.sortBy !== DEFAULT_VIEW_PREFERENCES.sortBy || + this.viewPreferences.sortDirection !== DEFAULT_VIEW_PREFERENCES.sortDirection || + this.viewPreferences.hideCompleted !== DEFAULT_VIEW_PREFERENCES.hideCompleted || + this.viewPreferences.showAboveTargetGrade !== DEFAULT_VIEW_PREFERENCES.showAboveTargetGrade ); } @@ -179,15 +277,14 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { this.loadViewPreferences(); } - if ('project' in changes || 'taskDefinitions' in changes || 'tasks' in changes) { + if ( + 'project' in changes || + 'targetGrade' in changes || + 'taskDefinitions' in changes || + 'tasks' in changes + ) { this.applyFilters(); - - if ( - this.selectedTaskDef && - !this.filteredTaskDefinitions?.some((taskDef) => taskDef.id === this.selectedTaskDef.id) - ) { - this.selectedTaskDefinition$.next(null); - } + this.applyRouteTaskSelection(); } } @@ -207,6 +304,10 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { return this.taskForTaskDef(taskDef); } + public taskStatusLabel(task: Task): string { + return TaskStatus.STATUS_LABELS.get(task?.status) ?? 'Status unavailable'; + } + public taskStartApproaching(task: Task): boolean { return ( !!task && @@ -226,6 +327,13 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { return `Start in ${days} ${days === 1 ? 'day' : 'days'}`; } + // The badge only shows the count, so its accessible name carries the unit. + public newCommentsLabel(task: Task): string { + const count = task.numNewComments; + + return `${count} new ${count === 1 ? 'comment' : 'comments'}`; + } + public taskOngoing(task: Task): boolean { if (!task || task.inFinalState()) { return false; @@ -249,39 +357,73 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { this.applyFilters(); // Watch for changes in the selected task definition... including from us - this.selectedTaskDefinition$.subscribe((taskDef) => { + this.selectedTaskDefinition$.pipe(takeUntil(this.destroy$)).subscribe((taskDef) => { this.selectedTaskDef = taskDef; }); - // // TODO: Remove the service - // this.taskViewerService.selectedTaskDef.subscribe((taskDef) => { - // this.selectedTaskDef = taskDef; - // }); - - // this.taskViewerService.taskSelected.subscribe((taskSelected) => { - // this.taskSelected = taskSelected; - // }); + // Follow the selected task in the url, rather than reading it once. + // + // Angular reuses this component when only a route parameter changes, so + // going from .../dashboard/1.1P to .../dashboard/2.3P never runs ngOnInit + // again. Reading route.snapshot here left the first task selected and the + // second one never opened, which is what any in-app link to another task on + // a dashboard the user is already looking at runs into. A notification + // linking to a task is exactly that. + this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { + this.routeTaskAbbreviation = params.get('taskAbbreviation'); + queueMicrotask(() => this.applyRouteTaskSelection()); + }); - // // Select the first task definition by default - // if (this.taskDefinitions.length > 0) { - // this.setSelectedTaskDefinition(this.taskDefinitions[0]); - // } + this.route.queryParamMap?.pipe(takeUntil(this.destroy$)).subscribe((params) => { + const status = params.get('taskStatus'); + const nextFilter = TaskStatus.isStatus(status) ? status : null; + if (nextFilter !== this.activeStatusFilter) { + this.activeStatusFilter = nextFilter; + this.applyFilters(); + } + }); + } - // Load selected task from URL + // Applies whatever the route currently names, using the task definitions that + // exist at the moment it runs. Called from the paramMap subscription and again + // from ngOnChanges, because the unit resolves progressively and taskDefinitions + // is usually still empty when the parameter first arrives. Without the second + // call a hard refresh on a deep link lands on nothing. + private applyRouteTaskSelection(): void { const current = this.selectedTaskDefinition$.value; - const param = this.route.snapshot.paramMap.get('taskAbbreviation'); - queueMicrotask(() => { - if (param) { - const taskDef = this.taskDefinitions.find((t) => t.abbreviation === param); + const nextTaskDefinition = this.routeTaskAbbreviation + ? (this.taskDefinitions?.find( + (taskDefinition) => taskDefinition.abbreviation === this.routeTaskAbbreviation, + ) ?? null) + : null; + + // An empty list means the unit has not finished resolving, not that the task + // is missing. Leave the selection alone and wait for ngOnChanges to call back + // once the definitions arrive. A loaded list that does not contain the + // abbreviation is a different thing and still clears the selection below. + if (this.routeTaskAbbreviation && !this.taskDefinitions?.length) { + return; + } - if (taskDef !== current) { - this.selectedTaskDefinition$.next(taskDef); - } - } else if (current !== null) { - this.selectedTaskDefinition$.next(null); - } - }); + if (nextTaskDefinition && this.isTaskDefinitionAboveTargetGrade(nextTaskDefinition)) { + this.viewPreferences = {...this.viewPreferences, showAboveTargetGrade: true}; + this.persistViewPreferences(); + this.applyFilters(); + } + + // The comparison is what stops a click looping. Selecting a task navigates, + // that navigation makes paramMap emit, and the emission comes straight back + // in here. It also collapses the duplicate write from the second rendered + // instance of this component in the parent template. + if (nextTaskDefinition !== current) { + this.selectedTaskDefinition$.next(nextTaskDefinition); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } setSelectedTaskDefinition(taskDef: TaskDefinition) { @@ -292,15 +434,6 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { this.selectedTaskDefinition$.next(taskDef); this.replaceSelectionUrl(taskDef); } - - // this.selectedTaskDefinition.emit(taskDef); - // const selectedTask = this.taskForTaskDef(taskDef); - // if (selectedTask) { - // this.selectedTask$.next(selectedTask); - // } - - //TODO: remove - // this.taskViewerService.setSelectedTaskDef(taskDef); } public isSelectedTaskDefinition(taskDef: TaskDefinition): boolean { @@ -320,6 +453,7 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { if (this.selectionUrlBase) { return this.angularRouter.createUrlTree( taskDef ? [...this.selectionUrlBase, taskDef.abbreviation] : this.selectionUrlBase, + {queryParamsHandling: 'preserve'}, ); } @@ -327,6 +461,7 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { if (this.route.parent?.snapshot.data.unit && unitId) { return this.angularRouter.createUrlTree( taskDef ? ['/units', unitId, 'tasks', taskDef.abbreviation] : ['/units', unitId, 'tasks'], + {queryParamsHandling: 'preserve'}, ); } @@ -336,6 +471,7 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { taskDef ? ['/projects', projectId, 'dashboard', taskDef.abbreviation] : ['/projects', projectId, 'dashboard'], + {queryParamsHandling: 'preserve'}, ); } @@ -354,13 +490,36 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { } return !( - this.viewPreferences.hideAboveTargetGrade && - this.project?.targetGrade !== undefined && - this.project?.targetGrade !== null && - taskDef.targetGrade > this.project.targetGrade + !this.viewPreferences.showAboveTargetGrade && + this.isTaskDefinitionAboveTargetGrade(taskDef) && + !this.hasNewComments(task) ); } + private matchesStatusFilter(taskDef: TaskDefinition): boolean { + if (!this.activeStatusFilter) { + return true; + } + + return this.taskForTaskDef(taskDef)?.status === this.activeStatusFilter; + } + + private isTaskDefinitionAboveTargetGrade(taskDef: TaskDefinition): boolean { + const targetGrade = this.effectiveTargetGrade; + + return targetGrade !== null && taskDef.targetGrade > targetGrade; + } + + private get effectiveTargetGrade(): number | null { + const targetGrade = this.targetGrade ?? this.project?.targetGrade; + + if (!this.project || targetGrade === undefined || targetGrade === null) { + return null; + } + + return targetGrade; + } + private hasNewComments(task: Task): boolean { return (task?.numNewComments ?? 0) > 0; } @@ -434,7 +593,7 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { } private loadViewPreferences(): void { - const rawPreferences = localStorage.getItem(this.viewPreferencesStorageKey); + const rawPreferences = this.viewPreferencesStorage?.getItem(this.viewPreferencesStorageKey); if (!rawPreferences) { this.viewPreferences = {...DEFAULT_VIEW_PREFERENCES}; @@ -450,7 +609,9 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { ? parsedPreferences.sortDirection : migratedSort.sortDirection, hideCompleted: !!parsedPreferences.hideCompleted, - hideAboveTargetGrade: !!parsedPreferences.hideAboveTargetGrade, + // The legacy preference was named `hideAboveTargetGrade` and defaulted to false, + // which made higher-grade tasks visible. Treat legacy records as the new default. + showAboveTargetGrade: parsedPreferences.showAboveTargetGrade === true, }; } catch { this.viewPreferences = {...DEFAULT_VIEW_PREFERENCES}; @@ -458,7 +619,10 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { } private persistViewPreferences(): void { - localStorage.setItem(this.viewPreferencesStorageKey, JSON.stringify(this.viewPreferences)); + this.viewPreferencesStorage?.setItem( + this.viewPreferencesStorageKey, + JSON.stringify(this.viewPreferences), + ); } private isSortOption(value: unknown): value is TaskListSortOption { @@ -500,6 +664,15 @@ export class FUnitTaskListComponent implements OnChanges, OnInit { private get viewPreferencesStorageKey(): string { const unitId = this.project?.unit?.id ?? this.taskDefinitions?.[0]?.unit?.id ?? 'unknown'; - return `ontrack.unitTaskList.${unitId}.viewPreferences`; + const userId = this.userService.currentUser?.id ?? 'unknown'; + return `ontrack.user.${userId}.unitTaskList.${unitId}.viewPreferences`; + } + + private get viewPreferencesStorage(): Storage | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } } } diff --git a/src/app/units/task-viewer/task-viewer-state.component.html b/src/app/units/task-viewer/task-viewer-state.component.html index 33a824d88e..2ab837e9d2 100644 --- a/src/app/units/task-viewer/task-viewer-state.component.html +++ b/src/app/units/task-viewer/task-viewer-state.component.html @@ -1,58 +1,80 @@ -@if (unit$ | async; as unit) { -
      - -
      -
      - -
      - -
      -
      - -
      - @if (selectedTaskDefinition$ | async; as selectedTaskDef) { -
      -
      - -
      - -
      - } -
      -
      -
      - -
      -
      - -
      -
      -
      +@if (loading) { +
      + + +
      +} @else if (loadFailed || !unit) { +
      + + +
      +} @else { + +
      +
      + +
      + + +
      + @if (activeTaskDef) { +
      +

      + {{ activeTaskDef.abbreviation }} {{ activeTaskDef.name }} +

      -
      - + + + + } @else { +
      +
      - -
      -
      + } +
      } diff --git a/src/app/units/task-viewer/task-viewer-state.component.ts b/src/app/units/task-viewer/task-viewer-state.component.ts index 36c6635f83..e59f25794b 100644 --- a/src/app/units/task-viewer/task-viewer-state.component.ts +++ b/src/app/units/task-viewer/task-viewer-state.component.ts @@ -1,9 +1,23 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; -import {BehaviorSubject, Observable} from 'rxjs'; -import {of} from 'rxjs'; -import {TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; +import { + BehaviorSubject, + Observable, + Subject, + asapScheduler, + catchError, + combineLatest, + distinctUntilChanged, + filter, + first, + map, + observeOn, + of, + switchMap, + takeUntil, + tap, +} from 'rxjs'; +import {TaskDefinition, Unit, UnitService} from 'src/app/api/models/doubtfire-model'; @Component({ selector: 'f-task-viewer-state', @@ -12,22 +26,89 @@ import {TaskDefinition, Unit} from 'src/app/api/models/doubtfire-model'; changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) -export class TaskViewerStateComponent { +export class TaskViewerStateComponent implements OnInit, OnDestroy { + /** The routed unit. The unit root hands this down; the route is the fallback. */ @Input() public unit$: Observable; + /** The unit with its task definitions loaded, once they have arrived. */ + public unit: Unit | null = null; + public loading = true; + public loadFailed = false; + + /** + * The selected task, as the template sees it. The task list picks the task the url + * names while it is being drawn, which is after this screen's own bindings were + * checked, so reading the subject straight into the template changed them mid-check. + * Taking the value on the next turn lets the list settle first. + */ + public activeTaskDef: TaskDefinition | null = null; + + private readonly retry$: BehaviorSubject = new BehaviorSubject(undefined); + private readonly destroy$: Subject = new Subject(); + constructor( private route: ActivatedRoute, private router: Router, + private unitService: UnitService, ) { - this.unit$ = of(this.route.parent.snapshot.data.unit); - const taskAbbreviation = this.route.snapshot.paramMap.get('taskAbbreviation'); - if (taskAbbreviation) { - this.unit$.subscribe((unit) => { - this.selectedTaskDefinition$.next( - unit.taskDefinitions.find((taskDef) => taskDef.abbreviation === taskAbbreviation) ?? null, - ); + // The task abbreviation is deliberately not read here. This component is + // reused when only the route parameter changes, so a snapshot read runs once + // and leaves the first task selected for the life of the screen. The child + // task list follows route.paramMap instead, and it is the one component + // shared by all three routes that carry a :taskAbbreviation, so the read + // belongs there and only there. + } + + ngOnInit(): void { + this.selectedTaskDefinition$ + .pipe(observeOn(asapScheduler), takeUntil(this.destroy$)) + .subscribe((taskDef) => { + this.activeTaskDef = taskDef; }); - } + + const routeUnit$ = + this.unit$ ?? this.route.parent.data.pipe(map((data) => data.unit as Unit | undefined)); + + // Every route under /units/:id/tasks resolves the unit progressively: the + // resolver hands over the bare unit from the user's roles and leaves the page to + // load the rest. The inbox does that itself. This screen did not, so after a + // reload its list had no task definitions and said "No tasks to display". + combineLatest([ + routeUnit$.pipe( + filter((unit): unit is Unit => !!unit), + distinctUntilChanged((a, b) => a.id === b.id), + ), + this.retry$, + ]) + .pipe( + tap(() => { + this.loading = true; + this.loadFailed = false; + }), + // switchMap so a slow answer for a unit the user has already left cannot land + // on top of the one they are looking at now. + switchMap(([unit]) => + this.unitService.get(unit.id).pipe( + first(), + catchError(() => of(null)), + ), + ), + takeUntil(this.destroy$), + ) + .subscribe((unit) => { + this.unit = unit; + this.loading = false; + this.loadFailed = !unit; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + public retryLoad(): void { + this.retry$.next(); } /** diff --git a/src/app/units/unit-root-state.component.html b/src/app/units/unit-root-state.component.html index c3d939bb5b..7cc45717d3 100644 --- a/src/app/units/unit-root-state.component.html +++ b/src/app/units/unit-root-state.component.html @@ -1,10 +1,11 @@ @if (unit || (unit$ | async)) { - + } @else {
      - -

      Loading unit details...

      + +

      Loading the unit...

      } diff --git a/src/app/units/unit-root-state.component.spec.ts b/src/app/units/unit-root-state.component.spec.ts new file mode 100644 index 0000000000..55fe53f7e2 --- /dev/null +++ b/src/app/units/unit-root-state.component.spec.ts @@ -0,0 +1,105 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {Component, Input, NO_ERRORS_SCHEMA} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + Router, + RouterModule, + provideRouter, +} from '@angular/router'; +import {Observable, firstValueFrom, of} from 'rxjs'; +import {Unit} from 'src/app/api/models/unit'; +import {UnitRootStateComponent} from './unit-root-state.component'; + +// The two components below are inert test stubs, so their one-line templates belong +// here rather than in files of their own. +/* eslint-disable @angular-eslint/component-max-inline-declarations */ + +function unitStub(id: number): Unit { + return {id} as unknown as Unit; +} + +@Component({ + selector: 'f-unit-child-stub', + template: '', + standalone: false, +}) +class UnitChildStubComponent { + @Input() public unit$: Observable; +} + +@Component({ + selector: 'f-unit-root-host', + template: '', + standalone: false, +}) +class UnitRootHostComponent {} + +describe('UnitRootStateComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [UnitRootStateComponent, UnitChildStubComponent, UnitRootHostComponent], + imports: [RouterModule], + providers: [ + provideRouter([ + { + path: 'units/:unitId', + component: UnitRootStateComponent, + resolve: { + unit: (route: ActivatedRouteSnapshot) => + of(unitStub(Number(route.paramMap.get('unitId')))), + }, + children: [{path: 'students', component: UnitChildStubComponent}], + }, + ]), + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + // Guards the (activate) binding on the outlet. Without it the children fall back to + // their own snapshot read and the whole fix is inert, with no other test noticing. + it('hands its unit stream to the child the outlet activates, and keeps it on the url', async () => { + const fixture = TestBed.createComponent(UnitRootHostComponent); + const router = TestBed.inject(Router); + fixture.detectChanges(); + + await router.navigateByUrl('/units/1/students'); + fixture.detectChanges(); + + const child = fixture.debugElement.query(By.directive(UnitChildStubComponent)) + .componentInstance as UnitChildStubComponent; + expect(child.unit$).toBeDefined(); + expect(await firstValueFrom(child.unit$)).toMatchObject({id: 1}); + + await router.navigateByUrl('/units/2/students'); + fixture.detectChanges(); + + const reused = fixture.debugElement.query(By.directive(UnitChildStubComponent)) + .componentInstance as UnitChildStubComponent; + expect(reused).toBe(child); + expect(await firstValueFrom(child.unit$)).toMatchObject({id: 2}); + }); + + it('leaves a child that already built its own unit stream alone', () => { + const fixture = TestBed.createComponent(UnitRootStateComponent); + const root = fixture.componentInstance; + const own = of(unitStub(9)); + const child: {unit$?: Observable} = {unit$: own}; + + root.onActivate(child); + + expect(child.unit$).toBe(own); + }); + + it('announces that the unit is loading until it has one', () => { + TestBed.overrideProvider(ActivatedRoute, {useValue: {data: of({})}}); + const fixture = TestBed.createComponent(UnitRootStateComponent); + fixture.detectChanges(); + + const status = (fixture.nativeElement as HTMLElement).querySelector('[role="status"]'); + expect(status?.textContent).toContain('Loading the unit...'); + }); +}); diff --git a/src/app/units/unit-root-state.component.ts b/src/app/units/unit-root-state.component.ts index 55feeecb27..90f7e7086c 100644 --- a/src/app/units/unit-root-state.component.ts +++ b/src/app/units/unit-root-state.component.ts @@ -1,9 +1,12 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; -import {Observable} from 'rxjs'; +import {Observable, filter, map, shareReplay} from 'rxjs'; import {Unit} from 'src/app/api/models/doubtfire-model'; +interface UnitRouteChild { + unit$?: Observable; +} + @Component({ selector: 'f-unit-root-state', templateUrl: './unit-root-state.component.html', @@ -15,6 +18,15 @@ export class UnitRootStateComponent implements OnInit { @Input() public unit$: Observable; public unit: Unit; + // route.data emits again whenever the unit resolver re-runs, and it re-runs whenever + // :unitId changes, so this stream follows the url even when the router reuses the + // component instances underneath it. + public readonly routeUnit$ = this.route.data.pipe( + map((data) => data.unit as Unit), + filter((unit): unit is Unit => unit != null), + shareReplay({bufferSize: 1, refCount: true}), + ); + constructor(private route: ActivatedRoute) {} ngOnInit(): void { @@ -22,4 +34,18 @@ export class UnitRootStateComponent implements OnInit { this.unit = data.unit; }); } + + // The outlet emits this after creating the child but before its first change + // detection, so the stream is in place before the child runs ngOnInit. + // + // project-root-state guards the same assignment with `'project$' in component`. + // That check cannot work here: useDefineForClassFields is off, so an @Input with + // no initialiser is never written to the instance and the `in` test is always + // false. Guarding on the value instead leaves a child that has already built its + // own unit$ alone, which keeps task-viewer-state on the behaviour RTE-02 owns. + onActivate(component: UnitRouteChild): void { + if (component && !component.unit$) { + component.unit$ = this.unit$ ?? this.routeUnit$; + } + } } diff --git a/src/app/visualisations/ngx-charts-compatibility.spec.ts b/src/app/visualisations/ngx-charts-compatibility.spec.ts new file mode 100644 index 0000000000..44e29439cd --- /dev/null +++ b/src/app/visualisations/ngx-charts-compatibility.spec.ts @@ -0,0 +1,67 @@ +import {NgxChartsModule} from '@swimlane/ngx-charts'; +import {TestBed} from '@angular/core/testing'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {PeerProgressService, Project} from 'src/app/api/models/doubtfire-model'; +import {ProjectProgressGaugeComponent} from 'src/app/common/project-progress/project-progress-gauge.component'; +import {DemoModeStore} from 'src/app/demo/demo-mode.store'; +import {ProgressBurndownChartComponent} from './progress-burndown-chart/progress-burndown-chart.component'; +import {TaskStatusPieChartComponent} from './task-status-pie-chart/task-status-pie-chart.component'; + +describe('Existing charts on Angular 22', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [NgxChartsModule, MatIconModule, MatProgressSpinnerModule], + declarations: [ + TaskStatusPieChartComponent, + ProgressBurndownChartComponent, + ProjectProgressGaugeComponent, + ], + providers: [ + provideNoopAnimations(), + {provide: PeerProgressService, useValue: {}}, + {provide: DemoModeStore, useValue: {enabled: false}}, + ], + }), + ); + + it('renders the existing project task status pie', () => { + const fixture = TestBed.createComponent(TaskStatusPieChartComponent); + fixture.componentRef.setInput('project', { + activeTasks: () => [{status: 'complete'}, {status: 'not_started'}], + } as unknown as Project); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ngx-charts-pie-chart svg')).toBeTruthy(); + expect(fixture.componentInstance.data).toContainEqual({name: 'Complete', value: 1}); + }); + + it('renders the existing burndown line chart and series controls', () => { + const startDate = new Date(2026, 6, 1); + const endDate = new Date(2026, 7, 1); + const fixture = TestBed.createComponent(ProgressBurndownChartComponent); + fixture.componentRef.setInput('project', { + id: 1, + unit: {startDate, endDate}, + refreshBurndownChartData: vi.fn(), + burndownChartData: [ + { + key: 'Target', + values: [ + [startDate.getTime(), 1], + [endDate.getTime(), 0], + ], + }, + ], + } as unknown as Project); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ngx-charts-line-chart svg')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('button').textContent).toContain('Target'); + }); + + it('renders the existing progress gauge', () => { + const fixture = TestBed.createComponent(ProjectProgressGaugeComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('ngx-charts-gauge svg')).toBeTruthy(); + }); +}); diff --git a/src/app/visualisations/summary-task-status-scatter.coffee b/src/app/visualisations/summary-task-status-scatter.coffee deleted file mode 100644 index 7b499ac18b..0000000000 --- a/src/app/visualisations/summary-task-status-scatter.coffee +++ /dev/null @@ -1,29 +0,0 @@ -angular.module('doubtfire.visualisations.summary-task-status-scatter', []) -.directive 'summaryTaskStatusScatter', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - data: '=' - unit: '=' - controller: ($scope, newTaskService, Visualisation) -> - yAxisTickFormatFunction = (value) -> - if $scope.unit.taskDefinitions[value] - $scope.unit.taskDefinitions[value].abbreviation - else - '' - - xAxisTickFormatFunction = (value) -> - idx = Math.floor(value) - newTaskService.statusAcronym.get(newTaskService.statusKeys[idx]) - - [$scope.options, $scope.config] = Visualisation 'scatterChart', 'Task Status Summary Scatter Chart', { - xAxis: - axisLabel: 'Statuses' - tickFormat: xAxisTickFormatFunction - yAxis: - axisLabel: 'Task' - tickFormat: yAxisTickFormatFunction - showDistX: yes - showDistY: yes - }, {} diff --git a/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.html b/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.html new file mode 100644 index 0000000000..79ad1698a8 --- /dev/null +++ b/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.html @@ -0,0 +1,65 @@ + + Task Status Stats + + + Task + + All tasks + @for (task of unit?.taskDefinitions ?? []; track task.id) { + {{ task.abbreviation }} — {{ task.name }} + } + + + @if (rows.length) { +

      Bubble size shows the number of tasks in each status.

      + +
      + View task status values + + + + + + + + + + + @for (row of rows; track $index) { + + + + + + } + +
      + Task counts for the selected tutorial and task +
      TaskStatusCount
      {{ row.task }}{{ row.status }}{{ row.count }}
      +
      + } @else { +

      No task status data for this selection.

      + } +
      +
      diff --git a/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.spec.ts b/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.spec.ts new file mode 100644 index 0000000000..faa2d1416c --- /dev/null +++ b/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.spec.ts @@ -0,0 +1,74 @@ +import {NgxChartsModule} from '@swimlane/ngx-charts'; +import {TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatCardModule} from '@angular/material/card'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {Unit} from 'src/app/api/models/unit'; +import {SummaryTaskStatusScatterComponent} from './summary-task-status-scatter.component'; + +const unit = { + taskDefinitions: [ + {id: 1, abbreviation: '1.1P', name: 'First'}, + {id: 2, abbreviation: '2.1P', name: 'Second'}, + ], +} as unknown as Unit; +const data = { + 1: { + 10: [{status: 'complete', num: 3, tutorial_stream_id: 1}], + 20: [{status: 'complete', num: 2, tutorial_stream_id: 1}], + }, + 2: {10: [{status: 'not_started', num: 4, tutorial_stream_id: 1}]}, +}; + +describe('SummaryTaskStatusScatterComponent', () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [provideNoopAnimations()], + declarations: [SummaryTaskStatusScatterComponent], + imports: [FormsModule, MatCardModule, MatFormFieldModule, MatSelectModule, NgxChartsModule], + }), + ); + + it('renders the chart and a table with summed task counts', () => { + const fixture = TestBed.createComponent(SummaryTaskStatusScatterComponent); + fixture.componentRef.setInput('unit', unit); + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + expect(fixture.componentInstance.rows.map((row) => row.count)).toEqual([5, 4]); + expect(fixture.nativeElement.querySelector('ngx-charts-bubble-chart svg')).toBeTruthy(); + expect(fixture.nativeElement.querySelectorAll('tbody tr').length).toBe(2); + }); + + it('filters by tutorial and task without changing the source', () => { + const chart = new SummaryTaskStatusScatterComponent(); + chart.unit = unit; + chart.data = data; + chart.tutorialId = 20; + chart.taskId = 1; + chart.updateData(); + expect(chart.rows).toEqual([{task: '1.1P', status: 'Complete', count: 2}]); + expect(data[1][10][0].num).toBe(3); + expect(chart.formatTask(0)).toBe('1.1P'); + expect(chart.formatTask(2.5)).toBe(''); + }); + + it('clears unavailable data and ignores invalid or unknown counts', () => { + const chart = new SummaryTaskStatusScatterComponent(); + chart.unit = unit; + chart.data = { + 1: { + 10: [ + {status: 'unknown', num: 3, tutorial_stream_id: 1}, + {status: 'complete', num: NaN, tutorial_stream_id: 1}, + ], + }, + }; + chart.ngOnChanges(); + expect(chart.rows).toEqual([]); + chart.data = undefined; + chart.ngOnChanges(); + expect(chart.rows).toEqual([]); + }); +}); diff --git a/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.ts b/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.ts new file mode 100644 index 0000000000..14d33774a2 --- /dev/null +++ b/src/app/visualisations/summary-task-status-scatter/summary-task-status-scatter.component.ts @@ -0,0 +1,65 @@ +import {ChangeDetectionStrategy, Component, Input, OnChanges} from '@angular/core'; +import {TaskStatus, Unit} from 'src/app/api/models/doubtfire-model'; +import {TaskStatusStats} from 'src/app/api/services/unit.service'; + +@Component({ + selector: 'f-summary-task-status-scatter', + templateUrl: './summary-task-status-scatter.component.html', + styleUrls: ['../unit-analytics-chart.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class SummaryTaskStatusScatterComponent implements OnChanges { + @Input() unit: Unit; + @Input() data: TaskStatusStats = {}; + @Input() tutorialId: number | null = null; + taskId: number | null = null; + rows: {task: string; status: string; count: number}[] = []; + series: {name: string; series: {name: string; x: number; y: number; r: number}[]}[] = []; + readonly Math = Math; + readonly statuses = TaskStatus.STATUS_KEYS; + readonly xTicks = this.statuses.map((_, index) => index); + yTicks: number[] = []; + formatStatus = (value: number): string => + TaskStatus.STATUS_LABELS.get(this.statuses[value]) ?? ''; + formatTask = (value: number): string => this.series[value]?.name ?? ''; + + ngOnChanges(): void { + if (!this.unit?.taskDefinitions.some((task) => task.id === this.taskId)) { + this.taskId = null; + } + this.updateData(); + } + + updateData(): void { + this.rows = []; + this.series = (this.unit?.taskDefinitions ?? []) + .filter((task) => this.taskId === null || task.id === this.taskId) + .map((task, y) => { + const totals: Map = new Map(); + Object.entries(this.data?.[task.id] ?? {}).forEach(([tutorial, stats]) => { + if (this.tutorialId !== null && tutorial !== String(this.tutorialId)) { + return; + } + stats.forEach(({status, num}) => { + if (Number.isFinite(num) && num > 0 && this.statuses.some((key) => key === status)) { + totals.set(status, (totals.get(status) ?? 0) + num); + } + }); + }); + return { + name: task.abbreviation, + series: this.statuses.flatMap((status, x) => { + const count = totals.get(status) ?? 0; + if (!count) { + return []; + } + const label = TaskStatus.STATUS_LABELS.get(status); + this.rows.push({task: task.abbreviation, status: label, count}); + return [{name: label, x, y, r: count}]; + }), + }; + }); + this.yTicks = this.series.map((_, index) => index); + } +} diff --git a/src/app/visualisations/target-grade-pie-chart.coffee b/src/app/visualisations/target-grade-pie-chart.coffee deleted file mode 100644 index e6546b466b..0000000000 --- a/src/app/visualisations/target-grade-pie-chart.coffee +++ /dev/null @@ -1,48 +0,0 @@ -angular.module('doubtfire.visualisations.target-grade-pie-chart', []) -.directive 'targetGradePieChart', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - rawData: '=data' - showLegend: '=?' - height: '=?' - controller: ($scope, $timeout, gradeService, Visualisation) -> - colors = gradeService.gradeColors - - updateData = (rawData) -> - $scope.data = _.map rawData, (value, grade) -> - $scope.total = _.chain(rawData) - .values() - .reduce(((memo, num) -> memo + num), 0) - .value() - { key: gradeService.grades[grade], y: value, status_key: grade } - $timeout -> - if $scope.api?.refresh? - $scope.api.refresh() - - updateData($scope.rawData) - - $scope.$watch 'rawData', updateData - - zeroMargin = { top: 0, right: 0, bottom: 0, left: 0 } - - [$scope.options, $scope.config] = Visualisation 'pieChart', 'Target Grade Summary Chart', { - color: (d, i) -> - colors[d.status_key] - x: (d) -> d.key - y: (d) -> d.y - showLabels: no - margin: zeroMargin - legend: - padding: 64 - margin: zeroMargin - height: $scope.height - showLegend: $scope.showLegend - tooltip: - valueFormatter: (d) -> - pct = Math.round((d / $scope.total) * 100) - "#{pct}%" - keyFormatter: (d) -> - d - }, {} diff --git a/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.html b/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.html new file mode 100644 index 0000000000..36512ff849 --- /dev/null +++ b/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.html @@ -0,0 +1,39 @@ + + Target Grade Stats + + @if (total) { + + + + + + + + + + + + @for (row of series; track row.name) { + + + + + + } + +
      + Target grades for the selected tutorial enrolments +
      Target gradeCountPercentage
      {{ row.name }}{{ row.value }}{{ percentage(row.value) }}%
      + } @else { +

      No target grade data for this selection.

      + } +
      +
      diff --git a/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.spec.ts b/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.spec.ts new file mode 100644 index 0000000000..3e47461c77 --- /dev/null +++ b/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.spec.ts @@ -0,0 +1,88 @@ +import {NgxChartsModule} from '@swimlane/ngx-charts'; +import {signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {MatCardModule} from '@angular/material/card'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {Unit} from 'src/app/api/models/unit'; +import {ThemeColorService} from 'src/app/common/theme/theme-color.service'; +import {ResolvedTheme} from 'src/app/common/theme/theme.service'; +import {TargetGradePieChartComponent} from './target-grade-pie-chart.component'; + +const data = [ + {tutorial_id: 10, tutorial_stream_id: 1, grade: 0, num: 2}, + {tutorial_id: 20, tutorial_stream_id: 1, grade: 0, num: 1}, + {tutorial_id: 20, tutorial_stream_id: 1, grade: 3, num: 1}, +]; + +describe('TargetGradePieChartComponent', () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [provideNoopAnimations()], + declarations: [TargetGradePieChartComponent], + imports: [MatCardModule, NgxChartsModule], + }), + ); + + it('renders grade counts, colours and rounded percentages', () => { + const fixture = TestBed.createComponent(TargetGradePieChartComponent); + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + const chart = fixture.componentInstance; + expect(chart.series).toEqual([ + {name: 'Pass', value: 3}, + {name: 'High Distinction', value: 1}, + ]); + expect(chart.percentage(3)).toBe(75); + expect(chart.colors.map((colour) => colour.name)).toEqual(['Pass', 'High Distinction']); + expect(chart.colors.every((colour) => colour.value.length > 0)).toBe(true); + expect(fixture.nativeElement.querySelector('ngx-charts-pie-chart svg')).toBeTruthy(); + expect(fixture.nativeElement.textContent).toContain('75%'); + }); + + it('takes slice colours from the theme tokens and follows a theme change', () => { + const theme = signal('light'); + TestBed.overrideProvider(ThemeColorService, { + useValue: { + resolved: theme.asReadonly(), + token: (name: string) => `${theme()}${name}`, + }, + }); + const fixture = TestBed.createComponent(TargetGradePieChartComponent); + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + expect(fixture.componentInstance.colors).toEqual([ + {name: 'Pass', value: 'light--ot-chart-4'}, + {name: 'High Distinction', value: 'light--ot-chart-3'}, + ]); + + theme.set('dark'); + fixture.detectChanges(); + expect(fixture.componentInstance.colors.map((colour) => colour.value)).toEqual([ + 'dark--ot-chart-4', + 'dark--ot-chart-3', + ]); + }); + + it('respects tutorial filters and configured grade labels', () => { + const chart = TestBed.createComponent(TargetGradePieChartComponent).componentInstance; + chart.unit = { + gradeDefinitions: [{id: 'pass', value: 0, label: 'Satisfactory', abbreviation: 'S'}], + } as Unit; + chart.data = data; + chart.tutorialId = 10; + chart.ngOnChanges(); + expect(chart.series).toEqual([{name: 'Satisfactory', value: 2}]); + expect(chart.colors.map((colour) => colour.name)).toEqual(['Satisfactory']); + }); + + it('clears stale values and never divides by zero', () => { + const chart = TestBed.createComponent(TargetGradePieChartComponent).componentInstance; + chart.data = data; + chart.ngOnChanges(); + chart.data = []; + chart.ngOnChanges(); + expect(chart.series).toEqual([]); + expect(chart.colors).toEqual([]); + expect(chart.percentage(0)).toBe(0); + }); +}); diff --git a/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.ts b/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.ts new file mode 100644 index 0000000000..105cd6d679 --- /dev/null +++ b/src/app/visualisations/target-grade-pie-chart/target-grade-pie-chart.component.ts @@ -0,0 +1,86 @@ +import {ChangeDetectionStrategy, Component, Input, OnChanges, effect} from '@angular/core'; +import {Unit} from 'src/app/api/models/unit'; +import {TargetGradeStat} from 'src/app/api/services/unit.service'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {ThemeColorService} from 'src/app/common/theme/theme-color.service'; + +// Slices are marks on the card, so each grade takes a chart token that clears 3:1 on +// the surface in both themes. The hues follow the legacy grade colours: grey, red, +// orange, blue and green. ngx-charts needs a real colour, so these are resolved, and +// the legacy colour is only the fallback when no stylesheet is loaded. +const GRADE_COLOUR_TOKENS: Record = { + [-1]: '--ot-color-text-muted', + 0: '--ot-chart-4', + 1: '--ot-chart-6', + 2: '--ot-chart-2', + 3: '--ot-chart-3', +}; +const UNKNOWN_GRADE_TOKEN = '--ot-color-text-muted'; + +@Component({ + selector: 'f-target-grade-pie-chart', + templateUrl: './target-grade-pie-chart.component.html', + styleUrls: ['../unit-analytics-chart.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TargetGradePieChartComponent implements OnChanges { + @Input() unit: Unit; + @Input() data: TargetGradeStat[] = []; + @Input() tutorialId: number | null = null; + series: {name: string; value: number}[] = []; + colors: {name: string; value: string}[] = []; + total = 0; + private shownGrades: number[] = []; + + constructor( + private grades: GradeService, + private themeColor: ThemeColorService, + ) { + // Re-resolve the slice colours when the theme flips while the chart is on screen. + effect(() => { + this.themeColor.resolved(); + this.colors = this.sliceColors(); + }); + } + + ngOnChanges(): void { + const counts: Map = new Map(); + (this.data ?? []).forEach(({tutorial_id, grade, num}) => { + if ( + (this.tutorialId === null || tutorial_id === this.tutorialId) && + Number.isFinite(num) && + num > 0 + ) { + counts.set(grade, (counts.get(grade) ?? 0) + num); + } + }); + this.series = Array.from(counts) + .sort(([a], [b]) => a - b) + .map(([grade, value]) => ({ + name: this.gradeName(grade), + value, + })); + this.shownGrades = Array.from(counts.keys()); + this.colors = this.sliceColors(); + this.total = this.series.reduce((sum, row) => sum + row.value, 0); + } + + percentage(value: number): number { + return this.total ? Math.round((value / this.total) * 100) : 0; + } + + private gradeName(grade: number): string { + return this.grades.gradeLabel(grade, this.unit) ?? `Grade ${grade}`; + } + + private sliceColors(): {name: string; value: string}[] { + return this.shownGrades.map((grade) => ({ + name: this.gradeName(grade), + value: this.themeColor.token( + GRADE_COLOUR_TOKENS[grade] ?? UNKNOWN_GRADE_TOKEN, + this.grades.gradeColors[grade] ?? this.grades.gradeColors[-1], + ), + })); + } +} diff --git a/src/app/visualisations/task-completion-box-plot.coffee b/src/app/visualisations/task-completion-box-plot.coffee deleted file mode 100644 index 26a43bbbe8..0000000000 --- a/src/app/visualisations/task-completion-box-plot.coffee +++ /dev/null @@ -1,66 +0,0 @@ -angular.module('doubtfire.visualisations.task-completion-box-plot', []) -.directive 'taskCompletionBoxPlot', -> - replace: true - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - rawData: '=data' - unit: '=' - type: '=' - height: '=?' - showLegend: '=?' - controller: ($scope, $filter, $timeout, gradeService, Visualisation) -> - $scope.showLegend = unless $scope.showLegend? then true else $scope.showLegend - $scope.height = unless $scope.height? then 600 else $scope.height - - refreshData = (newData) -> - if $scope.type isnt 'grade' - $scope.data = [ - { - label: $filter('ucfirst')($scope.type) - values: { - Q1: newData.lower - Q2: newData.median - Q3: newData.upper - whisker_low: newData.min - whisker_high: newData.max - } - } - ] - else - $scope.data = _.map newData, (d, id) -> - label = gradeService.grades[id] - { - label: label, - values: { - Q1: d.lower - Q2: d.median - Q3: d.upper - whisker_low: d.min - whisker_high: d.max - } - } - - $timeout -> - if $scope.api?.refresh? - $scope.api.refresh() - - $scope.$watch 'rawData', refreshData - refreshData($scope.rawData) - - [$scope.options, $scope.config] = Visualisation 'boxPlotChart', 'Task Completion Summary Box Plot', { - x: (d) -> d.label - height: $scope.height - showXAxis: $scope.showLegend - margin: - top: if $scope.showLegend then 20 else 20 - right: if $scope.showLegend then 10 else 10 - bottom: if $scope.showLegend then 60 else 20 - left: if $scope.showLegend then 80 else 40 - yAxis: - axisLabel: if $scope.showLegend then "Number of tasks completed" - tooltip: - enabled: $scope.showLegend - maxBoxWidth: 75 - yDomain: [0, Math.ceil($scope.unit.taskDefinitions.length/2) * 2] #round to nearest 2 - }, {} diff --git a/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.html b/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.html new file mode 100644 index 0000000000..c3553618df --- /dev/null +++ b/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.html @@ -0,0 +1,61 @@ + + Task Completion Stats + + + Group completion statistics + + Selected unit or tutorial + By target grade (whole unit) + + + @if (rows.length) { + @if (hasCompletions) { + + } @else { +

      No completed tasks in this selection.

      + } +

      + The box shows the reported lower, median and upper values; whiskers show the minimum and + maximum. +

      + + + + + + + + + + + + + + @for (row of rows; track row.name) { + + + + + + + + + } + +
      + Task completion summary values +
      GroupMinimumLowerMedianUpperMaximum
      {{ row.name }}{{ row.summary.min }}{{ row.summary.lower }}{{ row.summary.median }}{{ row.summary.upper }}{{ row.summary.max }}
      + } @else { +

      No task completion data for this selection.

      + } +
      +
      diff --git a/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.spec.ts b/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.spec.ts new file mode 100644 index 0000000000..74b82b0396 --- /dev/null +++ b/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.spec.ts @@ -0,0 +1,57 @@ +import {BoxSeriesComponent, NgxChartsModule} from '@swimlane/ngx-charts'; +import {TestBed} from '@angular/core/testing'; +import {FormsModule} from '@angular/forms'; +import {MatCardModule} from '@angular/material/card'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {By} from '@angular/platform-browser'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {GradeService} from 'src/app/common/services/grade.service'; +import {TaskCompletionBoxPlotComponent} from './task-completion-box-plot.component'; + +const summary = {min: 1, lower: 3, median: 4.5, upper: 8, max: 10}; +const data = {unit: summary, tutorial: {10: {...summary, max: 9}}, grade: {0: summary}}; + +describe('TaskCompletionBoxPlotComponent', () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [provideNoopAnimations()], + declarations: [TaskCompletionBoxPlotComponent], + imports: [FormsModule, MatCardModule, MatFormFieldModule, MatSelectModule, NgxChartsModule], + }), + ); + + it('renders a box with exactly the API bounds and median', () => { + const fixture = TestBed.createComponent(TaskCompletionBoxPlotComponent); + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + const box = fixture.debugElement.query(By.directive(BoxSeriesComponent)).componentInstance; + expect(box.quartiles).toEqual([3, 4.5, 8]); + expect(box.whiskers).toEqual([1, 10]); + expect(fixture.nativeElement.querySelector('ngx-charts-box-chart svg')).toBeTruthy(); + expect(fixture.nativeElement.textContent).toContain('4.5'); + }); + + it('selects tutorial summaries and unit-wide grade summaries', () => { + const chart = new TaskCompletionBoxPlotComponent(new GradeService()); + chart.data = data; + chart.tutorialId = 10; + chart.ngOnChanges(); + expect(chart.rows[0].summary.max).toBe(9); + chart.group = 'grade'; + chart.updateData(); + expect(chart.rows).toEqual([{name: 'Pass', summary}]); + }); + + it('handles empty, zero and invalid summaries', () => { + const chart = new TaskCompletionBoxPlotComponent(new GradeService()); + chart.ngOnChanges(); + expect(chart.rows).toEqual([]); + chart.data = {unit: {min: 0, lower: 0, median: 0, upper: 0, max: 0}, tutorial: {}, grade: {}}; + chart.ngOnChanges(); + expect(chart.hasCompletions).toBe(false); + chart.data.unit.median = NaN; + chart.ngOnChanges(); + expect(chart.rows).toEqual([]); + }); +}); diff --git a/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.ts b/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.ts new file mode 100644 index 0000000000..8a44d5d0f8 --- /dev/null +++ b/src/app/visualisations/task-completion-box-plot/task-completion-box-plot.component.ts @@ -0,0 +1,62 @@ +import {ChangeDetectionStrategy, Component, Input, OnChanges} from '@angular/core'; +import {Unit} from 'src/app/api/models/unit'; +import {TaskCompletionStats, TaskCompletionSummary} from 'src/app/api/services/unit.service'; +import {GradeService} from 'src/app/common/services/grade.service'; + +@Component({ + selector: 'f-task-completion-box-plot', + templateUrl: './task-completion-box-plot.component.html', + styleUrls: ['../unit-analytics-chart.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class TaskCompletionBoxPlotComponent implements OnChanges { + @Input() unit: Unit; + @Input() data: TaskCompletionStats; + @Input() tutorialId: number | null = null; + group: 'selection' | 'grade' = 'selection'; + rows: {name: string; summary: TaskCompletionSummary}[] = []; + series: {name: string; series: {name: string; value: number}[]}[] = []; + hasCompletions = false; + + constructor(private grades: GradeService) {} + + ngOnChanges(): void { + this.updateData(); + } + + updateData(): void { + const selection = + this.tutorialId === null ? this.data?.unit : this.data?.tutorial?.[this.tutorialId]; + const name = + this.tutorialId === null + ? 'Unit' + : (this.unit?.tutorials.find((tutorial) => tutorial.id === this.tutorialId)?.abbreviation ?? + 'Tutorial'); + this.rows = + this.group === 'grade' + ? Object.entries(this.data?.grade ?? {}).map(([grade, summary]) => ({ + name: this.grades.gradeLabel(Number(grade), this.unit) ?? `Grade ${grade}`, + summary, + })) + : selection + ? [{name, summary: selection}] + : []; + this.rows = this.rows.filter(({summary}) => + ['min', 'lower', 'median', 'upper', 'max'].every( + (key) => Number.isFinite(summary?.[key]) && summary[key] >= 0, + ), + ); + // ngx-charts computes quartiles from its input. Five ordered summary values place + // Q1, median and Q3 exactly at the supplied lower, median and upper values. + // Do not treat these as student samples or recompute the API's summary. + this.series = this.rows.map(({name: label, summary}) => ({ + name: label, + series: ['min', 'lower', 'median', 'upper', 'max'].map((key) => ({ + name: key, + value: summary[key], + })), + })); + this.hasCompletions = this.rows.some(({summary}) => summary.max > 0); + } +} diff --git a/src/app/visualisations/task-status-pie-chart.coffee b/src/app/visualisations/task-status-pie-chart.coffee deleted file mode 100644 index 4d31e440ad..0000000000 --- a/src/app/visualisations/task-status-pie-chart.coffee +++ /dev/null @@ -1,46 +0,0 @@ -angular.module('doubtfire.visualisations.task-status-pie-chart', []) -.directive 'taskStatusPieChart', -> - restrict: 'E' - templateUrl: 'visualisations/visualisation.tpl.html' - scope: - rawData: '=data' - showLegend: '=?' - height: '=?' - controller: ($scope, $timeout, newTaskService, Visualisation) -> - colors = newTaskService.statusColors - - updateData = (rawData) -> - $scope.total = _.chain(rawData) - .values() - .reduce(((memo, num) -> memo + num), 0) - .value() - $scope.data = _.map rawData, (value, status) -> - { key: newTaskService.statusLabels.get(status), y: value, statusKey: status } - $timeout -> - $scope.api.refresh() if $scope.api?.refresh? - - updateData($scope.rawData) - - $scope.$watch 'rawData', updateData - - zeroMargin = { top: 0, right: 0, bottom: 0, left: 0 } - - [$scope.options, $scope.config] = Visualisation 'pieChart', 'Task Status Summary Pie Chart', { - color: (d, i) -> - colors.get(d.statusKey) - x: (d) -> d.key - y: (d) -> d.y - showLabels: no - margin: zeroMargin - height: $scope.height - legend: - padding: 64 - margin: zeroMargin - showLegend: $scope.showLegend - tooltip: - valueFormatter: (d) -> - pct = Math.round((d / $scope.total) * 100) - "#{pct}%" - keyFormatter: (d) -> - d - }, {} diff --git a/src/app/visualisations/visualisation.tpl.html b/src/app/visualisations/visualisation.tpl.html deleted file mode 100644 index fad7a46241..0000000000 --- a/src/app/visualisations/visualisation.tpl.html +++ /dev/null @@ -1,3 +0,0 @@ -
      - -
      diff --git a/src/app/visualisations/visualisations.coffee b/src/app/visualisations/visualisations.coffee deleted file mode 100644 index 16808e4129..0000000000 --- a/src/app/visualisations/visualisations.coffee +++ /dev/null @@ -1,61 +0,0 @@ -# Component is no longer used and is unlinked from the app -# This file is left for reference, as these need to be migrated to be used in the Unit Analytics section - -angular.module('doubtfire.visualisations', [ - 'doubtfire.visualisations.summary-task-status-scatter' - 'doubtfire.visualisations.task-status-pie-chart' - 'doubtfire.visualisations.target-grade-pie-chart' - 'doubtfire.visualisations.task-completion-box-plot' -]) - -.factory('Visualisation', ($interval) -> - Visualisation = (type, visualisationName, opts, conf, titleOpts, subtitleOpts) -> - DEFAULT_OPTS = - objectequality: yes - interactive: yes - # tooltips: yes - showValues: yes - showXAxis: yes - showYAxis: yes - showLegend: yes - transitionDuration: 500 - duration: 500 - height: 600 - color: [ - "#1f77b4" - "#ff7f0e" - "#2ca02c" - "#d62728" - "#9467bd" - "#8c564b" - "#e377c2" - "#7f7f7f" - "#bcbd22" - "#17becf" - ] - - DEFAULT_CONF = { - visible: true, # default: true - extended: false, # default: false - disabled: false, # default: false - autorefresh: true, # default: true - refreshDataOnly: true, # default: true - deepWatchOptions: true, # default: true - deepWatchData: false, # default: false - deepWatchConfig: true, # default: true - debounce: 10 # default: 10 - } - - dirtyOpts = angular.extend {}, DEFAULT_OPTS, opts - dirtyOpts.type = type - - dirtyConf = angular.extend {}, DEFAULT_CONF, conf - - [ { chart: dirtyOpts, title: titleOpts, subtitle: subtitleOpts }, dirtyConf ] - - - Visualisation.refreshAll = -> - $interval (-> window.dispatchEvent(new Event('resize'))), 50, 1 - - Visualisation -) diff --git a/src/app/welcome/welcome.component.html b/src/app/welcome/welcome.component.html index 49f3552f95..24fb52c521 100644 --- a/src/app/welcome/welcome.component.html +++ b/src/app/welcome/welcome.component.html @@ -1,25 +1,19 @@ -
      - - -
      -
      +
      + +
      +
      -
      - Homepage Logo +
      + OnTrack logo

      {{ externalName.value }}

      -

      Welcome to {{ externalName.value }}

      +

      Welcome to {{ externalName.value }}

      @if (!loading) { }
      -
      diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 84d685e8a6..fec5e071b0 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -4,6 +4,20 @@ export const environment = { production: false, + + // Runs the service worker outside a production build. Push notifications + // cannot arrive without it, because the service worker is the thing that + // receives them. + // + // Turn this off if the worker gets in your way. It caches, so a stale bundle + // after a rebuild is the usual symptom, and clearing it is a manual step. + // See docs/service-worker.md. + enableServiceWorker: true, + + // Makes the local-only demo controls available. The mode itself still starts + // off and is stored only for the current browser tab. + enableDemoTools: true, + sentryDsn: '', sentryRelease: '', sentryDist: '',