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 (loadingActivities) {
-
- @for (width of ['30%', '22%', '4%']; track $index) {
-
- }
-
-
- @for (row of skeletonRows; track row) {
-
-
-
-
-
- }
+
+
}
@@ -125,7 +113,7 @@
Activities
-
+
add
@@ -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') {
-
-
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') {
-
+
check_circle_outline
}
@if (overseerImage.pulledImageStatus === 'loading') {
-
+
access_time_icon
}
@if (overseerImage.pulledImageStatus === 'failed') {
} @else {
-
+
cancel
-
+
save
-
+
delete
@@ -214,7 +204,7 @@
-
+
add
@@ -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 @@
-