-
Play it back
-
+
Play it back
+
Stop the recording, then listen to make sure it is clear.
diff --git a/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester-component.scss b/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester-component.scss
index d201a8dadd..e54d06a7a8 100644
--- a/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester-component.scss
+++ b/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester-component.scss
@@ -54,7 +54,7 @@ microphone-tester {
text-align: center;
font-size: 12px;
line-height: 1.42857;
- border: 1px solid #bdbdbd;
+ border: 1px solid var(--ot-color-border);
box-shadow: 0.5px 0.5px 1px #bdbdbd;
}
diff --git a/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester.component.ts b/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester.component.ts
index fb8586156b..94760bdcec 100644
--- a/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester.component.ts
+++ b/src/app/common/audio-recorder/audio/microphone-tester/microphone-tester.component.ts
@@ -7,6 +7,8 @@ import {
ViewChild,
} from '@angular/core';
import {Task} from 'src/app/api/models/doubtfire-model';
+import {AppLifecycleService} from 'src/app/common/services/app-lifecycle.service';
+import {AudioPlaybackCoordinatorService} from 'src/app/common/services/audio-playback-coordinator.service';
import {MediaRecorderService} from 'src/app/common/services/recorder-service';
import {BaseAudioRecorderComponent} from '../base-audio-recorder';
@@ -25,8 +27,12 @@ export class MicrophoneTesterComponent extends BaseAudioRecorderComponent implem
canvasCtx: CanvasRenderingContext2D;
isSending: boolean = false;
- constructor(private mediaRecorderService: MediaRecorderService) {
- super(mediaRecorderService);
+ constructor(
+ private mediaRecorderService: MediaRecorderService,
+ playbackCoordinator: AudioPlaybackCoordinatorService,
+ appLifecycle: AppLifecycleService,
+ ) {
+ super(mediaRecorderService, playbackCoordinator, appLifecycle);
}
ngAfterViewInit() {
@@ -38,7 +44,7 @@ export class MicrophoneTesterComponent extends BaseAudioRecorderComponent implem
init(): void {
super.init();
this.canvas = this.canvasRef.nativeElement;
- this.audio = this.audioRef.nativeElement;
+ this.attachAudio(this.audioRef.nativeElement);
this.canvasCtx = this.canvas.getContext('2d');
}
@@ -56,7 +62,7 @@ export class MicrophoneTesterComponent extends BaseAudioRecorderComponent implem
this.canvas.width = WIDTH = this.canvas.clientWidth;
this.canvas.height = HEIGHT = this.canvas.clientHeight;
- requestAnimationFrame(draw);
+ this.scheduleVisualisationFrame(draw);
analyser.getByteTimeDomainData(dataArray);
analyser.getByteFrequencyData(dataArray);
diff --git a/src/app/common/file-uploader/file-uploader.component.reviewed.spec.ts b/src/app/common/file-uploader/file-uploader.component.reviewed.spec.ts
new file mode 100644
index 0000000000..aa3a1bb504
--- /dev/null
+++ b/src/app/common/file-uploader/file-uploader.component.reviewed.spec.ts
@@ -0,0 +1,41 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {FileUploaderComponent} from './file-uploader.component';
+
+// SJ-11: the upload type check must match a real file extension, not any
+// filename that merely ends with the accepted letters (e.g. figure.eps
+// must not pass a `ps` zone).
+describe('FileUploaderComponent validateFiles', () => {
+ let component: FileUploaderComponent;
+
+ beforeEach(() => {
+ component = new FileUploaderComponent({} as never, {} as never);
+ (component as never as {refreshShownUploadZones: unknown}).refreshShownUploadZones = vi.fn();
+ (component as never as {updateReadyState: unknown}).updateReadyState = vi.fn();
+ (component as never as {readyToUpload: unknown}).readyToUpload = vi.fn(() => true);
+ });
+
+ const zoneFor = (filename: string) => ({
+ model: [{name: filename}] as unknown,
+ accepts: ['pdf', 'ps'],
+ display: {error: null as unknown},
+ });
+
+ it('rejects a file whose name only ends with the accepted letters', () => {
+ const zone = zoneFor('figure.eps');
+ (component as never as {shownUploadZones: unknown}).shownUploadZones = [zone];
+
+ component.validateFiles();
+
+ expect(zone.model).toBeNull();
+ expect(zone.display.error).toBe(true);
+ });
+
+ it('accepts a file with a real accepted extension', () => {
+ const zone = zoneFor('report.pdf');
+ (component as never as {shownUploadZones: unknown}).shownUploadZones = [zone];
+
+ component.validateFiles();
+
+ expect(zone.model).not.toBeNull();
+ });
+});
diff --git a/src/app/common/file-uploader/file-uploader.component.scss b/src/app/common/file-uploader/file-uploader.component.scss
index db00b824aa..55e069e72e 100644
--- a/src/app/common/file-uploader/file-uploader.component.scss
+++ b/src/app/common/file-uploader/file-uploader.component.scss
@@ -1,4 +1,3 @@
-.file-drop-zone .mat-icon,
.complete .mat-icon {
font-size: 50px;
height: 50px;
@@ -11,27 +10,532 @@
width: 75px;
}
+.file-input {
+ display: none;
+}
+
+.file-uploader-card,
+.file-uploader-content {
+ box-sizing: border-box;
+ min-width: 0;
+ width: 100%;
+}
+
+// Sized by the space the uploader gets, not the viewport, so a wide dialog puts the
+// drop zone and the file list side by side and a narrow one stacks them.
+.file-uploader-content {
+ container-type: inline-size;
+}
+
+.file-uploader-layout {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: minmax(0, 1fr);
+ min-width: 0;
+}
+
+@container (min-width: 600px) {
+ .file-uploader-layout--split {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+
+ > :only-child {
+ grid-column: 1 / -1;
+ }
+ }
+}
+
+.file-uploader-zones,
+.file-uploader-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+}
+
+// A normal section label above each zone and above the file list.
+.file-uploader-label {
+ margin: 0;
+ color: var(--ot-color-text);
+ font-size: 0.9rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+// --- Drop zone ---
+
.file-drop-zone {
align-items: center;
- background: transparent;
- border: 2px dashed rgba(0, 0, 0, 0.24);
- border-radius: 8px;
- color: inherit;
+ background: var(--ot-color-surface);
+ border: 1.5px dashed var(--ot-color-border);
+ border-radius: var(--ot-radius-lg);
+ box-sizing: border-box;
+ color: var(--ot-color-text);
cursor: pointer;
display: flex;
flex-direction: column;
font: inherit;
+ gap: 4px;
justify-content: center;
- padding: 2.5rem;
+ min-height: 180px;
+ padding: 20px 16px;
text-align: center;
+ width: 100%;
+
+ // Children never take the drag events, so dragleave only fires on leaving the zone.
+ > * {
+ pointer-events: none;
+ }
+}
+
+.file-drop-zone:hover {
+ border-color: var(--ot-color-link);
}
-.file-drop-zone:hover,
.file-drop-zone:focus-visible {
- border-color: currentColor;
- outline: none;
+ border-color: var(--ot-color-link);
+ outline: 2px solid var(--ot-color-focus);
+ outline-offset: 2px;
}
-.file-input {
- display: none;
+.file-drop-zone--over {
+ background: color-mix(in srgb, var(--ot-color-link) 7%, var(--ot-color-surface));
+ border-color: var(--ot-color-link);
+ border-style: solid;
+}
+
+.file-drop-zone--error,
+.file-drop-zone--error:hover {
+ border-color: var(--ot-color-error);
+ border-style: solid;
+}
+
+.file-drop-zone__icon {
+ align-items: center;
+ background: color-mix(in srgb, var(--ot-color-link) 12%, transparent);
+ border-radius: var(--ot-radius-circle);
+ color: var(--ot-color-link);
+ display: flex;
+ height: 48px;
+ justify-content: center;
+ margin-bottom: 6px;
+ transition: transform 150ms cubic-bezier(0.23, 1, 0.32, 1);
+ width: 48px;
+
+ .mat-icon {
+ font-size: 26px;
+ height: 26px;
+ width: 26px;
+ }
+}
+
+.file-drop-zone:hover .file-drop-zone__icon,
+.file-drop-zone--over .file-drop-zone__icon {
+ transform: translateY(-2px);
+}
+
+.file-drop-zone__icon--error {
+ background: color-mix(in srgb, var(--ot-color-error) 12%, transparent);
+ color: var(--ot-color-error);
+}
+
+.file-drop-zone--error:hover .file-drop-zone__icon {
+ transform: none;
+}
+
+.file-drop-zone__title {
+ font-size: 0.95rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.file-drop-zone__error {
+ color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text));
+}
+
+// Reads as a link: the whole zone is the button, so this is its visible call to action.
+.file-drop-zone__browse {
+ color: var(--ot-color-link);
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+.file-drop-zone__formats {
+ color: var(--ot-color-text-muted);
+ font-size: 0.78rem;
+ line-height: 1.4;
+ margin-top: 4px;
+ overflow-wrap: anywhere;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .file-drop-zone__icon {
+ transition: none;
+ }
+
+ .file-drop-zone:hover .file-drop-zone__icon,
+ .file-drop-zone--over .file-drop-zone__icon {
+ transform: none;
+ }
+}
+
+// --- Selected files ---
+
+.upload-file-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.upload-file-row {
+ align-items: center;
+ background: var(--ot-color-surface);
+ border: 1px solid color-mix(in srgb, var(--ot-color-border) 40%, transparent);
+ border-radius: var(--ot-radius-md);
+ box-sizing: border-box;
+ display: flex;
+ gap: 10px;
+ min-height: 56px;
+ min-width: 0;
+ padding: 6px 6px 6px 12px;
+}
+
+.upload-file-row--placeholder {
+ background: transparent;
+ border-style: dashed;
+ padding-right: 12px;
+}
+
+.upload-file-row__icon {
+ color: var(--ot-color-link);
+ flex-shrink: 0;
+}
+
+.upload-file-row--placeholder .upload-file-row__icon {
+ color: var(--ot-color-text-muted);
+}
+
+.upload-file-row__text {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.upload-file-row__name {
+ font-weight: 600;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.upload-file-row__meta {
+ color: var(--ot-color-text-muted);
+ font-size: 0.78rem;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.upload-chip {
+ background: color-mix(in srgb, var(--ot-color-text) 6%, transparent);
+ border: 1px solid color-mix(in srgb, var(--ot-color-border) 45%, transparent);
+ border-radius: var(--ot-radius-pill);
+ color: var(--ot-color-text-muted);
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.5;
+ padding: 1px 10px;
+ white-space: nowrap;
+}
+
+.upload-chip--success {
+ background: color-mix(in srgb, var(--ot-color-success) 12%, transparent);
+ border-color: color-mix(in srgb, var(--ot-color-success) 40%, transparent);
+ color: color-mix(in srgb, var(--ot-color-success) 80%, var(--ot-color-text));
+}
+
+.upload-chip--error {
+ background: color-mix(in srgb, var(--ot-color-error) 12%, transparent);
+ border-color: color-mix(in srgb, var(--ot-color-error) 40%, transparent);
+ color: color-mix(in srgb, var(--ot-color-error) 80%, var(--ot-color-text));
+}
+
+@media (max-width: 639.98px) {
+ .file-drop-zone {
+ min-height: 160px;
+ padding: 16px 12px;
+ }
+
+ .uploading,
+ .complete {
+ min-height: 14rem;
+ padding: 1rem;
+ text-align: center;
+ }
+
+ .uploading .mat-icon {
+ font-size: 3.5rem;
+ height: 3.5rem;
+ width: 3.5rem;
+ }
+
+ .complete strong {
+ font-size: 1.25rem;
+ line-height: 1.25;
+ overflow-wrap: anywhere;
+ }
+}
+
+// --- Uploading ---
+// One panel, the same elements from sending to sent: the circle greens when the
+// bytes land, the bar draws into the middle, the circle absorbs it, and the mark
+// draws out of it. Smaller than the submit dialog's, because this one sits
+// inside a card rather than taking over a surface.
+
+$upload-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
+$upload-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
+
+.upload-flow,
+.upload-outcome {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ min-height: 220px;
+ padding: 28px 16px;
+ text-align: center;
+ animation: upload-panel-in 220ms $upload-ease-out both;
+}
+
+// Holds the finished mark's height from the first frame, so the circle growing
+// inside it never pushes the words below it down.
+.upload-flow__mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 72px;
+}
+
+.upload-flow__ring,
+.upload-outcome__well {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ border-radius: var(--ot-radius-circle, 999px);
+ background: color-mix(in srgb, var(--ot-color-primary) 14%, transparent);
+ color: var(--ot-color-primary);
+
+ mat-icon {
+ width: 24px;
+ height: 24px;
+ font-size: 24px;
+ }
+}
+
+.upload-flow__ring {
+ transition:
+ width 380ms $upload-ease-in-out,
+ height 380ms $upload-ease-in-out,
+ background-color 300ms ease,
+ color 300ms ease;
+}
+
+.upload-flow__glyph {
+ transition: opacity 180ms ease;
+}
+
+// Breathing while there is still something to wait for. Opacity only, so nothing
+// is laid out again.
+.upload-flow:not(.upload-flow--done) .upload-flow__ring {
+ animation: upload-breathe 1600ms ease-in-out infinite;
+}
+
+.upload-flow__title,
+.upload-outcome__title {
+ margin: 10px 0 0;
+ color: var(--ot-color-text);
+ font-size: 1rem;
+ font-weight: 620;
+ line-height: 1.3;
+ transition: opacity 180ms ease;
+}
+
+.upload-flow__detail,
+.upload-outcome__detail {
+ max-width: 38ch;
+ margin: 0;
+ color: var(--ot-color-text-muted);
+ font-size: 0.85rem;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+ transition: opacity 180ms ease;
+}
+
+.upload-flow__track {
+ position: relative;
+ overflow: hidden;
+ width: min(320px, 100%);
+ height: 8px;
+ margin-block-start: 12px;
+ border-radius: 999px;
+ background: var(--ot-color-surface-raised);
+ // Narrows into the middle, then folds its own height away. It never simply
+ // disappears: something vanishing is what reads as a new screen.
+ transition:
+ width 300ms $upload-ease-in-out,
+ height 200ms $upload-ease-out 260ms,
+ margin 200ms $upload-ease-out 260ms,
+ opacity 160ms ease 280ms;
+}
+
+.upload-flow__fill {
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--ot-color-primary);
+ // Clipped rather than sized, so each progress event stays on the compositor.
+ clip-path: inset(0 calc(100% - var(--upload-progress, 0%)) 0 0);
+ transition:
+ clip-path 260ms $upload-ease-out,
+ background-color 300ms ease;
+}
+
+.upload-flow__value {
+ margin: 0;
+ color: var(--ot-color-text-muted);
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.upload-flow__action,
+.upload-outcome button {
+ margin-block-start: 12px;
+ transition: transform 140ms $upload-ease-out;
+
+ &:active {
+ transform: scale(0.97);
+ }
+}
+
+// The bytes are away.
+.upload-flow--done {
+ .upload-flow__ring {
+ background: color-mix(in srgb, var(--ot-color-success) 16%, transparent);
+ color: var(--ot-color-success);
+ }
+
+ .upload-flow__fill {
+ background: var(--ot-color-success);
+ }
+
+ .upload-flow__track {
+ width: 0;
+ }
+}
+
+// Sent, and this component is the one saying so.
+.upload-flow--complete {
+ .upload-flow__ring {
+ width: 68px;
+ height: 68px;
+ background: transparent;
+ animation: upload-absorb 320ms $upload-ease-out both;
+ }
+
+ .upload-flow__track {
+ height: 0;
+ margin-block-start: 0;
+ opacity: 0;
+ }
+
+ .upload-flow__title {
+ animation: upload-panel-in 280ms $upload-ease-out 200ms both;
+ }
+
+ .upload-flow__detail {
+ animation: upload-panel-in 280ms $upload-ease-out 280ms both;
+ }
+}
+
+.upload-outcome__well--error {
+ background: color-mix(in srgb, var(--ot-color-error) 16%, transparent);
+ color: var(--ot-color-error);
+}
+
+.upload-outcome__actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 10px;
+}
+
+@keyframes upload-panel-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes upload-absorb {
+ 0% {
+ transform: scale(1);
+ }
+
+ 45% {
+ transform: scale(1.07);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes upload-breathe {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.55;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .upload-flow,
+ .upload-outcome,
+ .upload-flow__ring,
+ .upload-flow__title,
+ .upload-flow__detail {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ }
+
+ .upload-flow__ring,
+ .upload-flow__glyph,
+ .upload-flow__title,
+ .upload-flow__detail,
+ .upload-flow__track,
+ .upload-flow__fill,
+ .upload-flow__action,
+ .upload-outcome button {
+ transition: none;
+ }
}
diff --git a/src/app/common/footer/footer.component.ts b/src/app/common/footer/footer.component.ts
index f0b1bc3878..166386c71a 100644
--- a/src/app/common/footer/footer.component.ts
+++ b/src/app/common/footer/footer.component.ts
@@ -78,7 +78,7 @@ export class FooterComponent implements OnInit {
}
public get canAccessTutorNotes(): boolean {
- const tutor = this.selectedTask.tutor;
+ const tutor = this.selectedTask?.tutor;
if (!tutor) {
return false;
}
@@ -182,7 +182,11 @@ export class FooterComponent implements OnInit {
public get currentUnitRole(): UnitRole | undefined {
const currentUser = this.userService.currentUser;
- return this.selectedTask.unit.staff.find((ur) => ur.user.id === currentUser.id);
+ if (!currentUser) {
+ return undefined;
+ }
+
+ return this.selectedTask?.unit?.staff?.find((ur) => ur.user?.id === currentUser.id);
}
public get actionButtonEnabled(): boolean {
@@ -194,8 +198,10 @@ export class FooterComponent implements OnInit {
return false;
}
+ // An admin or auditor may not hold a role in the unit, and nobody can mark a task
+ // claimed by someone else, so a missing role reads as "not yours".
if (this.viewType === 'overflow' || this.selectedTask.claimedByUnitRoleId) {
- if (this.currentUnitRole.id !== this.selectedTask.claimedByUnitRoleId) {
+ if (this.currentUnitRole?.id !== this.selectedTask.claimedByUnitRoleId) {
return false;
}
}
@@ -207,6 +213,35 @@ export class FooterComponent implements OnInit {
return this.actionButtonEnabled && !!this.selectedTask?.canMarkComplete;
}
+ public readonly completeHintId = 'footer-complete-blocked-reason';
+
+ /** Why Complete is off for this task, or nothing when that is not the reason. */
+ public get completeBlockedReason(): string {
+ if (!this.selectedTask || this.selectedTask.canMarkComplete) {
+ return '';
+ }
+
+ return 'Discuss this task in class before marking it complete';
+ }
+
+ public get suggestedStatusLabel(): string {
+ const status = this.selectedTask?.suggestedTaskStatus;
+ if (!status) {
+ return '';
+ }
+
+ return `Recommended: Mark as ${this.taskService.statusData(status).label}`;
+ }
+
+ public get progressLabel(): string {
+ const stats = this.selectedTask?.project?.taskStats;
+ if (!stats || stats.length <= 4) {
+ return 'Progress unavailable';
+ }
+
+ return `${stats[4].value}% progress towards ${this.selectedTask.project.targetGradeWord}`;
+ }
+
public get discussActionStatus(): TaskStatusEnum {
return this.selectedTask?.status === 'discuss' ? 'rediscuss' : 'discuss';
}
diff --git a/src/app/common/grade-icon/grade-icon.component.html b/src/app/common/grade-icon/grade-icon.component.html
index 9c98630e18..35932f2847 100644
--- a/src/app/common/grade-icon/grade-icon.component.html
+++ b/src/app/common/grade-icon/grade-icon.component.html
@@ -1,6 +1,8 @@
+
{
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('preserves the white OnTrack logo in the desktop hero', () => {
+ const logo = fixture.nativeElement.querySelector('img') as HTMLImageElement;
+
+ expect(logo.getAttribute('src')).toBe('../../../assets/images/logo-white.svg');
+ });
});
diff --git a/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.html b/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.html
index 8049fc8a04..93c99fa01f 100644
--- a/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.html
+++ b/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.html
@@ -1,14 +1,15 @@
-
-
Download the {{ data.type }} CSV
-
-
This action will download all {{ data.type.toLowerCase() }} associated with this unit.
-
Include task {{ data.type.toLowerCase() }}
-
-
+Download {{ what }}
-
- Cancel
- Download
-
+
+
+ The CSV lists every one of the unit's {{ what }}.
+
+ Include the {{ what }} of each task too
+
+
+
+ Cancel
+ Download
+
diff --git a/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.ts b/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.ts
index d17b8fd895..ee4463599f 100644
--- a/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.ts
+++ b/src/app/common/learning-outcome-editor/nested-csv-download-modal/nested-csv-download-modal.component.ts
@@ -17,6 +17,11 @@ export class NestedCsvDownloadModalComponent {
@Inject(MAT_DIALOG_DATA) public data: {url: string; name: string; type: string},
) {}
+ /** What the CSV holds, in the words staff use. */
+ public get what(): string {
+ return this.data.type === 'Feedback Templates' ? 'feedback comments' : 'learning outcomes';
+ }
+
downloadCsv() {
this.fileDownloaderService.downloadFile(
`${this.data.url}?includes_tlos=${this.includeNested}`,
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.component.clipboard.spec.ts b/src/app/common/modals/calendar-modal/calendar-modal.component.clipboard.spec.ts
new file mode 100644
index 0000000000..657be7a0c9
--- /dev/null
+++ b/src/app/common/modals/calendar-modal/calendar-modal.component.clipboard.spec.ts
@@ -0,0 +1,92 @@
+// The two clipboard behaviours from the 11.0.x calendar work that the other calendar
+// specs do not cover: the copy button copies the whole subscription URL, and a
+// refused clipboard write is reported instead of claimed as copied.
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {CdkCopyToClipboard, Clipboard, ClipboardModule} from '@angular/cdk/clipboard';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MAT_DIALOG_DATA} from '@angular/material/dialog';
+import {MatIconModule} from '@angular/material/icon';
+import {MatSlideToggleModule} from '@angular/material/slide-toggle';
+import {By} from '@angular/platform-browser';
+import {of} from 'rxjs';
+import {ProjectService, Webcal, WebcalService} from 'src/app/api/models/doubtfire-model';
+import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants';
+import {FileDownloaderService} from '../../file-downloader/file-downloader.service';
+import {AlertService} from '../../services/alert.service';
+import {ConfirmationModalService} from '../confirmation-modal/confirmation-modal.service';
+import {CalendarModalComponent} from './calendar-modal.component';
+
+describe('CalendarModalComponent clipboard', () => {
+ let fixture: ComponentFixture;
+ let clipboard: {copy: ReturnType};
+ let alerts: {success: ReturnType; error: ReturnType};
+
+ beforeEach(async () => {
+ clipboard = {copy: vi.fn().mockReturnValue(true)};
+ alerts = {success: vi.fn(), error: vi.fn()};
+ const webcal = Object.assign(new Webcal(), {
+ enabled: true,
+ guid: 'synthetic-calendar',
+ reminder: null,
+ unitExclusions: [],
+ });
+ await TestBed.configureTestingModule({
+ declarations: [CalendarModalComponent],
+ imports: [ClipboardModule, MatIconModule, MatSlideToggleModule],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ {provide: MAT_DIALOG_DATA, useValue: {}},
+ {provide: WebcalService, useValue: {get: () => of(webcal)}},
+ {provide: ProjectService, useValue: {query: () => of([])}},
+ {provide: DoubtfireConstants, useValue: {API_URL: 'https://example.test/api'}},
+ {provide: AlertService, useValue: alerts},
+ {provide: Clipboard, useValue: clipboard},
+ {provide: ConfirmationModalService, useValue: {}},
+ {provide: FileDownloaderService, useValue: {}},
+ ],
+ }).compileComponents();
+ fixture = TestBed.createComponent(CalendarModalComponent);
+ fixture.detectChanges();
+ });
+
+ it('copies the complete subscription URL from only the separate copy button', () => {
+ vi.useFakeTimers();
+ try {
+ const copyControls = fixture.debugElement.queryAll(By.directive(CdkCopyToClipboard));
+ expect(copyControls).toHaveLength(1);
+ const copy = copyControls[0].nativeElement as HTMLButtonElement;
+ expect(copy.tagName).toBe('BUTTON');
+ expect(copy.getAttribute('aria-label')).toBe('Copy web calendar URL');
+
+ copy.click();
+
+ expect(clipboard.copy).toHaveBeenCalledExactlyOnceWith(
+ 'https://example.test/api/webcal/synthetic-calendar.ics',
+ );
+ expect(alerts.success).toHaveBeenCalledOnce();
+ expect(alerts.error).not.toHaveBeenCalled();
+ expect(fixture.componentInstance.copying).toBe(true);
+ vi.runAllTimers();
+ expect(fixture.componentInstance.copying).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('reports a refused clipboard write without claiming the URL was copied', () => {
+ clipboard.copy.mockReturnValue(false);
+ const copy: HTMLButtonElement = fixture.nativeElement.querySelector(
+ 'button[aria-label="Copy web calendar URL"]',
+ );
+
+ copy.click();
+
+ expect(clipboard.copy).toHaveBeenCalledExactlyOnceWith(
+ 'https://example.test/api/webcal/synthetic-calendar.ics',
+ );
+ expect(alerts.success).not.toHaveBeenCalled();
+ expect(alerts.error).toHaveBeenCalledOnce();
+ expect(fixture.componentInstance.copying).toBe(false);
+ });
+});
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.component.reviewed.spec.ts b/src/app/common/modals/calendar-modal/calendar-modal.component.reviewed.spec.ts
new file mode 100644
index 0000000000..3316d0061b
--- /dev/null
+++ b/src/app/common/modals/calendar-modal/calendar-modal.component.reviewed.spec.ts
@@ -0,0 +1,324 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {CdkCopyToClipboard, Clipboard, ClipboardModule} from '@angular/cdk/clipboard';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MAT_DIALOG_DATA} from '@angular/material/dialog';
+import {MatIconModule} from '@angular/material/icon';
+import {MatSlideToggleModule} from '@angular/material/slide-toggle';
+import {By} from '@angular/platform-browser';
+import {Subject, of} from 'rxjs';
+import {ProjectService, Webcal, WebcalService} from 'src/app/api/models/doubtfire-model';
+import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants';
+import {DemoModeStore} from 'src/app/demo/demo-mode.store';
+import {FileDownloaderService} from '../../file-downloader/file-downloader.service';
+import {AlertService} from '../../services/alert.service';
+import {ConfirmationModalService} from '../confirmation-modal/confirmation-modal.service';
+import {CalendarModalComponent} from './calendar-modal.component';
+
+const emptyProvider = {};
+
+describe('CalendarModalComponent', () => {
+ let component: CalendarModalComponent;
+ let fixture: ComponentFixture;
+ let fileDownloaderStub: {downloadFileWithFeedback: ReturnType};
+
+ beforeEach(async () => {
+ fileDownloaderStub = {downloadFileWithFeedback: vi.fn()};
+
+ await TestBed.configureTestingModule({
+ declarations: [CalendarModalComponent],
+ providers: [
+ {provide: WebcalService, useValue: emptyProvider},
+ {provide: DoubtfireConstants, useValue: {API_URL: 'https://doubtfire.test/api'}},
+ {provide: AlertService, useValue: emptyProvider},
+ {provide: ProjectService, useValue: emptyProvider},
+ {provide: MAT_DIALOG_DATA, useValue: emptyProvider},
+ {provide: ConfirmationModalService, useValue: emptyProvider},
+ {provide: FileDownloaderService, useValue: fileDownloaderStub},
+ {provide: DemoModeStore, useValue: {enabled: false}},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ })
+ .overrideComponent(CalendarModalComponent, {set: {template: ''}})
+ .compileComponents();
+
+ fixture = TestBed.createComponent(CalendarModalComponent);
+ component = fixture.componentInstance;
+ // The dialog opens in its loading state. These tests start after the webcal has loaded.
+ component.working = false;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('restores saved settings and allows a retry after a failed save', () => {
+ const first: Subject = new Subject();
+ const retry: Subject = new Subject();
+ const update = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(retry);
+ const error = vi.fn();
+ const internals = component as unknown as {
+ webcalService: {update: typeof update};
+ alerts: {error: typeof error};
+ loadWebcal: (value: Webcal) => void;
+ };
+ internals.webcalService = {update};
+ internals.alerts = {error};
+ const webcal = Object.assign(new Webcal(), {
+ enabled: true,
+ guid: 'saved-guid',
+ unitExclusions: [],
+ includeStartDates: false,
+ });
+ internals.loadWebcal(webcal);
+
+ component.includeExclusion({unit: {id: 7}});
+ first.error(new Error('offline'));
+
+ expect(component.working).toBe(false);
+ expect(component.webcal.unitExclusions).toEqual([]);
+ expect(error).toHaveBeenCalledOnce();
+ component.includeExclusion({unit: {id: 7}});
+ expect(update).toHaveBeenCalledTimes(2);
+ expect(component.working).toBe(true);
+ });
+
+ it('downloads the feed as an .ics file when the webcal is enabled', () => {
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = 'abc-123';
+ component.webcal = webcal;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).toHaveBeenCalledOnce();
+ expect(fileDownloaderStub.downloadFileWithFeedback).toHaveBeenCalledWith(
+ 'https://doubtfire.test/api/webcal/abc-123',
+ 'ontrack-calendar.ics',
+ {requestKey: 'web-calendar-ics'},
+ );
+ });
+
+ it('does not download when the webcal is disabled', () => {
+ const webcal = new Webcal();
+ webcal.enabled = false;
+ webcal.guid = 'abc-123';
+ component.webcal = webcal;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+
+ it('does not download when the webcal is enabled but has no guid', () => {
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = undefined;
+ component.webcal = webcal;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+
+ it('does not download when there is no webcal loaded yet', () => {
+ component.webcal = null;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+
+ it('refuses every other save while one is in flight and holds the download until it lands', () => {
+ // Each request gets its own pending response, so resolving the first cannot hide a second.
+ const pending: Subject[] = [];
+ const update = vi.fn(() => {
+ const response: Subject = new Subject();
+ pending.push(response);
+ return response.asObservable();
+ });
+ const internals = component as unknown as {
+ webcalService: {update: typeof update};
+ confirmationModal: {show: (title: string, message: string, confirm: () => void) => void};
+ };
+ internals.webcalService = {update};
+ // Hold on to each confirmation, so it can be confirmed later, while a save is running.
+ const confirmations: (() => void)[] = [];
+ internals.confirmationModal = {
+ show: (_title, _message, confirm) => confirmations.push(confirm),
+ };
+
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = 'abc-123';
+ webcal.unitExclusions = [];
+ webcal.reminder = {time: 1, unit: 'W'};
+ component.webcal = webcal;
+ component.newReminderActive = true;
+ component.newReminderTime = 1;
+ component.newReminderUnit = 'W';
+
+ // Open both confirmations before any save starts.
+ component.onChangeWebcalUrl();
+ component.onWebcalToggle();
+ expect(confirmations).toHaveLength(2);
+
+ component.includeExclusion({unit: {id: 1}});
+
+ // Every save entry point while the first save is pending.
+ component.includeExclusion({unit: {id: 2}});
+ component.removeExclusion({unit: {id: 1}});
+ component.toggleIncludeTaskStartDates();
+ component.newReminderTime = 3;
+ component.newReminderUnit = 'D';
+ component.onSaveReminderEdits();
+ component.newReminderActive = false;
+ component.onToggleReminderActive();
+ // Confirming the regenerate and disable dialogs now must not start a save either.
+ confirmations.forEach((confirm) => confirm());
+ component.downloadCalendar();
+
+ expect(update).toHaveBeenCalledOnce();
+ expect(webcal.unitExclusions).toEqual([1]);
+ expect(webcal.reminder).toEqual({time: 1, unit: 'W'});
+ expect(webcal.shouldChangeGuid).toBeFalsy();
+ expect(webcal.enabled).toBe(true);
+ // The refused reminder switch goes back to match the stored reminder.
+ expect(component.newReminderActive).toBe(true);
+ expect(component.newReminderTime).toBe(1);
+ expect(component.newReminderUnit).toBe('W');
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+
+ pending[0].next(webcal);
+ component.downloadCalendar();
+
+ expect(component.working).toBe(false);
+ expect(fileDownloaderStub.downloadFileWithFeedback).toHaveBeenCalledOnce();
+ });
+
+ it('does not download while a settings update is still saving', () => {
+ const webcal = new Webcal();
+ webcal.enabled = true;
+ webcal.guid = 'abc-123';
+ component.webcal = webcal;
+ component.working = true;
+
+ component.downloadCalendar();
+
+ expect(fileDownloaderStub.downloadFileWithFeedback).not.toHaveBeenCalled();
+ });
+});
+
+describe('CalendarModalComponent accessible URL controls', () => {
+ let fixture: ComponentFixture;
+ let clipboard: {copy: ReturnType};
+ let alerts: {success: ReturnType; error: ReturnType};
+
+ beforeEach(async () => {
+ clipboard = {copy: vi.fn().mockReturnValue(true)};
+ alerts = {success: vi.fn(), error: vi.fn()};
+ const webcal = Object.assign(new Webcal(), {
+ enabled: true,
+ guid: 'synthetic-calendar',
+ reminder: null,
+ unitExclusions: [],
+ });
+ await TestBed.configureTestingModule({
+ declarations: [CalendarModalComponent],
+ imports: [ClipboardModule, MatIconModule, MatSlideToggleModule],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ {provide: MAT_DIALOG_DATA, useValue: {}},
+ {provide: WebcalService, useValue: {get: () => of(webcal)}},
+ {provide: ProjectService, useValue: {query: () => of([])}},
+ {provide: DoubtfireConstants, useValue: {API_URL: 'https://example.test/api'}},
+ {provide: AlertService, useValue: alerts},
+ {provide: Clipboard, useValue: clipboard},
+ {provide: DemoModeStore, useValue: {enabled: false}},
+ {provide: ConfirmationModalService, useValue: {}},
+ {provide: FileDownloaderService, useValue: {}},
+ ],
+ }).compileComponents();
+ fixture = TestBed.createComponent(CalendarModalComponent);
+ fixture.detectChanges();
+ });
+
+ it('renders a named subscription link with the complete ICS URL', () => {
+ const link: HTMLAnchorElement = fixture.nativeElement.querySelector(
+ 'a[aria-label^="Subscribe"]',
+ );
+ expect(link.href).toBe('https://example.test/api/webcal/synthetic-calendar.ics');
+ expect(link.getAttribute('aria-label')).toContain(link.href);
+ expect(link.querySelector('mat-icon')?.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('copies the complete subscription URL from only the separate copy button', () => {
+ vi.useFakeTimers();
+ try {
+ const copyControls = fixture.debugElement.queryAll(By.directive(CdkCopyToClipboard));
+ expect(copyControls).toHaveLength(1);
+ const copy = copyControls[0].nativeElement as HTMLButtonElement;
+ expect(copy.tagName).toBe('BUTTON');
+ expect(copy.getAttribute('aria-label')).toBe('Copy web calendar URL');
+
+ copy.click();
+
+ expect(clipboard.copy).toHaveBeenCalledExactlyOnceWith(
+ 'https://example.test/api/webcal/synthetic-calendar.ics',
+ );
+ expect(alerts.success).toHaveBeenCalledOnce();
+ expect(alerts.error).not.toHaveBeenCalled();
+ expect(fixture.componentInstance.copying).toBe(true);
+ vi.runAllTimers();
+ expect(fixture.componentInstance.copying).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('reports a refused clipboard write without claiming the URL was copied', () => {
+ clipboard.copy.mockReturnValue(false);
+ const copy: HTMLButtonElement = fixture.nativeElement.querySelector(
+ 'button[aria-label="Copy web calendar URL"]',
+ );
+
+ copy.click();
+
+ expect(clipboard.copy).toHaveBeenCalledExactlyOnceWith(
+ 'https://example.test/api/webcal/synthetic-calendar.ics',
+ );
+ expect(alerts.success).not.toHaveBeenCalled();
+ expect(alerts.error).toHaveBeenCalledOnce();
+ expect(fixture.componentInstance.copying).toBe(false);
+ });
+
+ it('gives the adjacent icon-only copy and regenerate buttons separate accessible names', () => {
+ const root: HTMLElement = fixture.nativeElement;
+ const copy = root.querySelector(
+ 'button[aria-label="Copy web calendar URL"]',
+ )!;
+ const regenerate = root.querySelector(
+ 'button[aria-label="Regenerate web calendar URL"]',
+ )!;
+ expect(copy.getAttribute('aria-label')).toBe('Copy web calendar URL');
+ expect(regenerate.getAttribute('aria-label')).toBe('Regenerate web calendar URL');
+ expect(copy.disabled).toBe(false);
+ expect(regenerate.disabled).toBe(false);
+ expect(
+ copy.compareDocumentPosition(regenerate) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it('disables the download a copy button while a settings update is saving', () => {
+ const root: HTMLElement = fixture.nativeElement;
+ const download = Array.from(root.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes('Download .ics'),
+ )!;
+ expect(download.disabled).toBe(false);
+
+ fixture.componentInstance.working = true;
+ fixture.detectChanges();
+
+ expect(download.disabled).toBe(true);
+ });
+});
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.component.scss b/src/app/common/modals/calendar-modal/calendar-modal.component.scss
index e69de29bb2..2d8ff6ad1e 100644
--- a/src/app/common/modals/calendar-modal/calendar-modal.component.scss
+++ b/src/app/common/modals/calendar-modal/calendar-modal.component.scss
@@ -0,0 +1,367 @@
+@use '@angular/material' as mat;
+
+:host {
+ display: block;
+ min-width: 0;
+ color: var(--ot-color-text);
+ background-color: var(--ot-color-surface);
+
+ @include mat.dialog-overrides(
+ (
+ subhead-color: var(--ot-color-text),
+ supporting-text-color: var(--ot-color-text),
+ )
+ );
+}
+
+mat-card {
+ @include mat.card-overrides(
+ (
+ outlined-container-color: var(--ot-color-surface-raised),
+ outlined-outline-color: var(--ot-color-border),
+ subtitle-text-color: var(--ot-color-text-muted),
+ )
+ );
+ color: var(--ot-color-text);
+}
+
+.calendar-dialog {
+ display: flex;
+ max-height: min(90dvh, 56rem);
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.calendar-dialog__header {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1.25rem 1.5rem 0;
+}
+
+.calendar-dialog__header h2 {
+ // mat-dialog-title carries Material's own 24px side padding, which pushed the
+ // title in from the eyebrow above it. The header already sets the inset.
+ margin: 0;
+ padding: 0;
+ line-height: 1.25;
+}
+
+.calendar-dialog__eyebrow {
+ margin: 0 0 0.25rem;
+ color: var(--ot-color-text-muted);
+ font-size: 0.8125rem;
+ font-weight: 600;
+}
+
+.calendar-dialog__enable-control {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.calendar-dialog__content {
+ min-height: 0;
+ flex: 1 1 auto;
+ padding-top: 1rem !important;
+ overflow-x: clip;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.calendar-dialog__loading,
+.calendar-dialog__error {
+ display: flex;
+ min-height: 12rem;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 0.75rem;
+ text-align: center;
+}
+
+.calendar-dialog__error h3,
+.calendar-dialog__error p {
+ margin: 0;
+}
+
+.calendar-dialog__status {
+ margin-top: 0;
+}
+
+.calendar-options-card,
+.calendar-provider-help {
+ margin-top: 1rem;
+}
+
+:host ::ng-deep .calendar-unit-chips .mdc-evolution-chip-set__chips {
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+:host ::ng-deep .calendar-unit-chips .mat-mdc-chip {
+ max-width: 100%;
+ min-height: 40px;
+ height: auto;
+ margin: 0;
+}
+
+:host ::ng-deep .calendar-unit-chips .mdc-evolution-chip__cell--primary,
+:host ::ng-deep .calendar-unit-chips .mdc-evolution-chip__action--primary,
+:host ::ng-deep .calendar-unit-chips .mdc-evolution-chip__text-label {
+ min-width: 0;
+ max-width: 100%;
+ white-space: normal;
+}
+
+.calendar-unit-chip__label {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.calendar-unit-chip--add {
+ min-height: 44px !important;
+}
+
+.calendar-options-card__empty {
+ margin: 0.5rem 0 1rem;
+ color: var(--ot-color-text-muted);
+}
+
+.calendar-reminder-group {
+ min-width: 0;
+ margin: 1rem 0;
+ padding: 1rem;
+ border: 1px solid var(--ot-color-border);
+ border-radius: 0.625rem;
+}
+
+.calendar-reminder-group legend {
+ padding: 0 0.375rem;
+ color: var(--ot-color-text);
+ font-weight: 650;
+}
+
+.calendar-reminder-sentence {
+ display: grid;
+ grid-template-columns: auto minmax(7rem, 0.75fr) minmax(9rem, 1fr) minmax(8rem, auto);
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.calendar-reminder-sentence mat-form-field {
+ min-width: 0;
+}
+
+.calendar-reminder-sentence__suffix {
+ margin-bottom: 1.25rem;
+ font-weight: 600;
+}
+
+.calendar-reminder-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+.calendar-reminder-actions button {
+ min-height: 44px;
+}
+
+// One option per line: mat-checkbox is inline, so two in a row sat side by side.
+.calendar-option {
+ display: flex;
+ min-height: 44px;
+}
+
+// Sits under the sessions option, lined up with its label rather than the box.
+.calendar-options-card__hint {
+ margin: 0 0 0.5rem 2.5rem;
+ color: var(--ot-color-text-muted);
+ font-size: 0.8125rem;
+}
+
+.calendar-provider-help {
+ padding: 1rem;
+ border: 1px solid var(--ot-color-border);
+ border-radius: 0.625rem;
+ background: var(--ot-color-surface-raised);
+}
+
+.calendar-provider-help h3 {
+ margin: 0 0 0.75rem;
+ font-size: 1rem;
+ font-weight: 650;
+}
+
+:host ::ng-deep .calendar-provider-help .mat-mdc-tab {
+ flex-grow: 1;
+ min-width: 0;
+ min-height: 44px;
+ padding: 0 0.5rem;
+}
+
+.calendar-provider-help__instructions {
+ min-height: 3.5rem;
+ margin: 1rem 0;
+ color: var(--ot-color-text);
+}
+
+.calendar-subscription-url {
+ padding: 0.75rem;
+ border: 1px solid var(--ot-color-border);
+ border-radius: 0.5rem;
+ background: var(--ot-color-surface);
+}
+
+.calendar-subscription-url__value {
+ display: flex;
+ min-width: 0;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 0.25rem;
+ color: var(--ot-color-link);
+}
+
+.calendar-subscription-url__value span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.75rem;
+}
+
+.calendar-subscription-url__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.calendar-subscription-url__actions button,
+.calendar-dialog__actions button {
+ min-height: 44px;
+}
+
+.calendar-dialog__actions {
+ flex: 0 0 auto;
+ padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
+}
+
+@media (max-width: 639.98px), (max-height: 639.98px) {
+ .calendar-dialog {
+ max-height: calc(100dvh - 1rem);
+ }
+
+ .calendar-dialog__header {
+ align-items: flex-start;
+ padding: 1rem 1rem 0;
+ }
+
+ .calendar-dialog__enable-control {
+ align-items: flex-end;
+ flex-direction: column-reverse;
+ gap: 0.125rem;
+ }
+
+ .calendar-dialog__content {
+ padding: 0.75rem 1rem !important;
+ }
+
+ .calendar-options-card {
+ margin-inline: 0;
+ }
+
+ .calendar-reminder-sentence {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ .calendar-reminder-sentence mat-checkbox,
+ .calendar-reminder-sentence__suffix {
+ grid-column: 1 / -1;
+ }
+
+ .calendar-reminder-sentence__suffix {
+ margin: -0.75rem 0 0;
+ }
+
+ .calendar-subscription-url__actions {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .calendar-subscription-url__actions button {
+ width: 100%;
+ }
+}
+
+@media (max-width: 359.98px) {
+ .calendar-reminder-sentence {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .calendar-reminder-sentence mat-checkbox,
+ .calendar-reminder-sentence__suffix {
+ grid-column: auto;
+ }
+}
+
+.calendar-subscription-url__value:hover {
+ text-decoration: underline;
+}
+
+.calendar-subscription-url__value:focus-visible {
+ outline: 2px solid var(--ot-color-focus);
+ outline-offset: 3px;
+}
+
+// The M2 dark palette sets these tokens on :root[data-ot-theme] .mat-mdc-tab-group.
+// Include the local panel and host so those global declarations cannot win.
+:host .calendar-provider-help mat-tab-group {
+ @include mat.tabs-overrides(
+ (
+ active-label-text-color: var(--ot-color-link),
+ active-focus-label-text-color: var(--ot-color-link),
+ active-hover-label-text-color: var(--ot-color-link),
+ active-indicator-color: var(--ot-color-link),
+ active-focus-indicator-color: var(--ot-color-link),
+ active-hover-indicator-color: var(--ot-color-link),
+ inactive-label-text-color: var(--ot-color-text-muted),
+ inactive-focus-label-text-color: var(--ot-color-text),
+ inactive-hover-label-text-color: var(--ot-color-text),
+ pagination-icon-color: var(--ot-color-text),
+ divider-color: var(--ot-color-divider),
+ )
+ );
+}
+
+// Include the dialog so the global M2 .mat-primary palette cannot override
+// the accessible link colour on outlined actions in dark mode.
+:host .calendar-dialog button[mat-stroked-button] {
+ @include mat.button-overrides(
+ (
+ outlined-label-text-color: var(--ot-color-link),
+ outlined-outline-color: var(--ot-color-border),
+ outlined-state-layer-color: var(--ot-color-link),
+ outlined-disabled-label-text-color: var(--ot-color-disabled-text),
+ outlined-disabled-outline-color: var(--ot-color-divider),
+ )
+ );
+ background-color: var(--ot-color-surface);
+
+ &[color='warn'] {
+ @include mat.button-overrides(
+ (
+ outlined-label-text-color: var(--ot-color-error),
+ outlined-state-layer-color: var(--ot-color-error),
+ )
+ );
+ }
+}
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.service.spec.ts b/src/app/common/modals/calendar-modal/calendar-modal.service.spec.ts
new file mode 100644
index 0000000000..8476f5bddf
--- /dev/null
+++ b/src/app/common/modals/calendar-modal/calendar-modal.service.spec.ts
@@ -0,0 +1,32 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {TestBed} from '@angular/core/testing';
+import {MatDialog} from '@angular/material/dialog';
+import {CalendarModalComponent} from './calendar-modal.component';
+import {CalendarModalService} from './calendar-modal.service';
+
+describe('CalendarModalService', () => {
+ let dialog: {open: ReturnType};
+ let service: CalendarModalService;
+
+ beforeEach(() => {
+ dialog = {open: vi.fn()};
+ TestBed.configureTestingModule({
+ providers: [CalendarModalService, {provide: MatDialog, useValue: dialog}],
+ });
+ service = TestBed.inject(CalendarModalService);
+ });
+
+ it('opens a safe-area-aware responsive dialog and restores focus to its launch control', () => {
+ service.show();
+
+ expect(dialog.open).toHaveBeenCalledWith(CalendarModalComponent, {
+ height: 'min-content',
+ width: 'calc(100vw - 2rem)',
+ maxWidth: '900px',
+ maxHeight: 'calc(100dvh - 2rem)',
+ autoFocus: 'dialog',
+ restoreFocus: true,
+ panelClass: 'calendar-modal-dialog',
+ });
+ });
+});
diff --git a/src/app/common/modals/calendar-modal/calendar-modal.service.ts b/src/app/common/modals/calendar-modal/calendar-modal.service.ts
index fd1c6bec9f..ddda84a027 100644
--- a/src/app/common/modals/calendar-modal/calendar-modal.service.ts
+++ b/src/app/common/modals/calendar-modal/calendar-modal.service.ts
@@ -11,10 +11,13 @@ export class CalendarModalService {
public show(_task?: Task) {
this.dialog.open(CalendarModalComponent, {
- height: 'h-min',
- maxHeight: '90vh',
- width: '800px',
- maxWidth: '95vw',
+ height: 'min-content',
+ width: 'calc(100vw - 2rem)',
+ maxWidth: '900px',
+ maxHeight: 'calc(100dvh - 2rem)',
+ autoFocus: 'dialog',
+ restoreFocus: true,
+ panelClass: 'calendar-modal-dialog',
});
}
}
diff --git a/src/app/common/modals/comments-modal/comments-modal.component.html b/src/app/common/modals/comments-modal/comments-modal.component.html
index 86be41b59f..ddc92e5d69 100644
--- a/src/app/common/modals/comments-modal/comments-modal.component.html
+++ b/src/app/common/modals/comments-modal/comments-modal.component.html
@@ -1,11 +1,40 @@
-
- @if (taskComment.commentType === 'image') {
-
- } @else if (taskComment.commentType === 'pdf') {
-
- }
-
+
diff --git a/src/app/common/modals/comments-modal/comments-modal.component.scss b/src/app/common/modals/comments-modal/comments-modal.component.scss
index e69de29bb2..50c0213bab 100644
--- a/src/app/common/modals/comments-modal/comments-modal.component.scss
+++ b/src/app/common/modals/comments-modal/comments-modal.component.scss
@@ -0,0 +1,106 @@
+:host {
+ display: block;
+ max-height: 100dvh;
+ min-width: 0;
+ width: 100%;
+}
+
+.comments-modal {
+ background: var(--ot-color-surface);
+ color: var(--ot-color-text);
+ display: flex;
+ flex-direction: column;
+ height: min(90dvh, 56rem);
+ min-height: min(36rem, 90dvh);
+ overflow: hidden;
+ width: 100%;
+}
+
+.comments-modal__header {
+ align-items: center;
+ border-bottom: 1px solid var(--ot-color-border);
+ display: flex;
+ flex: 0 0 auto;
+ gap: 1rem;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+}
+
+.comments-modal__heading {
+ align-items: center;
+ display: flex;
+ gap: 0.75rem;
+ min-width: 0;
+
+ > div {
+ min-width: 0;
+ }
+
+ h2,
+ p {
+ margin: 0;
+ overflow-wrap: anywhere;
+ }
+
+ h2 {
+ color: var(--ot-color-text);
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.35;
+ }
+
+ p {
+ color: var(--ot-color-text-muted);
+ font-size: 0.8rem;
+ }
+}
+
+.comments-modal__close {
+ flex: 0 0 auto;
+ min-height: 3rem;
+ min-width: 3rem;
+}
+
+.comments-modal__content {
+ background: var(--ot-color-page);
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ min-width: 0;
+ overflow: auto;
+ padding: 0.75rem;
+}
+
+.comments-modal__pdf {
+ display: block;
+ flex: 1 1 auto;
+ min-height: 0;
+ min-width: 0;
+ width: 100%;
+}
+
+.comments-modal__image {
+ align-self: flex-start;
+ border-radius: 0.5rem;
+ height: auto;
+ margin: auto;
+ max-height: 100%;
+ max-width: 100%;
+ object-fit: contain;
+}
+
+@media (max-width: 600px), (max-height: 600px) {
+ .comments-modal {
+ height: 100dvh;
+ min-height: 0;
+ }
+
+ .comments-modal__header {
+ padding: max(0.5rem, env(safe-area-inset-top)) max(0.5rem, env(safe-area-inset-right)) 0.5rem
+ max(0.75rem, env(safe-area-inset-left));
+ }
+
+ .comments-modal__content {
+ padding: 0.5rem;
+ }
+}
diff --git a/src/app/common/modals/comments-modal/comments-modal.component.spec.ts b/src/app/common/modals/comments-modal/comments-modal.component.spec.ts
new file mode 100644
index 0000000000..12d861ab9a
--- /dev/null
+++ b/src/app/common/modals/comments-modal/comments-modal.component.spec.ts
@@ -0,0 +1,51 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
+import {TaskComment} from 'src/app/api/models/doubtfire-model';
+import {CommentsModalComponent, CommentsModalData} from './comments-modal.component';
+
+describe('CommentsModalComponent', () => {
+ let fixture: ComponentFixture;
+ const close = vi.fn();
+ const data: CommentsModalData = {
+ commentResourceUrl: '/comments/22',
+ comment: {
+ commentType: 'pdf',
+ attachmentFileName: 'Long evidence filename.pdf',
+ } as TaskComment,
+ };
+
+ beforeEach(async () => {
+ close.mockReset();
+ await TestBed.configureTestingModule({
+ declarations: [CommentsModalComponent],
+ providers: [
+ {provide: MAT_DIALOG_DATA, useValue: data},
+ {provide: MatDialogRef, useValue: {close}},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CommentsModalComponent);
+ fixture.detectChanges();
+ });
+
+ it('passes the real filename and URL into the responsive PDF viewer', () => {
+ const heading = fixture.nativeElement.querySelector('h2') as HTMLHeadingElement;
+ const viewer = fixture.nativeElement.querySelector('f-pdf-viewer') as HTMLElement;
+ expect(heading.textContent).toContain('Long evidence filename.pdf');
+ expect(viewer).toBeTruthy();
+ expect(fixture.componentInstance.fileName).toBe('Long evidence filename.pdf');
+ expect(fixture.componentInstance.commentResourceUrl).toBe('/comments/22');
+ });
+
+ it('has an explicit close action', () => {
+ const closeButton = fixture.nativeElement.querySelector(
+ '.comments-modal__close',
+ ) as HTMLButtonElement;
+ expect(closeButton.getAttribute('aria-label')).toBe('Close attachment preview');
+ closeButton.click();
+ expect(close).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/app/common/modals/comments-modal/comments-modal.component.ts b/src/app/common/modals/comments-modal/comments-modal.component.ts
index 7cb5d80dc3..556ccc5182 100644
--- a/src/app/common/modals/comments-modal/comments-modal.component.ts
+++ b/src/app/common/modals/comments-modal/comments-modal.component.ts
@@ -1,5 +1,5 @@
import {ChangeDetectionStrategy, Component, Inject, Input, OnInit} from '@angular/core';
-import {MAT_DIALOG_DATA} from '@angular/material/dialog';
+import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {TaskComment} from 'src/app/api/models/doubtfire-model';
export interface CommentsModalData {
@@ -18,10 +18,24 @@ export class CommentsModalComponent implements OnInit {
@Input() taskComment: TaskComment;
@Input() commentResourceUrl: string;
- constructor(@Inject(MAT_DIALOG_DATA) public data: CommentsModalData) {}
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: CommentsModalData,
+ private dialogRef: MatDialogRef,
+ ) {}
ngOnInit(): void {
this.taskComment = this.data.comment;
this.commentResourceUrl = this.data.commentResourceUrl;
}
+
+ get fileName(): string {
+ if (this.taskComment?.attachmentFileName) {
+ return this.taskComment.attachmentFileName;
+ }
+ return this.taskComment?.commentType === 'pdf' ? 'PDF attachment' : 'Image attachment';
+ }
+
+ close(): void {
+ this.dialogRef.close();
+ }
}
diff --git a/src/app/common/modals/comments-modal/comments-modal.service.spec.ts b/src/app/common/modals/comments-modal/comments-modal.service.spec.ts
new file mode 100644
index 0000000000..279cb2bcb6
--- /dev/null
+++ b/src/app/common/modals/comments-modal/comments-modal.service.spec.ts
@@ -0,0 +1,35 @@
+import {describe, expect, it, vi} from 'vitest';
+import {TestBed} from '@angular/core/testing';
+import {MatDialog} from '@angular/material/dialog';
+import {TaskComment} from 'src/app/api/models/doubtfire-model';
+import {CommentsModalComponent} from './comments-modal.component';
+import {CommentsModalService} from './comments-modal.service';
+
+describe('CommentsModalService', () => {
+ it('opens a bounded dialog with focus restoration, close control focus and Escape enabled', () => {
+ const open = vi.fn();
+ TestBed.configureTestingModule({
+ providers: [CommentsModalService, {provide: MatDialog, useValue: {open}}],
+ });
+ const service = TestBed.inject(CommentsModalService);
+ const comment = {
+ commentType: 'pdf',
+ attachmentFileName: 'feedback.pdf',
+ } as TaskComment;
+
+ service.show('/comments/3', comment);
+
+ expect(open).toHaveBeenCalledWith(
+ CommentsModalComponent,
+ expect.objectContaining({
+ data: {commentResourceUrl: '/comments/3', comment},
+ maxWidth: '960px',
+ maxHeight: '100dvh',
+ autoFocus: '.comments-modal__close',
+ restoreFocus: true,
+ disableClose: false,
+ ariaLabelledBy: 'comments-modal-title',
+ }),
+ );
+ });
+});
diff --git a/src/app/common/modals/comments-modal/comments-modal.service.ts b/src/app/common/modals/comments-modal/comments-modal.service.ts
index 353c9c6c37..c55620876c 100644
--- a/src/app/common/modals/comments-modal/comments-modal.service.ts
+++ b/src/app/common/modals/comments-modal/comments-modal.service.ts
@@ -13,8 +13,14 @@ export class CommentsModalService {
this.dialog.open(CommentsModalComponent, {
data: {commentResourceUrl, comment},
width: '100%',
- maxWidth: '900px',
- panelClass: 'overflow-y-auto',
+ maxWidth: '960px',
+ maxHeight: '100dvh',
+ autoFocus: '.comments-modal__close',
+ restoreFocus: true,
+ closeOnNavigation: true,
+ disableClose: false,
+ ariaLabelledBy: 'comments-modal-title',
+ panelClass: 'comments-modal-dialog',
});
}
}
diff --git a/src/app/common/modals/extension-modal/extension-modal.component.html b/src/app/common/modals/extension-modal/extension-modal.component.html
index 30beb590e5..7940d3e922 100644
--- a/src/app/common/modals/extension-modal/extension-modal.component.html
+++ b/src/app/common/modals/extension-modal/extension-modal.component.html
@@ -1,21 +1,51 @@
-Extension request
-
-
- Please explain why you require an extension for this task, the teaching team will assess the
- request shortly.
+
Request an extension
+
+
+ @if (data.task.definition) {
+
+ {{ data.task.definition.abbreviation }}
+ {{ data.task.definition.name }}
+
+ }
+
+ event
+ Due {{ formatShortDate(dueDate) }}
+
+
+
+ @if (daysPastDue > 0) {
+
+ warning
+ This task is {{ daysPastDue }} {{ daysPastDue === 1 ? 'day' : 'days' }} past its due
+ date
+
+ }
+
+
+ Tell your teaching team why you need more time. They'll review your request and reply in the
+ task comments.
-
+
Reason
- {{ extensionData.controls.extensionReason.value.length }} / {{ reasonMaxLength }} {{ reasonLength }} / {{ reasonMaxLength }}
@if (extensionData.controls.extensionReason.hasError('required')) {
You must enter a reason
@@ -28,38 +58,68 @@ Extension request
}
-
- Choose a date
-
-
-
-
+
+
+ New due date
+
+
+
+
+ @if (dateRangeText) {
+ Choose a date from {{ dateRangeText }}
+ } @else if (!hasDateRange) {
+ The final deadline has passed, so the earliest date is requested
+ } @else {
+
+ Choose a date on or after {{ formatShortDate(minDate) }}
+ }
+
+
+ @if (dateNeedsAttention) {
+
{{ dateErrorText }}
+ } @else if (extensionSummary) {
+
{{ extensionSummary }}
+ }
+
+
+ @if (errorMessage) {
+ {{ errorMessage }}
+ }
-
-
Cancel
+
+
+ Cancel
+
- Request extension to
- {{
- extensionDate.toLocaleDateString(currentLocale, {
- month: 'long',
- day: 'numeric',
- })
- }}
+ @if (submitting) {
+
+ } @else {
+ send
+ }
+ {{ submitting ? 'Sending…' : 'Request extension' }}
diff --git a/src/app/common/modals/extension-modal/extension-modal.component.reviewed.spec.ts b/src/app/common/modals/extension-modal/extension-modal.component.reviewed.spec.ts
new file mode 100644
index 0000000000..5e3e05784f
--- /dev/null
+++ b/src/app/common/modals/extension-modal/extension-modal.component.reviewed.spec.ts
@@ -0,0 +1,21 @@
+import {describe, expect, it, vi} from 'vitest';
+import {ExtensionModalComponent} from './extension-modal.component';
+
+// scrollCommentsDown reaches into the DOM for div.comments-body, which is not
+// mounted on every screen the modal can open from. Exercise it on a bare
+// instance with no such element present.
+describe('ExtensionModalComponent scrollCommentsDown', () => {
+ it('does not throw when the comments panel is not on the page', () => {
+ vi.useFakeTimers();
+ const component = Object.create(ExtensionModalComponent.prototype) as {
+ scrollCommentsDown(): void;
+ };
+
+ expect(() => {
+ component.scrollCommentsDown();
+ vi.runAllTimers();
+ }).not.toThrow();
+
+ vi.useRealTimers();
+ });
+});
diff --git a/src/app/common/modals/extension-modal/extension-modal.component.scss b/src/app/common/modals/extension-modal/extension-modal.component.scss
new file mode 100644
index 0000000000..f02ef2f2d4
--- /dev/null
+++ b/src/app/common/modals/extension-modal/extension-modal.component.scss
@@ -0,0 +1,187 @@
+@use '../../../unit-hub/hub-card' as hub;
+
+@include hub.card-theme;
+
+:host {
+ display: block;
+ max-width: 100%;
+}
+
+.ext-dialog-title {
+ padding-inline: 24px;
+}
+
+.task-dialog-content {
+ display: flex;
+ max-height: calc(100dvh - 10rem);
+ flex-direction: column;
+ gap: 16px;
+ overflow: auto;
+ padding: 0 24px 8px;
+ color: var(--ot-color-text);
+}
+
+// Task abbreviation, name and current due date under the title.
+.ext-context {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 16px;
+ margin-top: -4px;
+ font-size: 0.875rem;
+ line-height: 1.4;
+}
+
+.ext-context__task {
+ display: inline-flex;
+ min-width: 0;
+ align-items: center;
+ gap: 8px;
+}
+
+.ext-context__name {
+ overflow-wrap: anywhere;
+ font-weight: 600;
+}
+
+.ext-context__due {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--ot-color-text-muted);
+ white-space: nowrap;
+
+ mat-icon {
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
+ color: var(--ot-color-link);
+ }
+}
+
+// Same chip as the extension request card in the task comments.
+.ext-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: var(--ot-radius-pill);
+ background: color-mix(in srgb, var(--ot-color-primary) 14%, transparent);
+ color: var(--ot-color-text);
+ font-size: 0.75rem;
+ font-weight: 500;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+.ext-chip--task {
+ font-weight: 600;
+}
+
+.ext-callout {
+ @include hub.callout(raised);
+ @include hub.tinted-card(var(--ot-color-warning));
+ margin: 0;
+ color: var(--ot-color-text);
+
+ > mat-icon {
+ color: var(--ot-color-warning);
+ }
+}
+
+.ext-description {
+ margin: 0;
+ color: var(--ot-color-text-muted);
+ line-height: 1.5;
+}
+
+// The hint row sits inside the field, so no extra spacing is added below it.
+.ext-field {
+ margin: 0;
+}
+
+.ext-counter {
+ color: var(--ot-color-text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.ext-counter--warn {
+ color: var(--ot-color-warning);
+}
+
+.ext-counter--limit {
+ color: var(--ot-color-error);
+ font-weight: 600;
+}
+
+.ext-date {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.ext-summary {
+ font-variant-numeric: tabular-nums;
+}
+
+.ext-date__error {
+ margin: 0;
+ color: var(--ot-color-error);
+ font-size: 0.75rem;
+}
+
+.task-dialog-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 16px 24px 24px;
+
+ button {
+ min-height: 44px;
+ margin: 0;
+ white-space: normal;
+ }
+}
+
+// The app paints every spinner circle in the divider colour, which vanishes on a
+// disabled button. Inside the button it follows the label colour instead.
+.ext-submit__spinner {
+ display: inline-block;
+ margin-right: 8px;
+ vertical-align: middle;
+
+ ::ng-deep circle {
+ stroke: currentColor !important;
+ }
+}
+
+.task-dialog-error {
+ margin: 0;
+ border-left: 4px solid var(--ot-color-error);
+ background: color-mix(in srgb, var(--ot-color-error) 10%, transparent);
+ padding: 0.75rem;
+ color: var(--ot-color-text);
+}
+
+@media (max-width: 599.98px) {
+ .task-dialog-content {
+ max-height: calc(100dvh - 11.5rem);
+ padding-inline: 16px;
+ }
+
+ .ext-dialog-title {
+ padding-inline: 16px;
+ }
+
+ .task-dialog-actions {
+ flex-direction: column-reverse;
+ align-items: stretch;
+ padding-inline: 16px;
+
+ button {
+ width: 100%;
+ }
+ }
+}
diff --git a/src/app/common/modals/extension-modal/extension-modal.service.spec.ts b/src/app/common/modals/extension-modal/extension-modal.service.spec.ts
new file mode 100644
index 0000000000..fa4d4f1a49
--- /dev/null
+++ b/src/app/common/modals/extension-modal/extension-modal.service.spec.ts
@@ -0,0 +1,32 @@
+import {describe, expect, it, vi} from 'vitest';
+import {MatDialog, MatDialogConfig} from '@angular/material/dialog';
+import {Task} from 'src/app/api/models/task';
+import {ExtensionModalComponent} from './extension-modal.component';
+import {ExtensionModalService} from './extension-modal.service';
+
+describe('ExtensionModalService', () => {
+ it('opens a centred finite-height dialog without focusing the textarea', () => {
+ const dialogRef = {};
+ const open = vi.fn((_component: unknown, _config: MatDialogConfig) => dialogRef);
+ const service = new ExtensionModalService({open} as unknown as MatDialog);
+
+ expect(service.show({} as Task)).toBe(dialogRef);
+
+ const config = open.mock.calls[0][1] as MatDialogConfig;
+ expect(config).toMatchObject({
+ autoFocus: 'dialog',
+ closeOnNavigation: true,
+ maxHeight: 'calc(100dvh - 2rem)',
+ maxWidth: '560px',
+ restoreFocus: true,
+ width: 'calc(100vw - 2rem)',
+ });
+ expect(config.position).toBeUndefined();
+
+ const canClose = vi.fn(() => false);
+ expect(
+ config.closePredicate(undefined, config, {canClose} as unknown as ExtensionModalComponent),
+ ).toBe(false);
+ expect(canClose).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/common/modals/extension-modal/extension-modal.service.ts b/src/app/common/modals/extension-modal/extension-modal.service.ts
index 19709c908e..73c51be7a4 100644
--- a/src/app/common/modals/extension-modal/extension-modal.service.ts
+++ b/src/app/common/modals/extension-modal/extension-modal.service.ts
@@ -13,15 +13,22 @@ export class ExtensionModalService {
const dialogRef: MatDialogRef
= this.dialog.open(
ExtensionModalComponent,
{
+ autoFocus: 'dialog',
+ closeOnNavigation: true,
+ maxHeight: 'calc(100dvh - 2rem)',
+ maxWidth: '560px',
+ panelClass: 'responsive-task-dialog',
+ restoreFocus: true,
+ width: 'calc(100vw - 2rem)',
data: {
task,
afterApplication,
},
+ closePredicate: (_result, _config, component) =>
+ (component as ExtensionModalComponent | null)?.canClose() ?? true,
},
);
- dialogRef.afterOpened().subscribe();
-
- dialogRef.afterClosed().subscribe();
+ return dialogRef;
}
}
diff --git a/src/app/common/modals/task-assessment-modal/task-assessment-modal.component.html b/src/app/common/modals/task-assessment-modal/task-assessment-modal.component.html
index 1e3f606499..52b4333281 100644
--- a/src/app/common/modals/task-assessment-modal/task-assessment-modal.component.html
+++ b/src/app/common/modals/task-assessment-modal/task-assessment-modal.component.html
@@ -1,7 +1,11 @@
Overseer Assessment
-
+
@@ -16,6 +20,6 @@
Overseer Assessment
refresh Refresh
}
- Cancel
+ Cancel
diff --git a/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.html b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.html
index 176eebc049..74041df998 100644
--- a/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.html
+++ b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.html
@@ -1,5 +1,20 @@
-
+
+
+ Moderation notes
+
+
+
+
+
+
+
+ Close
+
diff --git a/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.spec.ts b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.spec.ts
new file mode 100644
index 0000000000..a763455d7a
--- /dev/null
+++ b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.spec.ts
@@ -0,0 +1,38 @@
+import {beforeEach, describe, expect, it} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MAT_DIALOG_DATA} from '@angular/material/dialog';
+import {UnitRole} from 'src/app/api/models/unit-role';
+import {TutorNotesModalComponent} from './tutor-notes-modal.component';
+
+describe('TutorNotesModalComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [TutorNotesModalComponent],
+ providers: [
+ {provide: MAT_DIALOG_DATA, useValue: {unitRole: {user: {name: 'Tia Tutor'}} as UnitRole}},
+ ],
+ // The dialog directives and the notes view are not under test here.
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TutorNotesModalComponent);
+ fixture.detectChanges();
+ });
+
+ it('lays the dialog out as a title, the notes and a Close action', () => {
+ const title = fixture.nativeElement.querySelector('[mat-dialog-title]') as HTMLElement;
+ expect(title.textContent.trim()).toBe('Moderation notes');
+
+ const content = fixture.nativeElement.querySelector('mat-dialog-content') as HTMLElement;
+ expect(content.querySelector('f-tutor-notes-view')).not.toBeNull();
+
+ const close = fixture.nativeElement.querySelector(
+ 'mat-dialog-actions button[mat-dialog-close]',
+ ) as HTMLButtonElement;
+ expect(close.textContent.trim()).toBe('Close');
+ expect(close.getAttribute('type')).toBe('button');
+ });
+});
diff --git a/src/app/common/notifications/notification-presentation.spec.ts b/src/app/common/notifications/notification-presentation.spec.ts
new file mode 100644
index 0000000000..e9af069cda
--- /dev/null
+++ b/src/app/common/notifications/notification-presentation.spec.ts
@@ -0,0 +1,88 @@
+import {describe, expect, it} from 'vitest';
+import {Notification} from 'src/app/api/models/notification';
+import {presentationFor} from './notification-presentation';
+
+function notification(event: string, notificationType = 'task'): Notification {
+ return Object.assign(new Notification(), {event, notificationType});
+}
+
+describe('notification presentation', () => {
+ it('distinguishes every deterministic demo event without reading message text', () => {
+ expect(
+ [
+ 'new_task_available',
+ 'task_due_soon',
+ 'task_due_date_changed',
+ 'task_comment_created',
+ 'task_status_changed',
+ 'extension_assessed',
+ 'portfolio_received',
+ ].map((event) => presentationFor(notification(event)).label),
+ ).toEqual([
+ 'New task',
+ 'Due soon',
+ 'Date changed',
+ 'New feedback',
+ 'Status changed',
+ 'Extension',
+ 'Portfolio',
+ ]);
+ });
+
+ // Tutors are told when a student asks for help or for an extension. The request is
+ // a task notification so it can be switched off, and still reads as an extension.
+ it('names the tutor request events apart from the category they are filed under', () => {
+ expect(presentationFor(notification('task_help_requested'))).toMatchObject({
+ icon: 'help',
+ label: 'Help requested',
+ tone: 'task',
+ });
+ expect(presentationFor(notification('extension_requested', 'task'))).toMatchObject({
+ icon: 'more_time',
+ label: 'Extension request',
+ tone: 'extension',
+ });
+ });
+
+ it('draws the staff portfolio notification apart from the student receipt', () => {
+ expect(presentationFor(notification('portfolio_submitted', 'portfolio'))).toMatchObject({
+ icon: 'inventory',
+ label: 'Portfolio submitted',
+ tone: 'portfolio',
+ });
+ });
+
+ it('draws Unit Hub announcements and sessions in their own tone', () => {
+ expect(
+ [
+ 'unit_announcement_published',
+ 'unit_announcement_updated',
+ 'unit_session_changed',
+ 'unit_session_starting_soon',
+ ].map((event) => presentationFor(notification(event, 'unit_hub'))),
+ ).toEqual([
+ {icon: 'campaign', label: 'Announcement', tone: 'unit-hub'},
+ {icon: 'edit_note', label: 'Announcement updated', tone: 'unit-hub'},
+ {icon: 'event_note', label: 'Session update', tone: 'unit-hub'},
+ {icon: 'alarm', label: 'Starting soon', tone: 'unit-hub'},
+ ]);
+ expect(presentationFor(notification('unit_hub_future_event', 'unit_hub'))).toEqual({
+ icon: 'hub',
+ label: 'Unit Hub',
+ tone: 'unit-hub',
+ });
+ });
+
+ it('uses a category fallback for a new event and a generic fallback for a new category', () => {
+ expect(presentationFor(notification('future_feedback_event', 'feedback'))).toMatchObject({
+ icon: 'chat_bubble',
+ label: 'Feedback',
+ tone: 'feedback',
+ });
+ expect(presentationFor(notification('future_event', 'future_category'))).toMatchObject({
+ icon: 'notifications',
+ label: 'Notification',
+ tone: 'general',
+ });
+ });
+});
diff --git a/src/app/common/notifications/notification-presentation.ts b/src/app/common/notifications/notification-presentation.ts
new file mode 100644
index 0000000000..5d9cd4111d
--- /dev/null
+++ b/src/app/common/notifications/notification-presentation.ts
@@ -0,0 +1,67 @@
+import {Notification} from 'src/app/api/models/notification';
+
+export interface NotificationPresentation {
+ icon: string;
+ label: string;
+ tone: 'feedback' | 'task' | 'portfolio' | 'extension' | 'general' | 'unit-hub';
+}
+
+const EVENT_PRESENTATIONS: Readonly> = {
+ new_task_available: {icon: 'assignment_add', label: 'New task', tone: 'task'},
+ task_due_soon: {icon: 'event', label: 'Due soon', tone: 'task'},
+ task_due_date_changed: {icon: 'edit_calendar', label: 'Date changed', tone: 'task'},
+ task_comment_created: {icon: 'chat_bubble', label: 'New feedback', tone: 'feedback'},
+ discussion_request_created: {
+ icon: 'record_voice_over',
+ label: 'Discussion prompt',
+ tone: 'feedback',
+ },
+ task_status_changed: {icon: 'published_with_changes', label: 'Status changed', tone: 'task'},
+ task_submitted: {icon: 'upload_file', label: 'Task submitted', tone: 'task'},
+ task_help_requested: {icon: 'help', label: 'Help requested', tone: 'task'},
+ extension_assessed: {icon: 'more_time', label: 'Extension', tone: 'extension'},
+ // Filed under task notifications so a tutor can switch it off, but still drawn as
+ // an extension so it reads as one in the list.
+ extension_requested: {icon: 'more_time', label: 'Extension request', tone: 'extension'},
+ portfolio_received: {icon: 'collections_bookmark', label: 'Portfolio', tone: 'portfolio'},
+ // Staff side of the same submission, told apart from the student's receipt.
+ portfolio_submitted: {icon: 'inventory', label: 'Portfolio submitted', tone: 'portfolio'},
+ group_membership_changed: {icon: 'groups', label: 'Group update', tone: 'general'},
+ tutorial_changed: {icon: 'event_repeat', label: 'Tutorial update', tone: 'general'},
+ // Unit Hub. A cancellation is a session change too, and says so in its message.
+ unit_announcement_published: {icon: 'campaign', label: 'Announcement', tone: 'unit-hub'},
+ unit_announcement_updated: {icon: 'edit_note', label: 'Announcement updated', tone: 'unit-hub'},
+ unit_session_changed: {icon: 'event_note', label: 'Session update', tone: 'unit-hub'},
+ unit_session_starting_soon: {icon: 'alarm', label: 'Starting soon', tone: 'unit-hub'},
+};
+
+const CATEGORY_PRESENTATIONS: Readonly> = {
+ feedback: {icon: 'chat_bubble', label: 'Feedback', tone: 'feedback'},
+ task: {icon: 'assignment', label: 'Task', tone: 'task'},
+ portfolio: {icon: 'collections_bookmark', label: 'Portfolio', tone: 'portfolio'},
+ extension: {icon: 'more_time', label: 'Extension', tone: 'extension'},
+ general: {icon: 'campaign', label: 'OnTrack update', tone: 'general'},
+ unit_hub: {icon: 'hub', label: 'Unit Hub', tone: 'unit-hub'},
+};
+
+const UNKNOWN_PRESENTATION: NotificationPresentation = {
+ icon: 'notifications',
+ label: 'Notification',
+ tone: 'general',
+};
+
+/**
+ * Stable notification presentation keyed by the API event hook.
+ *
+ * Event names, rather than message parsing, let due dates, feedback, status
+ * changes and portfolio activity remain visibly different while keeping the
+ * message body privacy-safe. The category fallback keeps an older browser
+ * useful when the API adds an event this build has not learned yet.
+ */
+export function presentationFor(notification: Notification): NotificationPresentation {
+ return (
+ EVENT_PRESENTATIONS[notification.event] ??
+ CATEGORY_PRESENTATIONS[notification.notificationType] ??
+ UNKNOWN_PRESENTATION
+ );
+}
diff --git a/src/app/common/page-container/page-container.component.scss b/src/app/common/page-container/page-container.component.scss
new file mode 100644
index 0000000000..e44919fb53
--- /dev/null
+++ b/src/app/common/page-container/page-container.component.scss
@@ -0,0 +1,5 @@
+@media (max-width: 639.98px) {
+ .page-container {
+ padding-bottom: var(--mobile-page-bottom-padding);
+ }
+}
diff --git a/src/app/common/pdf-viewer/element-fullscreen.spec.ts b/src/app/common/pdf-viewer/element-fullscreen.spec.ts
new file mode 100644
index 0000000000..87c5a0ec65
--- /dev/null
+++ b/src/app/common/pdf-viewer/element-fullscreen.spec.ts
@@ -0,0 +1,84 @@
+import {describe, expect, it, vi} from 'vitest';
+import {ElementFullscreen} from './element-fullscreen';
+
+/** Enough of the Fullscreen API for ElementFullscreen, which jsdom does not have. */
+function fakeDocument(enabled = true) {
+ const doc = {
+ fullscreenEnabled: enabled,
+ fullscreenElement: null as Element | null,
+ exitFullscreen: vi.fn(() => {
+ doc.fullscreenElement = null;
+ return Promise.resolve();
+ }),
+ };
+ return doc;
+}
+
+function fakeElement(doc: ReturnType) {
+ const element = document.createElement('div');
+ element.requestFullscreen = vi.fn(() => {
+ doc.fullscreenElement = element;
+ return Promise.resolve();
+ });
+ return element;
+}
+
+describe('ElementFullscreen, the PDF viewer full screen', () => {
+ it('reports whether the browser can go full screen', () => {
+ const element = document.createElement('div');
+
+ expect(new ElementFullscreen(() => element, fakeDocument(true) as never).supported).toBe(true);
+ expect(new ElementFullscreen(() => element, fakeDocument(false) as never).supported).toBe(
+ false,
+ );
+ });
+
+ it('asks the browser to put the viewer full screen, then leaves it on the next toggle', async () => {
+ const doc = fakeDocument();
+ const element = fakeElement(doc);
+ const fullscreen = new ElementFullscreen(() => element, doc as never);
+
+ expect(fullscreen.active).toBe(false);
+
+ await fullscreen.toggle();
+ expect(element.requestFullscreen).toHaveBeenCalledOnce();
+ expect(fullscreen.active).toBe(true);
+
+ await fullscreen.toggle();
+ expect(doc.exitFullscreen).toHaveBeenCalledOnce();
+ expect(fullscreen.active).toBe(false);
+ });
+
+ it('is not active when something else on the page holds full screen', async () => {
+ const doc = fakeDocument();
+ const element = fakeElement(doc);
+ const fullscreen = new ElementFullscreen(() => element, doc as never);
+ doc.fullscreenElement = document.createElement('video');
+
+ expect(fullscreen.active).toBe(false);
+
+ fullscreen.release();
+ expect(doc.exitFullscreen).not.toHaveBeenCalled();
+ });
+
+ it('passes a refusal back to the caller', async () => {
+ const doc = fakeDocument();
+ const element = document.createElement('div');
+ element.requestFullscreen = vi.fn(() => Promise.reject(new Error('denied')));
+ const fullscreen = new ElementFullscreen(() => element, doc as never);
+
+ await expect(fullscreen.toggle()).rejects.toThrow('denied');
+ expect(fullscreen.active).toBe(false);
+ });
+
+ it('releases full screen when the viewer goes away', async () => {
+ const doc = fakeDocument();
+ const element = fakeElement(doc);
+ const fullscreen = new ElementFullscreen(() => element, doc as never);
+ await fullscreen.toggle();
+
+ fullscreen.release();
+
+ expect(doc.exitFullscreen).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/app/common/pdf-viewer/element-fullscreen.ts b/src/app/common/pdf-viewer/element-fullscreen.ts
new file mode 100644
index 0000000000..cb8754371f
--- /dev/null
+++ b/src/app/common/pdf-viewer/element-fullscreen.ts
@@ -0,0 +1,39 @@
+/**
+ * The browser's full screen for one element, in the shape the PDF viewer needs:
+ * whether it can go full screen here, whether it is, and a toggle.
+ *
+ * Kept apart from the viewer so it can be tested without loading the PDF library.
+ */
+export class ElementFullscreen {
+ constructor(
+ private readonly element: () => HTMLElement | undefined,
+ private readonly doc: Document = document,
+ ) {}
+
+ /** Phones such as the iPhone only let video go full screen. */
+ public get supported(): boolean {
+ return !!this.doc.fullscreenEnabled;
+ }
+
+ public get active(): boolean {
+ const element = this.element();
+ return !!element && this.doc.fullscreenElement === element;
+ }
+
+ /** Resolves once the browser has switched, and rejects when it refuses. */
+ public toggle(): Promise {
+ if (this.active) {
+ return this.doc.exitFullscreen();
+ }
+
+ const element = this.element();
+ return element ? element.requestFullscreen() : Promise.resolve();
+ }
+
+ /** Leave full screen if this element holds it, for when the viewer goes away. */
+ public release(): void {
+ if (this.active) {
+ this.doc.exitFullscreen().catch(() => undefined);
+ }
+ }
+}
diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.reviewed.spec.ts b/src/app/common/pdf-viewer/pdf-viewer.component.reviewed.spec.ts
new file mode 100644
index 0000000000..1891fc0546
--- /dev/null
+++ b/src/app/common/pdf-viewer/pdf-viewer.component.reviewed.spec.ts
@@ -0,0 +1,69 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {FormsModule} from '@angular/forms';
+import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service';
+import {AlertService} from 'src/app/common/services/alert.service';
+import {SafePipe} from '../pipes/safe.pipe';
+import {fPdfViewerComponent} from './pdf-viewer.component';
+
+vi.mock('ng2-pdf-viewer', () => ({
+ PdfViewerComponent: class {},
+ PDFDocumentProxy: class {},
+}));
+
+describe('fPdfViewerComponent', () => {
+ let component: fPdfViewerComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ localStorage.clear();
+ await TestBed.configureTestingModule({
+ declarations: [fPdfViewerComponent, SafePipe],
+ imports: [FormsModule],
+ providers: [
+ {provide: FileDownloaderService, useValue: {releaseBlob: () => {}}},
+ {provide: AlertService, useValue: {error: () => {}}},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(fPdfViewerComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('labels the two zoom buttons with different accessible names instead of both announcing "Zoom in PDF button"', () => {
+ const buttons = fixture.nativeElement.querySelectorAll('.pdf-viewer__zoom button');
+ expect(buttons.length).toBe(2);
+
+ const zoomOutButton = buttons[0] as HTMLButtonElement;
+ const zoomInButton = buttons[1] as HTMLButtonElement;
+
+ expect(zoomOutButton.getAttribute('aria-label')).toBe('Zoom out PDF');
+ expect(zoomInButton.getAttribute('aria-label')).toBe('Zoom in PDF');
+ expect(zoomOutButton.getAttribute('aria-label')).not.toBe(
+ zoomInButton.getAttribute('aria-label'),
+ );
+ });
+
+ it('gives the search field a mat-label and the loading spinner an accessible name, instead of leaving them unnamed', () => {
+ const search = fixture.nativeElement.querySelector('input[type="search"]');
+ expect(search.getAttribute('aria-label')).toBe('Search this PDF');
+ component.loadState = 'loading';
+ fixture.detectChanges();
+
+ const spinner = fixture.nativeElement.querySelector('mat-spinner');
+ expect(spinner.getAttribute('aria-label')).toBe('Loading PDF');
+ });
+
+ it('titles the embedded PDF object so the native viewer is not an untitled embed', () => {
+ component.pdfBlobUrl = 'blob:http://localhost/fake-pdf';
+ component.viewerMode = 'browser';
+ component.loadState = 'ready';
+ fixture.detectChanges();
+
+ const object = fixture.nativeElement.querySelector('object');
+ expect(object.getAttribute('title')).toBe('Submission PDF');
+ });
+});
diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.scss b/src/app/common/pdf-viewer/pdf-viewer.component.scss
index b5787040ba..31f0598618 100644
--- a/src/app/common/pdf-viewer/pdf-viewer.component.scss
+++ b/src/app/common/pdf-viewer/pdf-viewer.component.scss
@@ -1,83 +1,239 @@
-@use 'styles/mixins/scrollable' as *;
+:host {
+ display: block;
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ width: 100%;
+}
-#pdfContainer {
+// The panel that holds the viewer already draws the frame, so the viewer does not
+// add a second border and radius inside it.
+.pdf-viewer {
+ background: var(--ot-color-surface);
+ color: var(--ot-color-text);
+ box-sizing: border-box;
+ display: flex;
flex-direction: column;
height: 100%;
- min-height: 100%;
- min-width: 100%;
+ min-height: 18rem;
+ min-width: 0;
+ overflow: hidden;
width: 100%;
- box-sizing: border-box;
+}
+
+.pdf-viewer__toolbar {
+ border-bottom: 1px solid var(--ot-color-divider);
display: flex;
- place-content: center;
- align-items: center;
+ flex: 0 0 auto;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.375rem 0.5rem 0.5rem 0.75rem;
}
-#actionBar {
+.pdf-viewer__title-row {
+ align-items: center;
display: flex;
- flex-direction: row;
- justify-content: space-between;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.pdf-viewer__file-heading {
align-items: center;
- width: 100%;
+ color: var(--ot-color-text);
+ display: flex;
+ flex: 1 1 auto;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ gap: 0.5rem;
+ min-width: 0;
+
+ mat-icon {
+ color: var(--ot-color-text-muted);
+ flex: none;
+ }
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
}
-object {
- height: 100%;
- min-width: 100%;
- width: 100%;
+.pdf-viewer__actions,
+.pdf-viewer__zoom {
+ align-items: center;
+ display: flex;
+ flex: none;
+ gap: 0.125rem;
}
-#pdfViewActions {
- padding-top: 1rem;
- padding-left: 1rem;
- margin-right: auto;
+// Round Material icon buttons, the same as every other panel's actions.
+.pdf-viewer__icon-button {
+ --mat-icon-button-state-layer-size: 40px;
+ color: var(--ot-color-text-muted);
+ padding: 8px;
+}
- button {
- margin-bottom: 28px;
- }
+.pdf-viewer__document-actions {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ min-width: 0;
}
-#pdfActions {
- padding-top: 1rem;
- padding-right: 1rem;
- margin-left: auto;
+// The search pill used across the app (the inbox and the task list): a rounded field
+// on the surface with a muted icon, and one focus ring round the whole pill.
+.pdf-viewer__search {
+ align-items: center;
+ background: var(--ot-color-surface);
+ border: 1px solid var(--ot-color-border);
+ border-radius: 999px;
+ box-sizing: border-box;
+ display: flex;
+ flex: 1 1 12rem;
+ gap: 0.5rem;
+ height: 2.5rem;
+ min-width: 0;
+ padding-inline: 0.75rem;
- ::ng-deep .mdc-notched-outline__leading {
- min-width: 28px !important;
- border-top-left-radius: 28px !important;
- border-bottom-left-radius: 28px !important;
+ &:focus-within {
+ outline: 2px solid var(--ot-color-focus);
+ outline-offset: 1px;
}
- ::ng-deep .mdc-notched-outline__notch {
- border-radius: 28px 0 0 28px !important;
+
+ mat-icon {
+ color: var(--ot-color-text-muted);
+ flex: none;
}
- ::ng-deep .mdc-notched-outline__trailing {
- border-radius: 0 28px 28px 0 !important;
+
+ input {
+ background: transparent;
+ border: 0;
+ color: var(--ot-color-text);
+ flex: 1 1 auto;
+ font: inherit;
+ font-size: 0.9375rem;
+ min-width: 0;
+ outline: 0;
+ width: 100%;
+
+ &::placeholder {
+ color: var(--ot-color-text-muted);
+ opacity: 1;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ }
}
- ::ng-deep .mat-form-field-outline-end {
- border-radius: 0 28px 28px 0 !important;
+}
+
+.pdf-viewer__zoom output {
+ color: var(--ot-color-text-muted);
+ font-size: 0.875rem;
+ font-variant-numeric: tabular-nums;
+ min-width: 2.75rem;
+ text-align: center;
+}
+
+.pdf-viewer__content {
+ background: var(--ot-color-page);
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ min-width: 0;
+ overflow: auto;
+ position: relative;
+}
+
+// The document fills exactly the space under the toolbar. With its own 18rem minimum
+// it overflowed a shorter space, and its scroll box and scrollbar were cut off inside
+// the content's scroll box, so the PDF could not be scrolled to its end. The viewer
+// as a whole keeps the minimum instead.
+.pdf-viewer__document,
+.pdf-viewer__native-document {
+ border: 0;
+ display: block;
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ width: 100%;
+}
+
+.pdf-viewer__document--loading {
+ opacity: 0;
+}
+
+pdf-viewer {
+ ::ng-deep .ng2-pdf-viewer-container {
+ overflow: auto;
}
- ::ng-deep .mat-mdc-form-field-flex {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
+}
+
+.pdf-viewer__state {
+ align-items: center;
+ color: var(--ot-color-text);
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0.625rem;
+ justify-content: center;
+ min-height: 16rem;
+ padding: 1.25rem;
+ text-align: center;
+
+ h3,
+ p {
+ margin: 0;
+ max-width: 34rem;
+ overflow-wrap: anywhere;
}
- button {
- margin-left: -12px;
+ > mat-icon {
+ font-size: 2.5rem;
+ height: 2.5rem;
+ width: 2.5rem;
}
}
-pdf-viewer {
- width: 100%;
- height: 100%;
+.pdf-viewer__state--overlay {
+ background: var(--ot-color-surface);
+ inset: 0;
+ position: absolute;
+ z-index: 2;
+}
- .ng2-pdf-viewer-container {
- overflow-x: hidden;
- overflow-y: visible;
- }
+.pdf-viewer__state-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: center;
+}
+
+.sr-only {
+ clip: rect(0, 0, 0, 0);
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
}
-.ng2-pdf-viewer-container {
- overflow-x: hidden;
- overflow-y: visible;
+// The browser presents only this element in full screen. Keep its page colour
+// and constrain the document to the theme's comfortable reading width.
+#pdfContainer:fullscreen {
+ background: var(--ot-color-page);
+ color: var(--ot-color-text);
+ border-radius: 0;
+
+ .pdf-viewer__content {
+ justify-content: center;
+ }
+
+ pdf-viewer,
+ object {
+ min-width: 0;
+ max-width: 64rem;
+ }
}
diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.theme.spec.ts b/src/app/common/pdf-viewer/pdf-viewer.component.theme.spec.ts
new file mode 100644
index 0000000000..8ba6656d13
--- /dev/null
+++ b/src/app/common/pdf-viewer/pdf-viewer.component.theme.spec.ts
@@ -0,0 +1,115 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {FormsModule} from '@angular/forms';
+import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service';
+import {AlertService} from 'src/app/common/services/alert.service';
+import {SafePipe} from '../pipes/safe.pipe';
+import {fPdfViewerComponent} from './pdf-viewer.component';
+
+vi.mock('ng2-pdf-viewer', () => ({
+ PdfViewerComponent: class {},
+ PDFDocumentProxy: class {},
+}));
+
+describe('fPdfViewerComponent theme full screen', () => {
+ let component: fPdfViewerComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ localStorage.clear();
+ await TestBed.configureTestingModule({
+ declarations: [fPdfViewerComponent, SafePipe],
+ imports: [FormsModule],
+ providers: [
+ {provide: FileDownloaderService, useValue: {releaseBlob: () => {}}},
+ {provide: AlertService, useValue: {error: () => {}}},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(fPdfViewerComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ describe('full screen', () => {
+ function setDocumentProperty(name: string, value: unknown): void {
+ Object.defineProperty(document, name, {configurable: true, value});
+ }
+
+ function fullscreenButton(): HTMLButtonElement | null {
+ return fixture.nativeElement.querySelector('button[aria-label="Full screen"]');
+ }
+
+ function container(): HTMLElement {
+ return fixture.nativeElement.querySelector('#pdfContainer');
+ }
+
+ // jsdom has no Fullscreen API. The button is decided when the viewer is made,
+ // so these tests make their own viewer after stubbing it.
+ function renderWithFullscreen(): void {
+ setDocumentProperty('fullscreenEnabled', true);
+ setDocumentProperty('fullscreenElement', null);
+ fixture.destroy();
+ fixture = TestBed.createComponent(fPdfViewerComponent);
+ component = fixture.componentInstance;
+ component.pdfBlobUrl = 'blob:http://localhost/fake-pdf';
+ fixture.detectChanges();
+ }
+
+ afterEach(() => {
+ for (const name of ['fullscreenEnabled', 'fullscreenElement', 'exitFullscreen']) {
+ delete (document as unknown as Record)[name];
+ }
+ });
+
+ it('has no full-screen button where the browser cannot go full screen', () => {
+ component.pdfBlobUrl = 'blob:http://localhost/fake-pdf';
+ fixture.detectChanges();
+
+ expect(fullscreenButton()).toBeNull();
+ });
+
+ it('goes full screen from the button and follows the browser when Esc leaves it', () => {
+ renderWithFullscreen();
+ const requestFullscreen = vi.fn(() => {
+ setDocumentProperty('fullscreenElement', container());
+ document.dispatchEvent(new Event('fullscreenchange'));
+ return Promise.resolve();
+ });
+ container().requestFullscreen = requestFullscreen;
+ const resize = vi.fn();
+ window.addEventListener('resize', resize);
+
+ fullscreenButton().click();
+ fixture.detectChanges();
+
+ expect(requestFullscreen).toHaveBeenCalledOnce();
+ expect(fullscreenButton().getAttribute('aria-pressed')).toBe('true');
+ // The pages are sized on window resize, so the viewer asks for one.
+ expect(resize).toHaveBeenCalled();
+
+ // Esc is handled by the browser, which only reports the change.
+ setDocumentProperty('fullscreenElement', null);
+ document.dispatchEvent(new Event('fullscreenchange'));
+ fixture.detectChanges();
+
+ expect(fullscreenButton().getAttribute('aria-pressed')).toBe('false');
+ window.removeEventListener('resize', resize);
+ });
+
+ it('leaves full screen from the same button', () => {
+ renderWithFullscreen();
+ setDocumentProperty('fullscreenElement', container());
+ document.dispatchEvent(new Event('fullscreenchange'));
+ fixture.detectChanges();
+ const exitFullscreen = vi.fn(() => Promise.resolve());
+ setDocumentProperty('exitFullscreen', exitFullscreen);
+
+ fullscreenButton().click();
+
+ expect(exitFullscreen).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/src/app/common/pdf-viewer/pdf-viewer.component.ts b/src/app/common/pdf-viewer/pdf-viewer.component.ts
index a8a2de519f..d502452d1b 100644
--- a/src/app/common/pdf-viewer/pdf-viewer.component.ts
+++ b/src/app/common/pdf-viewer/pdf-viewer.component.ts
@@ -1,9 +1,11 @@
-import {PDFDocumentProxy, PdfViewerComponent} from 'ng2-pdf-viewer';
+import {PdfViewerComponent} from 'ng2-pdf-viewer';
+import type {PDFDocumentProxy} from 'ng2-pdf-viewer';
import {HttpResponse} from '@angular/common/http';
import {
- AfterViewInit,
ChangeDetectionStrategy,
Component,
+ ElementRef,
+ HostListener,
Inject,
Input,
OnChanges,
@@ -13,6 +15,10 @@ import {
} from '@angular/core';
import {FileDownloaderService} from '../file-downloader/file-downloader.service';
import {AlertService} from '../services/alert.service';
+import {ElementFullscreen} from './element-fullscreen';
+
+export type PdfLoadState = 'empty' | 'loading' | 'ready' | 'error';
+export type PdfViewerMode = 'ontrack' | 'browser';
@Component({
selector: 'f-pdf-viewer',
@@ -21,70 +27,67 @@ import {AlertService} from '../services/alert.service';
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
})
-export class fPdfViewerComponent implements OnDestroy, OnChanges, AfterViewInit {
+export class fPdfViewerComponent implements OnDestroy, OnChanges {
private readonly ZOOM_MIN = 0.5;
private readonly ZOOM_MAX = 2.5;
-
private _pdfUrl: string;
- public pdfBlobUrl: string;
- public useNativePdfViewer = false;
- public pdfTotalPages?: number | undefined;
- public pdfHasRendered: boolean = false;
+ private ownsPdfBlobUrl = false;
+ private loadGeneration = 0;
@Input() pdfUrl: string;
- @Input() startPage: number = 1;
+ @Input() filename = 'PDF attachment.pdf';
+ @Input() startPage = 1;
- public pageNumber: number = 1;
+ @ViewChild(PdfViewerComponent) private pdfComponent?: PdfViewerComponent;
+ @ViewChild('pdfContainer', {static: true}) private container: ElementRef;
- @ViewChild(PdfViewerComponent) private pdfComponent: PdfViewerComponent;
- pdfSearchString: string;
- zoomValue = 1;
- loaded = false;
+ private readonly fullscreen = new ElementFullscreen(() => this.container?.nativeElement);
+ public readonly canFullscreen = this.fullscreen.supported;
+ public isFullscreen = false;
+
+ public pdfBlobUrl: string;
+ public pdfTotalPages?: number;
+ public pdfHasRendered = false;
+ public pageNumber = 1;
+ public pdfSearchString = '';
+ public zoomValue = 1;
+ public loadState: PdfLoadState = 'empty';
+ public errorMessage = '';
+ public previewEnabled = true;
+ public viewerMode: PdfViewerMode = 'ontrack';
constructor(
@Inject(FileDownloaderService) private fileDownloader: FileDownloaderService,
private alerts: AlertService,
- ) {}
-
- ngOnDestroy(): void {
- if (this.pdfBlobUrl) {
- this.fileDownloader.releaseBlob(this.pdfBlobUrl);
- this.pdfBlobUrl = null;
+ ) {
+ this.previewEnabled = localStorage.getItem('pdfPreviewEnabled') !== 'false';
+ this.viewerMode =
+ localStorage.getItem('pdfViewerMode') === 'browser' ||
+ localStorage.getItem('useNativePdfViewer') === 'true'
+ ? 'browser'
+ : 'ontrack';
+ const storedZoomValue = Number.parseFloat(localStorage.getItem('pdfViewerZoom') ?? '');
+ if (Number.isFinite(storedZoomValue)) {
+ this.zoomValue = Math.min(Math.max(storedZoomValue, this.ZOOM_MIN), this.ZOOM_MAX);
}
}
- ngAfterViewInit(): void {
- this.useNativePdfViewer = localStorage.getItem('useNativePdfViewer') === 'true';
- const storedZoomValue = parseFloat(localStorage.getItem('pdfViewerZoom')) || 1;
- // Clamp zoom value between ZOOM_MIN and ZOOM_MAX
- this.zoomValue = Math.min(Math.max(storedZoomValue, this.ZOOM_MIN), this.ZOOM_MAX);
- }
-
ngOnChanges(changes: SimpleChanges): void {
- this.pdfUrlChanges(changes.pdfUrl.currentValue);
+ if (changes.pdfUrl) {
+ this.loadPdf(changes.pdfUrl.currentValue);
+ }
}
- pdfUrlChanges(value: string): void {
- if (this._pdfUrl !== value) {
- // Free the memory used by the old PDF blob
- if (this.pdfBlobUrl) {
- this.fileDownloader.releaseBlob(this.pdfBlobUrl);
- this.pdfBlobUrl = null;
- }
-
- // Get the new blob
- this._pdfUrl = value;
- this.loaded = false;
- this.pdfHasRendered = false;
- if (value?.startsWith('blob:')) {
- this.pdfBlobUrl = value;
- } else {
- this.downloadBlob(value);
- }
- }
+ ngOnDestroy(): void {
+ this.fullscreen.release();
+ this.loadGeneration += 1;
+ this.releaseOwnedBlob();
}
searchPdf(stringToSearch: string): void {
+ if (!this.pdfComponent?.eventBus || this.viewerMode !== 'ontrack') {
+ return;
+ }
this.pdfComponent.eventBus.dispatch('find', {
query: stringToSearch,
type: 'again',
@@ -95,68 +98,188 @@ export class fPdfViewerComponent implements OnDestroy, OnChanges, AfterViewInit
});
}
- scrollToPage(pageNumber: number) {
- if (pageNumber <= this.pdfComponent.pdfViewer.pagesCount) {
- this.pdfComponent.pdfViewer.scrollPageIntoView({
- pageNumber,
- });
+ scrollToPage(pageNumber: number): void {
+ const pagesCount = this.pdfComponent?.pdfViewer?.pagesCount ?? 0;
+ if (pageNumber > 0 && pageNumber <= pagesCount) {
+ this.pdfComponent.pdfViewer.scrollPageIntoView({pageNumber});
}
}
- public zoomIn() {
+ public zoomIn(): void {
if (this.zoomValue < this.ZOOM_MAX) {
- this.zoomValue += 0.1;
- localStorage.setItem('pdfViewerZoom', this.zoomValue.toString());
+ this.setZoom(this.zoomValue + 0.1);
}
}
- public zoomOut() {
+
+ public zoomOut(): void {
if (this.zoomValue > this.ZOOM_MIN) {
- this.zoomValue -= 0.1;
- localStorage.setItem('pdfViewerZoom', this.zoomValue.toString());
+ this.setZoom(this.zoomValue - 0.1);
}
}
- public downloadPdf() {
- this.fileDownloader.downloadBlobToFile(this.pdfBlobUrl, 'displayed-pdf.pdf');
+ get canZoomIn(): boolean {
+ return (
+ this.viewerMode === 'ontrack' && this.loadState === 'ready' && this.zoomValue < this.ZOOM_MAX
+ );
}
- public toggleNativePdfViewer() {
- this.useNativePdfViewer = !this.useNativePdfViewer;
- localStorage.setItem('useNativePdfViewer', this.useNativePdfViewer.toString());
+ get canZoomOut(): boolean {
+ return (
+ this.viewerMode === 'ontrack' && this.loadState === 'ready' && this.zoomValue > this.ZOOM_MIN
+ );
}
- private downloadBlob(downloadUrl: string): void {
- this.fileDownloader.downloadBlob(
- downloadUrl,
- (url: string, _response: HttpResponse) => {
- this.pdfBlobUrl = url;
- },
- (error: unknown) => {
- this.alerts.error(`Error downloading PDF. ${error}`, 6000);
- },
- );
+ get zoomPercent(): string {
+ return `${Math.round(this.zoomValue * 100)}%`;
+ }
+
+ get displayFilename(): string {
+ return this.filename?.trim() || 'PDF attachment.pdf';
+ }
+
+ get hasDownloadSource(): boolean {
+ return Boolean(this.pdfBlobUrl || this._pdfUrl);
+ }
+
+ public downloadPdf(): void {
+ if (this.pdfBlobUrl) {
+ this.fileDownloader.downloadBlobToFileWithFeedback(this.pdfBlobUrl, this.displayFilename);
+ } else if (this._pdfUrl) {
+ this.fileDownloader.downloadFileWithFeedback(this._pdfUrl, this.displayFilename, {
+ requestKey: `pdf-viewer:${this._pdfUrl}`,
+ });
+ }
+ }
+
+ /**
+ * The browser's own full screen, for the task sheet or submission in this viewer.
+ * Esc leaves it too, and fullscreenchange keeps the button in step either way.
+ */
+ public toggleFullscreen(): void {
+ const entering = !this.fullscreen.active;
+ this.fullscreen.toggle().catch((error: unknown) => {
+ if (entering) {
+ this.alerts.error(`Could not open full screen. ${error}`, 6000);
+ }
+ });
}
- onLoaded(event: PDFDocumentProxy) {
- this.loaded = true;
+ @HostListener('document:fullscreenchange')
+ onFullscreenChange(): void {
+ this.isFullscreen = this.fullscreen.active;
+ // The pages are sized to the viewer's width when the window resizes, and the
+ // viewer has just changed size.
window.dispatchEvent(new Event('resize'));
+ }
+
+ public togglePreview(): void {
+ this.previewEnabled = !this.previewEnabled;
+ localStorage.setItem('pdfPreviewEnabled', this.previewEnabled.toString());
+ if (this.previewEnabled && this.pdfBlobUrl) {
+ this.loadState = this.viewerMode === 'browser' ? 'ready' : 'loading';
+ }
+ }
+
+ public toggleViewerMode(): void {
+ this.viewerMode = this.viewerMode === 'ontrack' ? 'browser' : 'ontrack';
+ localStorage.setItem('pdfViewerMode', this.viewerMode);
+ localStorage.removeItem('useNativePdfViewer');
+ if (this.pdfBlobUrl && this.previewEnabled) {
+ this.loadState = this.viewerMode === 'browser' ? 'ready' : 'loading';
+ }
+ }
+
+ public retry(): void {
+ this.loadPdf(this._pdfUrl, true);
+ }
+
+ onLoaded(event: PDFDocumentProxy): void {
this.pdfTotalPages = event.numPages;
+ if (event.numPages < 1) {
+ this.loadState = 'empty';
+ return;
+ }
+ this.loadState = 'ready';
+ this.errorMessage = '';
+ window.dispatchEvent(new Event('resize'));
}
- onTextLayerRendered() {
+ onPdfError(_error: unknown): void {
+ this.loadState = 'error';
+ this.errorMessage = `Preview could not open ${this.displayFilename}. You can retry or download the file.`;
+ }
+
+ onTextLayerRendered(): void {
if (this.pdfHasRendered) {
return;
}
this.pdfHasRendered = true;
- setTimeout(() => {
- if (
- this.startPage &&
- this.startPage > 1 &&
- this.pdfTotalPages &&
- this.startPage <= this.pdfTotalPages
- ) {
- this.pageNumber = Number(this.startPage);
+ if (this.startPage > 1 && this.pdfTotalPages && this.startPage <= this.pdfTotalPages) {
+ this.pageNumber = Number(this.startPage);
+ }
+ }
+
+ private loadPdf(value: string, force = false): void {
+ if (!force && this._pdfUrl === value) {
+ return;
+ }
+
+ const generation = ++this.loadGeneration;
+ this.releaseOwnedBlob();
+ this._pdfUrl = value;
+ this.pdfBlobUrl = null;
+ this.pdfTotalPages = undefined;
+ this.pdfHasRendered = false;
+ this.pageNumber = 1;
+ this.errorMessage = '';
+
+ if (!value?.trim()) {
+ this.loadState = 'empty';
+ return;
+ }
+
+ this.loadState = 'loading';
+ if (value.startsWith('blob:')) {
+ this.pdfBlobUrl = value;
+ this.ownsPdfBlobUrl = false;
+ if (this.previewEnabled && this.viewerMode === 'browser') {
+ this.loadState = 'ready';
}
- });
+ return;
+ }
+
+ this.fileDownloader.downloadBlob(
+ value,
+ (url: string, _response: HttpResponse) => {
+ if (generation !== this.loadGeneration) {
+ this.fileDownloader.releaseBlob(url);
+ return;
+ }
+ this.pdfBlobUrl = url;
+ this.ownsPdfBlobUrl = true;
+ if (this.previewEnabled && this.viewerMode === 'browser') {
+ this.loadState = 'ready';
+ }
+ },
+ (_error: unknown) => {
+ if (generation !== this.loadGeneration) {
+ return;
+ }
+ this.loadState = 'error';
+ this.errorMessage = `Preview could not load ${this.displayFilename}. Check your connection and try again.`;
+ },
+ );
+ }
+
+ private setZoom(value: number): void {
+ this.zoomValue = Math.min(Math.max(Number(value.toFixed(1)), this.ZOOM_MIN), this.ZOOM_MAX);
+ localStorage.setItem('pdfViewerZoom', this.zoomValue.toString());
+ }
+
+ private releaseOwnedBlob(): void {
+ if (this.pdfBlobUrl && this.ownsPdfBlobUrl) {
+ this.fileDownloader.releaseBlob(this.pdfBlobUrl);
+ }
+ this.ownsPdfBlobUrl = false;
}
}
diff --git a/src/app/common/services/app-lifecycle.service.spec.ts b/src/app/common/services/app-lifecycle.service.spec.ts
new file mode 100644
index 0000000000..ae540ab9e9
--- /dev/null
+++ b/src/app/common/services/app-lifecycle.service.spec.ts
@@ -0,0 +1,127 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {NavigationStart, Router} from '@angular/router';
+import {Subject} from 'rxjs';
+import {AppLifecycleService} from './app-lifecycle.service';
+
+describe('AppLifecycleService', () => {
+ let service: AppLifecycleService;
+ let routerEvents: Subject;
+ let visibilityState: DocumentVisibilityState;
+ let originalVisibilityState: PropertyDescriptor | undefined;
+
+ beforeEach(() => {
+ visibilityState = 'visible';
+ originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => visibilityState,
+ });
+ routerEvents = new Subject();
+ service = new AppLifecycleService(document, {
+ events: routerEvents.asObservable(),
+ } as unknown as Router);
+ service.start();
+ });
+
+ afterEach(() => {
+ service.ngOnDestroy();
+ if (originalVisibilityState) {
+ Object.defineProperty(document, 'visibilityState', originalVisibilityState);
+ } else {
+ delete (document as Document & {visibilityState?: DocumentVisibilityState}).visibilityState;
+ }
+ });
+
+ function registeredPlayingMedia(): {media: HTMLMediaElement; pause: ReturnType} {
+ const pause = vi.fn();
+ const media = {paused: false, pause} as unknown as HTMLMediaElement;
+ service.registerMedia(media);
+ return {media, pause};
+ }
+
+ it('pauses registered media and publishes hidden state when the app is backgrounded', () => {
+ const {pause} = registeredPlayingMedia();
+ const pauseEvents: string[] = [];
+ service.mediaPauseSubject.subscribe((event) => pauseEvents.push(event.reason));
+
+ visibilityState = 'hidden';
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(service.stateSubject.value).toBe('hidden');
+ expect(pause).toHaveBeenCalledOnce();
+ expect(pauseEvents).toEqual(['app-hidden']);
+ });
+
+ it('pauses media on route change', () => {
+ const {pause} = registeredPlayingMedia();
+
+ routerEvents.next(new NavigationStart(1, '/projects/2/dashboard'));
+
+ expect(pause).toHaveBeenCalledOnce();
+ });
+
+ it('returns from a 30-second background interval without reload or media auto-resume', async () => {
+ vi.useFakeTimers();
+ const {pause} = registeredPlayingMedia();
+
+ visibilityState = 'hidden';
+ document.dispatchEvent(new Event('visibilitychange'));
+ await vi.advanceTimersByTimeAsync(30_000);
+ visibilityState = 'visible';
+ window.dispatchEvent(new Event('focus'));
+
+ expect(service.stateSubject.value).toBe('active');
+ expect(pause).toHaveBeenCalledOnce();
+ vi.useRealTimers();
+ });
+
+ it('ignores a window blur/focus round trip while the page stays visible', () => {
+ const {pause} = registeredPlayingMedia();
+ const pauseEvents: string[] = [];
+ service.mediaPauseSubject.subscribe((event) => pauseEvents.push(event.reason));
+
+ // A file picker, a microphone permission prompt or another window taking focus.
+ window.dispatchEvent(new Event('blur'));
+
+ expect(service.stateSubject.value).toBe('active');
+ expect(pause).not.toHaveBeenCalled();
+ expect(pauseEvents).toEqual([]);
+
+ window.dispatchEvent(new Event('focus'));
+
+ expect(service.stateSubject.value).toBe('active');
+ expect(pauseEvents).toEqual([]);
+ });
+
+ it('pauses media when the page is hidden for bfcache or unload', () => {
+ const {pause} = registeredPlayingMedia();
+
+ window.dispatchEvent(new Event('pagehide'));
+
+ expect(service.stateSubject.value).toBe('hidden');
+ expect(pause).toHaveBeenCalledOnce();
+ });
+
+ it('pauses media when the browser freezes the page', () => {
+ const {pause} = registeredPlayingMedia();
+ const pauseEvents: string[] = [];
+ service.mediaPauseSubject.subscribe((event) => pauseEvents.push(event.reason));
+
+ document.dispatchEvent(new Event('freeze'));
+
+ expect(service.stateSubject.value).toBe('hidden');
+ expect(pause).toHaveBeenCalledOnce();
+ expect(pauseEvents).toEqual(['app-hidden']);
+ });
+
+ it('stops listening once stopped', () => {
+ const {pause} = registeredPlayingMedia();
+ service.stop();
+
+ visibilityState = 'hidden';
+ document.dispatchEvent(new Event('visibilitychange'));
+ document.dispatchEvent(new Event('freeze'));
+
+ expect(pause).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/common/services/app-lifecycle.service.ts b/src/app/common/services/app-lifecycle.service.ts
new file mode 100644
index 0000000000..afb0d022ac
--- /dev/null
+++ b/src/app/common/services/app-lifecycle.service.ts
@@ -0,0 +1,116 @@
+import {DOCUMENT} from '@angular/common';
+import {Inject, Injectable, OnDestroy} from '@angular/core';
+import {NavigationStart, Router} from '@angular/router';
+import {BehaviorSubject, Subject, Subscription, filter} from 'rxjs';
+
+export type AppLifecycleState = 'active' | 'hidden';
+export type MediaPauseReason = 'app-hidden' | 'route-change';
+
+export interface MediaPauseEvent {
+ reason: MediaPauseReason;
+ occurredAt: number;
+}
+
+/**
+ * One app-wide lifecycle contract for mobile/PWA features. Foregrounding only
+ * publishes state; it deliberately does not navigate, reload, or resume media.
+ *
+ * Only real backgrounding counts as hidden: visibilitychange to hidden, pagehide
+ * and freeze. A window blur is not, because a file picker, a microphone
+ * permission prompt or a click into another window all blur a page the user is
+ * still looking at, and pausing there cut recordings off mid-take.
+ */
+@Injectable({providedIn: 'root'})
+export class AppLifecycleService implements OnDestroy {
+ readonly stateSubject: BehaviorSubject = new BehaviorSubject(
+ this.document.visibilityState === 'hidden' ? 'hidden' : 'active',
+ );
+ readonly mediaPauseSubject: Subject = new Subject();
+
+ private readonly registeredMedia: Set = new Set();
+ private routerSubscription?: Subscription;
+ private started = false;
+
+ private readonly visibilityChangeListener = () => {
+ if (this.document.visibilityState === 'hidden') {
+ this.markHiddenAndPause();
+ } else {
+ this.stateSubject.next('active');
+ }
+ };
+ private readonly pageHideListener = () => this.markHiddenAndPause();
+ private readonly pageShowListener = () => this.stateSubject.next('active');
+ private readonly freezeListener = () => this.markHiddenAndPause();
+ private readonly focusListener = () => {
+ if (this.document.visibilityState !== 'hidden') {
+ this.stateSubject.next('active');
+ }
+ };
+
+ constructor(
+ @Inject(DOCUMENT) private document: Document,
+ private router: Router,
+ ) {}
+
+ start(): void {
+ if (this.started) {
+ return;
+ }
+ this.started = true;
+
+ this.document.addEventListener('visibilitychange', this.visibilityChangeListener);
+ this.document.addEventListener('freeze', this.freezeListener);
+ window.addEventListener('pagehide', this.pageHideListener);
+ window.addEventListener('pageshow', this.pageShowListener);
+ window.addEventListener('focus', this.focusListener);
+ this.routerSubscription = this.router.events
+ .pipe(filter((event) => event instanceof NavigationStart))
+ .subscribe(() => this.pauseMedia('route-change'));
+ }
+
+ stop(): void {
+ if (!this.started) {
+ return;
+ }
+ this.started = false;
+
+ this.document.removeEventListener('visibilitychange', this.visibilityChangeListener);
+ this.document.removeEventListener('freeze', this.freezeListener);
+ window.removeEventListener('pagehide', this.pageHideListener);
+ window.removeEventListener('pageshow', this.pageShowListener);
+ window.removeEventListener('focus', this.focusListener);
+ this.routerSubscription?.unsubscribe();
+ this.routerSubscription = undefined;
+ }
+
+ registerMedia(media: HTMLMediaElement): () => void {
+ this.registeredMedia.add(media);
+ return () => this.registeredMedia.delete(media);
+ }
+
+ ngOnDestroy(): void {
+ this.stop();
+ this.registeredMedia.clear();
+ this.stateSubject.complete();
+ this.mediaPauseSubject.complete();
+ }
+
+ private markHiddenAndPause(): void {
+ this.stateSubject.next('hidden');
+ this.pauseMedia('app-hidden');
+ }
+
+ private pauseMedia(reason: MediaPauseReason): void {
+ const media: Set = new Set([
+ ...this.registeredMedia,
+ ...Array.from(this.document.querySelectorAll('audio, video')),
+ ]);
+
+ media.forEach((element) => {
+ if (!element.paused) {
+ element.pause();
+ }
+ });
+ this.mediaPauseSubject.next({reason, occurredAt: Date.now()});
+ }
+}
diff --git a/src/app/common/services/audio-playback-coordinator.service.spec.ts b/src/app/common/services/audio-playback-coordinator.service.spec.ts
new file mode 100644
index 0000000000..bf07b1cd99
--- /dev/null
+++ b/src/app/common/services/audio-playback-coordinator.service.spec.ts
@@ -0,0 +1,32 @@
+import {describe, expect, it, vi} from 'vitest';
+import {AudioPlaybackCoordinatorService} from './audio-playback-coordinator.service';
+
+describe('AudioPlaybackCoordinatorService', () => {
+ it('pauses and deactivates the previous audio item', () => {
+ const service = new AudioPlaybackCoordinatorService();
+ const firstInterrupted = vi.fn();
+ const first = {pause: vi.fn()} as unknown as HTMLAudioElement;
+ const second = {pause: vi.fn()} as unknown as HTMLAudioElement;
+
+ service.activate(first, firstInterrupted);
+ service.activate(second, vi.fn());
+
+ expect(first.pause).toHaveBeenCalledOnce();
+ expect(firstInterrupted).toHaveBeenCalledOnce();
+ expect(service.isActive(first)).toBe(false);
+ expect(service.isActive(second)).toBe(true);
+ });
+
+ it('releases only the currently active item', () => {
+ const service = new AudioPlaybackCoordinatorService();
+ const first = {pause: vi.fn()} as unknown as HTMLAudioElement;
+ const second = {pause: vi.fn()} as unknown as HTMLAudioElement;
+ service.activate(first, vi.fn());
+
+ service.release(second);
+ expect(service.isActive(first)).toBe(true);
+
+ service.release(first);
+ expect(service.isActive(first)).toBe(false);
+ });
+});
diff --git a/src/app/common/services/audio-playback-coordinator.service.ts b/src/app/common/services/audio-playback-coordinator.service.ts
new file mode 100644
index 0000000000..4ffaa53699
--- /dev/null
+++ b/src/app/common/services/audio-playback-coordinator.service.ts
@@ -0,0 +1,39 @@
+import {Injectable} from '@angular/core';
+
+interface ActivePlayback {
+ media: HTMLMediaElement;
+ onInterrupted: () => void;
+}
+
+/**
+ * Keeps feedback audio mutually exclusive without coupling individual comment
+ * components to their neighbours.
+ */
+@Injectable({providedIn: 'root'})
+export class AudioPlaybackCoordinatorService {
+ private activePlayback?: ActivePlayback;
+
+ activate(media: HTMLMediaElement, onInterrupted: () => void): void {
+ const previous = this.activePlayback;
+ if (previous?.media === media) {
+ previous.onInterrupted = onInterrupted;
+ return;
+ }
+
+ this.activePlayback = {media, onInterrupted};
+ if (previous) {
+ previous.media.pause();
+ previous.onInterrupted();
+ }
+ }
+
+ release(media: HTMLMediaElement): void {
+ if (this.activePlayback?.media === media) {
+ this.activePlayback = undefined;
+ }
+ }
+
+ isActive(media: HTMLMediaElement): boolean {
+ return this.activePlayback?.media === media;
+ }
+}
diff --git a/src/app/common/services/audio-waveform.service.spec.ts b/src/app/common/services/audio-waveform.service.spec.ts
new file mode 100644
index 0000000000..3c8a9a3d2a
--- /dev/null
+++ b/src/app/common/services/audio-waveform.service.spec.ts
@@ -0,0 +1,62 @@
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {AudioWaveformService} from './audio-waveform.service';
+
+describe('AudioWaveformService', () => {
+ const service = new AudioWaveformService();
+
+ afterEach(() => vi.restoreAllMocks());
+
+ it('calculates normalized peaks from real channel samples', () => {
+ const samples = new Float32Array([0, 0.25, -0.5, 1]);
+ const peaks = service.calculatePeaks(
+ {length: samples.length, numberOfChannels: 1, getChannelData: () => samples},
+ 2,
+ );
+
+ expect(peaks).toEqual([0.25, 1]);
+ });
+
+ it('keeps a valid very short clip finite across every bar', () => {
+ const samples = new Float32Array([0.4]);
+ const peaks = service.calculatePeaks(
+ {length: 1, numberOfChannels: 1, getChannelData: () => samples},
+ 8,
+ );
+
+ expect(peaks).toHaveLength(8);
+ expect(peaks.every((peak) => Number.isFinite(peak) && peak === 1)).toBe(true);
+ });
+
+ it('closes its decoding context when corrupt audio fails to decode', async () => {
+ const close = vi.fn(() => Promise.resolve());
+ const decodeAudioData = vi.fn(() =>
+ Promise.reject(new DOMException('bad audio', 'EncodingError')),
+ );
+ const originalAudioContext = window.AudioContext;
+ const originalFetch = globalThis.fetch;
+ Object.defineProperty(window, 'AudioContext', {
+ configurable: true,
+ value: class {
+ close = close;
+ decodeAudioData = decodeAudioData;
+ },
+ });
+ Object.defineProperty(globalThis, 'fetch', {
+ configurable: true,
+ value: vi.fn(() =>
+ Promise.resolve({ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(4))}),
+ ),
+ });
+
+ await expect(service.decodePeaks('blob:corrupt')).rejects.toMatchObject({
+ name: 'EncodingError',
+ });
+ expect(close).toHaveBeenCalledOnce();
+
+ Object.defineProperty(window, 'AudioContext', {
+ configurable: true,
+ value: originalAudioContext,
+ });
+ Object.defineProperty(globalThis, 'fetch', {configurable: true, value: originalFetch});
+ });
+});
diff --git a/src/app/common/services/audio-waveform.service.ts b/src/app/common/services/audio-waveform.service.ts
new file mode 100644
index 0000000000..51447dfadd
--- /dev/null
+++ b/src/app/common/services/audio-waveform.service.ts
@@ -0,0 +1,73 @@
+import {Injectable} from '@angular/core';
+
+const DEFAULT_WAVEFORM_BARS = 40;
+
+/** Decode real channel samples into normalized peaks for compact feedback waveforms. */
+@Injectable({providedIn: 'root'})
+export class AudioWaveformService {
+ async decodePeaks(
+ sourceUrl: string,
+ barCount: number = DEFAULT_WAVEFORM_BARS,
+ ): Promise {
+ const response = await fetch(sourceUrl);
+ if (!response.ok) {
+ throw new Error(`Audio download failed with ${response.status}`);
+ }
+
+ const encodedAudio = await response.arrayBuffer();
+ if (encodedAudio.byteLength === 0) {
+ throw new Error('The audio recording is empty.');
+ }
+
+ const AudioContextConstructor =
+ window.AudioContext ||
+ (window as typeof window & {webkitAudioContext?: typeof AudioContext}).webkitAudioContext;
+ if (!AudioContextConstructor) {
+ throw new Error('Audio decoding is unavailable in this browser.');
+ }
+
+ const context = new AudioContextConstructor();
+ try {
+ const decodedAudio = await context.decodeAudioData(encodedAudio.slice(0));
+ if (!Number.isFinite(decodedAudio.duration) || decodedAudio.duration <= 0) {
+ throw new Error('The audio recording has no playable duration.');
+ }
+ return this.calculatePeaks(decodedAudio, barCount);
+ } finally {
+ await context.close().catch(() => undefined);
+ }
+ }
+
+ calculatePeaks(
+ audio: Pick,
+ barCount: number,
+ ): number[] {
+ const safeBarCount = Math.max(1, Math.floor(barCount));
+ if (audio.length <= 0 || audio.numberOfChannels <= 0) {
+ return Array.from({length: safeBarCount}, () => 0);
+ }
+
+ const peaks = Array.from({length: safeBarCount}, (_, barIndex) => {
+ const start = Math.min(
+ audio.length - 1,
+ Math.floor((barIndex * audio.length) / safeBarCount),
+ );
+ const end = Math.min(
+ audio.length,
+ Math.max(start + 1, Math.floor(((barIndex + 1) * audio.length) / safeBarCount)),
+ );
+ let peak = 0;
+
+ for (let channel = 0; channel < audio.numberOfChannels; channel++) {
+ const samples = audio.getChannelData(channel);
+ for (let sampleIndex = start; sampleIndex < end; sampleIndex++) {
+ peak = Math.max(peak, Math.abs(samples[sampleIndex] || 0));
+ }
+ }
+ return peak;
+ });
+
+ const maximum = Math.max(...peaks);
+ return maximum > 0 ? peaks.map((peak) => peak / maximum) : peaks;
+ }
+}
diff --git a/src/app/common/services/feedback-draft-store.service.spec.ts b/src/app/common/services/feedback-draft-store.service.spec.ts
new file mode 100644
index 0000000000..3a1cfd32e4
--- /dev/null
+++ b/src/app/common/services/feedback-draft-store.service.spec.ts
@@ -0,0 +1,168 @@
+import {beforeEach, describe, expect, it} from 'vitest';
+import {
+ FeedbackDraftContext,
+ FeedbackDraftStore,
+ StagedFeedbackAttachment,
+} from './feedback-draft-store.service';
+
+function memoryStorage(): Storage {
+ const values: Map = new Map();
+ return {
+ get length() {
+ return values.size;
+ },
+ clear: () => values.clear(),
+ getItem: (key: string) => values.get(key) ?? null,
+ key: (index: number) => Array.from(values.keys())[index] ?? null,
+ removeItem: (key: string) => values.delete(key),
+ setItem: (key: string, value: string) => values.set(key, String(value)),
+ };
+}
+
+function context(
+ userId: number,
+ taskId: number,
+ taskDefinitionId = taskId + 100,
+): FeedbackDraftContext {
+ return {
+ userId,
+ unitId: 4,
+ projectId: 8,
+ taskDefinitionId,
+ taskId,
+ conversation: 'task-feedback',
+ };
+}
+
+function attachment(clientRequestId: string, fileName: string): StagedFeedbackAttachment {
+ const data = new File(['private bytes'], fileName, {type: 'application/pdf'});
+ return {
+ data,
+ fileName,
+ mimeType: data.type,
+ byteSize: data.size,
+ kind: 'file',
+ clientRequestId,
+ status: 'staged',
+ progress: 0,
+ };
+}
+
+describe('FeedbackDraftStore', () => {
+ beforeEach(() => {
+ Object.defineProperty(globalThis, 'sessionStorage', {
+ configurable: true,
+ value: memoryStorage(),
+ });
+ });
+
+ it('round-trips exact multiline text, reply context, and the stable text request id', () => {
+ const store = new FeedbackDraftStore();
+ const scope = context(7, 19);
+ const exactText = ' First line \n\nSecond line\t';
+
+ store.save(scope, exactText, 321, 'text-request-1');
+
+ expect(store.load(scope)).toMatchObject({
+ text: exactText,
+ replyToId: 321,
+ clientRequestId: 'text-request-1',
+ });
+ expect(store.load(scope).updatedAt).toBeGreaterThan(0);
+ });
+
+ it('keeps the text request id paired with the payload it was issued for', () => {
+ const store = new FeedbackDraftStore();
+ const scope = context(7, 19);
+
+ store.save(scope, 'first words', null, 'text-request-1', '[null,"first words"]');
+ store.save(scope, 'first words, then more', null);
+
+ expect(store.load(scope)).toMatchObject({
+ text: 'first words, then more',
+ clientRequestId: 'text-request-1',
+ clientRequestFingerprint: '[null,"first words"]',
+ });
+
+ store.save(scope, 'new words', null, 'text-request-2', '[null,"new words"]');
+
+ expect(store.load(scope)).toMatchObject({
+ clientRequestId: 'text-request-2',
+ clientRequestFingerprint: '[null,"new words"]',
+ });
+ });
+
+ it('isolates drafts by signed-in user and task conversation', () => {
+ const store = new FeedbackDraftStore();
+ const userOneTaskA = context(1, 10);
+ const userOneTaskB = context(1, 11);
+ const userTwoTaskA = context(2, 10);
+
+ store.save(userOneTaskA, 'private A', 12);
+
+ expect(store.key(userOneTaskA)).not.toBe(store.key(userOneTaskB));
+ expect(store.key(userOneTaskA)).not.toBe(store.key(userTwoTaskA));
+ expect(store.load(userOneTaskA).text).toBe('private A');
+ expect(store.load(userOneTaskB).text).toBe('');
+ expect(store.load(userTwoTaskA).text).toBe('');
+ });
+
+ it('keeps raw staged files in memory only and preserves independent items', () => {
+ const store = new FeedbackDraftStore();
+ const scope = context(4, 25);
+ const first = attachment('file-1', 'first.pdf');
+ const second = attachment('file-2', 'second.pdf');
+
+ store.stageAttachment(scope, first);
+ store.stageAttachment(scope, second);
+ store.save(scope, 'caption', null);
+
+ expect(store.attachments(scope)).toEqual([first, second]);
+ const persisted = sessionStorage.getItem(store.key(scope));
+ expect(persisted).not.toContain('first.pdf');
+ expect(persisted).not.toContain('second.pdf');
+ expect(persisted).not.toContain('private bytes');
+
+ const resumedProcess = new FeedbackDraftStore();
+ expect(resumedProcess.load(scope).text).toBe('caption');
+ expect(resumedProcess.attachments(scope)).toEqual([]);
+ });
+
+ it('updates and removes only the selected staged item', () => {
+ const store = new FeedbackDraftStore();
+ const scope = context(4, 25);
+ store.stageAttachment(scope, attachment('file-1', 'first.pdf'));
+ store.stageAttachment(scope, attachment('file-2', 'second.pdf'));
+
+ store.updateAttachment(scope, 'file-1', {
+ status: 'failed',
+ error: 'offline',
+ });
+ store.removeAttachment(scope, 'file-2');
+
+ expect(store.attachments(scope)).toHaveLength(1);
+ expect(store.attachments(scope)[0]).toMatchObject({
+ clientRequestId: 'file-1',
+ fileName: 'first.pdf',
+ status: 'failed',
+ error: 'offline',
+ });
+ });
+
+ it('clears only the requested account on sign out', () => {
+ const store = new FeedbackDraftStore();
+ const userOne = context(1, 10);
+ const userTwo = context(2, 10);
+ store.save(userOne, 'one', null);
+ store.save(userTwo, 'two', null);
+ store.stageAttachment(userOne, attachment('one-file', 'one.pdf'));
+ store.stageAttachment(userTwo, attachment('two-file', 'two.pdf'));
+
+ store.clearUser(1);
+
+ expect(store.load(userOne).text).toBe('');
+ expect(store.attachments(userOne)).toEqual([]);
+ expect(store.load(userTwo).text).toBe('two');
+ expect(store.attachments(userTwo)).toHaveLength(1);
+ });
+});
diff --git a/src/app/common/services/feedback-draft-store.service.ts b/src/app/common/services/feedback-draft-store.service.ts
new file mode 100644
index 0000000000..7d22749b41
--- /dev/null
+++ b/src/app/common/services/feedback-draft-store.service.ts
@@ -0,0 +1,190 @@
+import {Injectable} from '@angular/core';
+
+export interface FeedbackDraftContext {
+ userId: number;
+ unitId: number | null;
+ projectId: number;
+ taskDefinitionId: number;
+ taskId: number | null;
+ conversation: 'task-feedback';
+}
+
+export type StagedAttachmentStatus = 'staged' | 'uploading' | 'failed';
+
+export interface StagedFeedbackAttachment {
+ data: File | Blob;
+ fileName: string;
+ mimeType: string;
+ byteSize: number;
+ kind: 'file' | 'audio';
+ clientRequestId: string;
+ status: StagedAttachmentStatus;
+ progress: number;
+ error?: string;
+}
+
+export interface FeedbackDraftSnapshot {
+ text: string;
+ replyToId: number | null;
+ clientRequestId: string | null;
+ // The reply target and text the request id was issued for.
+ clientRequestFingerprint: string | null;
+ updatedAt: number;
+}
+
+/**
+ * Keeps feedback drafts isolated to one signed-in user and one conversation.
+ * Text and reply context survive a normal browser resume. Raw File/Blob objects
+ * stay memory-only so private attachments are never copied into browser storage.
+ */
+@Injectable({providedIn: 'root'})
+export class FeedbackDraftStore {
+ static readonly STORAGE_PREFIX = 'feedback_draft_v1';
+ private readonly volatileAttachments: Map = new Map();
+
+ key(context: FeedbackDraftContext): string {
+ return [
+ FeedbackDraftStore.STORAGE_PREFIX,
+ `uid${context.userId}`,
+ `unit${context.unitId ?? 0}`,
+ `project${context.projectId}`,
+ `definition${context.taskDefinitionId}`,
+ `task${context.taskId ?? 0}`,
+ context.conversation,
+ ].join('_');
+ }
+
+ load(context: FeedbackDraftContext): FeedbackDraftSnapshot {
+ const empty: FeedbackDraftSnapshot = {
+ text: '',
+ replyToId: null,
+ clientRequestId: null,
+ clientRequestFingerprint: null,
+ updatedAt: 0,
+ };
+ try {
+ const raw = sessionStorage.getItem(this.key(context));
+ if (!raw) {
+ return empty;
+ }
+ const parsed = JSON.parse(raw) as Partial;
+ return {
+ text: typeof parsed.text === 'string' ? parsed.text : '',
+ replyToId: typeof parsed.replyToId === 'number' ? parsed.replyToId : null,
+ clientRequestId: typeof parsed.clientRequestId === 'string' ? parsed.clientRequestId : null,
+ clientRequestFingerprint:
+ typeof parsed.clientRequestFingerprint === 'string'
+ ? parsed.clientRequestFingerprint
+ : null,
+ updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : 0,
+ };
+ } catch {
+ return empty;
+ }
+ }
+
+ save(
+ context: FeedbackDraftContext,
+ text: string,
+ replyToId: number | null,
+ clientRequestId: string | null = null,
+ clientRequestFingerprint: string | null = null,
+ ): void {
+ const key = this.key(context);
+ try {
+ if (
+ !this.hasMeaningfulText(text) &&
+ replyToId === null &&
+ !this.volatileAttachments.has(key)
+ ) {
+ sessionStorage.removeItem(key);
+ return;
+ }
+ const prior = this.load(context);
+ const snapshot: FeedbackDraftSnapshot = {
+ text,
+ replyToId,
+ clientRequestId: clientRequestId ?? prior.clientRequestId,
+ // Keep the id and its fingerprint together, whichever one is kept.
+ clientRequestFingerprint: clientRequestId
+ ? clientRequestFingerprint
+ : prior.clientRequestFingerprint,
+ updatedAt: Date.now(),
+ };
+ sessionStorage.setItem(key, JSON.stringify(snapshot));
+ } catch {
+ // Storage may be unavailable in private browsing; the composer remains usable.
+ }
+ }
+
+ attachments(context: FeedbackDraftContext): StagedFeedbackAttachment[] {
+ return this.volatileAttachments.get(this.key(context)) ?? [];
+ }
+
+ stageAttachment(context: FeedbackDraftContext, attachment: StagedFeedbackAttachment): void {
+ const key = this.key(context);
+ this.volatileAttachments.set(key, [...(this.volatileAttachments.get(key) ?? []), attachment]);
+ }
+
+ updateAttachment(
+ context: FeedbackDraftContext,
+ clientRequestId: string,
+ update: Partial,
+ ): void {
+ const key = this.key(context);
+ const attachments = this.volatileAttachments.get(key) ?? [];
+ this.volatileAttachments.set(
+ key,
+ attachments.map((attachment) =>
+ attachment.clientRequestId === clientRequestId ? {...attachment, ...update} : attachment,
+ ),
+ );
+ }
+
+ removeAttachment(context: FeedbackDraftContext, clientRequestId: string): void {
+ const key = this.key(context);
+ const remaining = (this.volatileAttachments.get(key) ?? []).filter(
+ (attachment) => attachment.clientRequestId !== clientRequestId,
+ );
+ if (remaining.length > 0) {
+ this.volatileAttachments.set(key, remaining);
+ } else {
+ this.volatileAttachments.delete(key);
+ }
+ }
+
+ clear(context: FeedbackDraftContext): void {
+ const key = this.key(context);
+ this.volatileAttachments.delete(key);
+ try {
+ sessionStorage.removeItem(key);
+ } catch {
+ // See save(): storage failure must not break feedback.
+ }
+ }
+
+ clearUser(userId: number): void {
+ const marker = `_uid${userId}_`;
+ for (const key of Array.from(this.volatileAttachments.keys())) {
+ if (key.includes(marker)) {
+ this.volatileAttachments.delete(key);
+ }
+ }
+ try {
+ const keys: string[] = [];
+ for (let index = 0; index < sessionStorage.length; index++) {
+ const key = sessionStorage.key(index);
+ if (key?.startsWith(FeedbackDraftStore.STORAGE_PREFIX) && key.includes(marker)) {
+ keys.push(key);
+ }
+ }
+ keys.forEach((key) => sessionStorage.removeItem(key));
+ } catch {
+ // Sign out continues even when storage is unavailable.
+ }
+ }
+
+ private hasMeaningfulText(text: string): boolean {
+ return text.replace(/\s+/g, '').length > 0;
+ }
+}
diff --git a/src/app/common/services/peer-progress-display-preference.service.spec.ts b/src/app/common/services/peer-progress-display-preference.service.spec.ts
new file mode 100644
index 0000000000..c49a3423c4
--- /dev/null
+++ b/src/app/common/services/peer-progress-display-preference.service.spec.ts
@@ -0,0 +1,64 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {PeerProgressDisplayPreferenceService} from './peer-progress-display-preference.service';
+
+describe('PeerProgressDisplayPreferenceService', () => {
+ let currentUser: {id: number} | null;
+ let service: PeerProgressDisplayPreferenceService;
+
+ beforeEach(() => {
+ const values: Map = new Map();
+ vi.stubGlobal('localStorage', {
+ get length() {
+ return values.size;
+ },
+ clear: () => values.clear(),
+ getItem: (key: string) => values.get(key) ?? null,
+ removeItem: (key: string) => values.delete(key),
+ setItem: (key: string, value: string) => values.set(key, value),
+ });
+ currentUser = {id: 41};
+ service = new PeerProgressDisplayPreferenceService({
+ get currentUser() {
+ return currentUser;
+ },
+ } as never);
+ });
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('persists Advanced under the authenticated user only', () => {
+ expect(service.enabled).toBe(false);
+
+ expect(service.setEnabled(true)).toBe(true);
+ expect(service.enabled).toBe(true);
+ expect(localStorage.getItem(PeerProgressDisplayPreferenceService.storageKeyFor(41))).toBe(
+ 'true',
+ );
+
+ currentUser = {id: 42};
+ expect(service.enabled).toBe(false);
+ expect(localStorage.getItem(PeerProgressDisplayPreferenceService.storageKeyFor(42))).toBeNull();
+ });
+
+ it('survives a new service instance until the same user turns it off', () => {
+ service.setEnabled(true);
+
+ const reopened = new PeerProgressDisplayPreferenceService({
+ get currentUser() {
+ return currentUser;
+ },
+ } as never);
+
+ expect(reopened.enabled).toBe(true);
+ expect(reopened.setEnabled(false)).toBe(false);
+ expect(service.enabled).toBe(false);
+ });
+
+ it('fails closed and writes nothing before authentication is available', () => {
+ currentUser = null;
+
+ expect(service.enabled).toBe(false);
+ expect(service.setEnabled(true)).toBe(false);
+ expect(localStorage.length).toBe(0);
+ });
+});
diff --git a/src/app/common/services/peer-progress-display-preference.service.ts b/src/app/common/services/peer-progress-display-preference.service.ts
new file mode 100644
index 0000000000..b97cb0abfa
--- /dev/null
+++ b/src/app/common/services/peer-progress-display-preference.service.ts
@@ -0,0 +1,69 @@
+import {Injectable} from '@angular/core';
+import {UserService} from 'src/app/api/services/user.service';
+
+const STORAGE_PREFIX = 'ontrack.user';
+const STORAGE_SUFFIX = 'peerProgress.advanced';
+
+/**
+ * Stores only the PPI presentation preference, never peer-progress data.
+ *
+ * The key is scoped to the authenticated user so a shared browser cannot show
+ * one person's choice to another account. Local storage is intentionally used
+ * here because Advanced is a display-only choice that must survive navigation,
+ * a normal app resume and a later browser session.
+ */
+@Injectable({providedIn: 'root'})
+export class PeerProgressDisplayPreferenceService {
+ private readonly memoryFallback: Map = new Map();
+
+ constructor(private readonly userService: UserService) {}
+
+ get enabled(): boolean {
+ const userId = this.currentUserId;
+ if (userId === null) {
+ return false;
+ }
+
+ try {
+ const stored = globalThis.localStorage?.getItem(this.storageKey(userId));
+ if (stored !== null) {
+ return stored === 'true';
+ }
+ } catch {
+ // Hardened/private browsers may reject local storage. The per-user
+ // in-memory fallback still preserves navigation within this session.
+ }
+
+ return this.memoryFallback.get(userId) ?? false;
+ }
+
+ setEnabled(enabled: boolean): boolean {
+ const userId = this.currentUserId;
+ if (userId === null) {
+ return false;
+ }
+
+ this.memoryFallback.set(userId, enabled);
+
+ try {
+ globalThis.localStorage?.setItem(this.storageKey(userId), String(enabled));
+ } catch {
+ // Keep the per-user in-memory value when durable storage is unavailable.
+ }
+
+ return enabled;
+ }
+
+ static storageKeyFor(userId: number): string {
+ return `${STORAGE_PREFIX}.${userId}.${STORAGE_SUFFIX}`;
+ }
+
+ private storageKey(userId: number): string {
+ return PeerProgressDisplayPreferenceService.storageKeyFor(userId);
+ }
+
+ private get currentUserId(): number | null {
+ const id = this.userService.currentUser?.id;
+ return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null;
+ }
+}
diff --git a/src/app/common/services/recorder-service.spec.ts b/src/app/common/services/recorder-service.spec.ts
new file mode 100644
index 0000000000..ad62c827fc
--- /dev/null
+++ b/src/app/common/services/recorder-service.spec.ts
@@ -0,0 +1,148 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {MediaRecorderService} from './recorder-service';
+
+interface FakeAudioContextRecord {
+ close: ReturnType;
+}
+
+describe('MediaRecorderService', () => {
+ let originalAudioContext: PropertyDescriptor | undefined;
+ let originalMediaRecorder: PropertyDescriptor | undefined;
+ let originalMediaDevices: PropertyDescriptor | undefined;
+ let contexts: FakeAudioContextRecord[];
+
+ beforeEach(() => {
+ originalAudioContext = Object.getOwnPropertyDescriptor(window, 'AudioContext');
+ originalMediaRecorder = Object.getOwnPropertyDescriptor(window, 'MediaRecorder');
+ originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, 'mediaDevices');
+ contexts = [];
+
+ class FakeAudioContext {
+ currentTime = 0;
+ sampleRate = 44_100;
+ destination = fakeNode();
+ close = vi.fn(() => Promise.resolve());
+ createGain = vi.fn(() => ({...fakeNode(), gain: {setValueAtTime: vi.fn()}}));
+ createAnalyser = vi.fn(() => ({
+ ...fakeNode(),
+ fftSize: 2048,
+ getByteTimeDomainData: vi.fn(),
+ }));
+ createDynamicsCompressor = vi.fn(() => fakeNode());
+ createScriptProcessor = vi.fn(() => ({...fakeNode(), onaudioprocess: null}));
+ createMediaStreamDestination = vi.fn(() => ({...fakeNode(), stream: {}}));
+ createMediaStreamSource = vi.fn(() => ({...fakeNode(), context: this}));
+
+ constructor() {
+ contexts.push(this);
+ }
+ }
+
+ class FakeMediaRecorder extends EventTarget {
+ state: RecordingState = 'inactive';
+ constructor(_stream: MediaStream, _options: MediaRecorderOptions) {
+ super();
+ }
+ start(): void {
+ this.state = 'recording';
+ }
+ stop(): void {
+ this.state = 'inactive';
+ const event = new Event('dataavailable');
+ Object.defineProperty(event, 'data', {
+ value: new Blob(['x'], {type: 'audio/webm'}),
+ });
+ this.dispatchEvent(event);
+ }
+ requestData(): void {}
+ }
+
+ Object.defineProperty(window, 'AudioContext', {configurable: true, value: FakeAudioContext});
+ Object.defineProperty(window, 'MediaRecorder', {configurable: true, value: FakeMediaRecorder});
+ });
+
+ afterEach(() => {
+ restoreProperty(window, 'AudioContext', originalAudioContext);
+ restoreProperty(window, 'MediaRecorder', originalMediaRecorder);
+ restoreProperty(navigator, 'mediaDevices', originalMediaDevices);
+ });
+
+ it('reports permission denial and closes the graph it created before the prompt', async () => {
+ const permissionError = new DOMException('denied', 'NotAllowedError');
+ setGetUserMedia(vi.fn(() => Promise.reject(permissionError)));
+ const service = new MediaRecorderService();
+ const failures: unknown[] = [];
+ service.em.addEventListener('error', (event) =>
+ failures.push((event as CustomEvent<{error: unknown}>).detail.error),
+ );
+
+ await expect(service.startRecording()).rejects.toBe(permissionError);
+
+ expect(service.state).toBe('inactive');
+ expect(failures).toEqual([permissionError]);
+ expect(contexts[0].close).toHaveBeenCalledOnce();
+ expect(service.audioCtx).toBeNull();
+ expect(service.analyserNode).toBeNull();
+ });
+
+ it('stops a late microphone stream after cancellation during the permission prompt', async () => {
+ let resolvePermission!: (stream: MediaStream) => void;
+ const permission: Promise = new Promise(
+ (resolve) => (resolvePermission = resolve),
+ );
+ setGetUserMedia(vi.fn(() => permission));
+ const service = new MediaRecorderService();
+ const start = service.startRecording();
+ const stopTrack = vi.fn();
+
+ service.cancelRecording();
+ resolvePermission({getTracks: () => [{stop: stopTrack}]} as unknown as MediaStream);
+ await start;
+
+ expect(stopTrack).toHaveBeenCalledOnce();
+ expect(service.state).toBe('inactive');
+ expect(contexts[0].close).toHaveBeenCalledOnce();
+ });
+
+ it('emits a short recording and releases its track and audio context on stop', async () => {
+ const stopTrack = vi.fn();
+ setGetUserMedia(
+ vi.fn(() =>
+ Promise.resolve({getTracks: () => [{stop: stopTrack}]} as unknown as MediaStream),
+ ),
+ );
+ const service = new MediaRecorderService();
+ const recordings: Blob[] = [];
+ service.em.addEventListener('recording', (event) =>
+ recordings.push((event as CustomEvent<{recording: {blob: Blob}}>).detail.recording.blob),
+ );
+
+ await service.startRecording();
+ service.stopRecording();
+
+ expect(recordings).toHaveLength(1);
+ expect(recordings[0].size).toBe(1);
+ expect(service.state).toBe('inactive');
+ expect(stopTrack).toHaveBeenCalledOnce();
+ expect(contexts[0].close).toHaveBeenCalledOnce();
+ });
+});
+
+function fakeNode() {
+ return {connect: vi.fn(), disconnect: vi.fn()};
+}
+
+function setGetUserMedia(getUserMedia: ReturnType): void {
+ Object.defineProperty(navigator, 'mediaDevices', {
+ configurable: true,
+ value: {getUserMedia},
+ });
+}
+
+function restoreProperty(target: object, property: string, descriptor?: PropertyDescriptor): void {
+ if (descriptor) {
+ Object.defineProperty(target, property, descriptor);
+ } else {
+ delete (target as Record)[property];
+ }
+}
diff --git a/src/app/common/services/recorder-service.ts b/src/app/common/services/recorder-service.ts
index 2c50415efc..07563adc16 100644
--- a/src/app/common/services/recorder-service.ts
+++ b/src/app/common/services/recorder-service.ts
@@ -1,6 +1,6 @@
import {Injectable} from '@angular/core';
-type RecorderState = 'inactive' | 'recording';
+export type RecorderState = 'inactive' | 'requesting' | 'recording' | 'stopping';
interface RecorderConfig {
broadcastAudioProcessEvents: boolean;
@@ -15,54 +15,47 @@ interface RecorderConfig {
audioBitsPerSecond: number;
}
+export interface RecorderFailureEvent extends Event {
+ detail: {error: unknown};
+}
+
@Injectable()
export class MediaRecorderService {
- em: DocumentFragment;
- state: RecorderState;
- audioCtx: AudioContext | null;
- chunks: BlobPart[];
- chunkType: string | null;
+ readonly em: DocumentFragment = document.createDocumentFragment();
+ state: RecorderState = 'inactive';
+ audioCtx: AudioContext | null = null;
+ chunks: BlobPart[] = [];
+ chunkType: string | null = null;
usingMediaRecorder: boolean;
encoderMimeType?: string;
config: RecorderConfig;
- micGainNode: GainNode | null;
- outputGainNode: GainNode | null;
- dynamicsCompressorNode: DynamicsCompressorNode | null;
- analyserNode: AnalyserNode | null;
- processorNode: ScriptProcessorNode | null;
- destinationNode: MediaStreamAudioDestinationNode | AudioDestinationNode | null;
- encoderWorker: Worker | null;
- micAudioStream: MediaStream | null;
- inputStreamNode: MediaStreamAudioSourceNode | null;
- mediaRecorder: MediaRecorder | null;
+ micGainNode: GainNode | null = null;
+ outputGainNode: GainNode | null = null;
+ dynamicsCompressorNode: DynamicsCompressorNode | null = null;
+ analyserNode: AnalyserNode | null = null;
+ processorNode: ScriptProcessorNode | null = null;
+ destinationNode: MediaStreamAudioDestinationNode | AudioDestinationNode | null = null;
+ encoderWorker: Worker | null = null;
+ micAudioStream: MediaStream | null = null;
+ inputStreamNode: MediaStreamAudioSourceNode | null = null;
+ mediaRecorder: MediaRecorder | null = null;
onGraphSetupWithInputStream?: (inputStream: MediaStreamAudioSourceNode) => void;
- constructor() {
- const audioWindow = window as typeof window & {
- webkitAudioContext?: typeof AudioContext;
- };
- audioWindow.AudioContext = audioWindow.AudioContext || audioWindow.webkitAudioContext;
-
- this.em = document.createDocumentFragment();
- this.state = 'inactive';
- this.audioCtx = null;
- this.chunks = [];
- this.chunkType = '';
-
- this.micGainNode = null;
- this.outputGainNode = null;
- this.dynamicsCompressorNode = null;
- this.analyserNode = null;
- this.processorNode = null;
- this.destinationNode = null;
- this.encoderWorker = null;
- this.micAudioStream = null;
- this.inputStreamNode = null;
- this.mediaRecorder = null;
+ private requestGeneration = 0;
+ private readonly mediaRecorderDataListener = (event: BlobEvent) => this.onDataAvailable(event);
+ private readonly mediaRecorderErrorListener = (event: Event) => this.onError(event);
+ private readonly workerMessageListener = (event: MessageEvent) => {
+ const dataEvent = new Event('dataavailable') as Event & {data: Blob};
+ dataEvent.data =
+ this.config.manualEncoderId === 'ogg'
+ ? (event.data as Blob)
+ : new Blob(event.data as BlobPart[], {type: this.encoderMimeType});
+ this.onDataAvailable(dataEvent);
+ };
+ constructor() {
this.usingMediaRecorder = Boolean(window.MediaRecorder);
-
if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
this.usingMediaRecorder = false;
}
@@ -76,74 +69,41 @@ export class MediaRecorderService {
micGain: 1.0,
processorBufferSize: 2048,
stopTracksAndCloseCtxWhenFinished: true,
- userMediaConstraints: {
- audio: true,
- },
+ userMediaConstraints: {audio: true},
audioBitsPerSecond: 128000,
};
}
- startRecording(): Promise | void {
+ async startRecording(): Promise {
if (this.state !== 'inactive') {
return;
}
-
if (!navigator?.mediaDevices?.getUserMedia) {
- console.error('Missing support for navigator.mediaDevices.getUserMedia');
- return;
+ const error = new Error('Audio recording is unavailable in this browser.');
+ this.dispatchError(error);
+ throw error;
}
- this.audioCtx = new AudioContext();
- this.micGainNode = this.audioCtx.createGain();
- this.outputGainNode = this.audioCtx.createGain();
-
- if (this.config.createDynamicsCompressorNode) {
- this.dynamicsCompressorNode = this.audioCtx.createDynamicsCompressor();
- }
-
- if (this.config.createAnalyserNode) {
- this.analyserNode = this.audioCtx.createAnalyser();
- }
+ this.cleanupGraph();
+ this.state = 'requesting';
+ const requestGeneration = ++this.requestGeneration;
- if (
- this.config.forceScriptProcessor ||
- this.config.broadcastAudioProcessEvents ||
- !this.usingMediaRecorder
- ) {
- this.processorNode = this.audioCtx.createScriptProcessor(
- this.config.processorBufferSize,
- 1,
- 1,
- );
- }
-
- if (this.audioCtx.createMediaStreamDestination) {
- this.destinationNode = this.audioCtx.createMediaStreamDestination();
- } else {
- this.destinationNode = this.audioCtx.destination;
- }
-
- if (!this.usingMediaRecorder) {
- this.encoderWorker = new Worker('/assets/wav-worker.js');
- this.encoderMimeType = 'audio/wav';
-
- this.encoderWorker.addEventListener('message', (e: MessageEvent) => {
- const event = new Event('dataavailable') as Event & {data: Blob};
- if (this.config.manualEncoderId === 'ogg') {
- event.data = e.data as Blob;
- } else {
- event.data = new Blob(e.data as BlobPart[], {type: this.encoderMimeType});
- }
- this._onDataAvailable(event);
- });
+ try {
+ this.setupAudioGraph();
+ const stream = await navigator.mediaDevices.getUserMedia(this.config.userMediaConstraints);
+ if (requestGeneration !== this.requestGeneration || this.state !== 'requesting') {
+ stream.getTracks().forEach((track) => track.stop());
+ return;
+ }
+ this.startRecordingWithStream(stream);
+ } catch (error) {
+ if (requestGeneration === this.requestGeneration) {
+ this.state = 'inactive';
+ this.dispatchError(error);
+ this.cleanupGraph();
+ }
+ throw error;
}
-
- return navigator.mediaDevices
- .getUserMedia(this.config.userMediaConstraints)
- .then((stream) => {
- this._startRecordingWithStream(stream);
- })
- .catch(() => undefined);
}
setMicGain(newGain: number): void {
@@ -154,35 +114,105 @@ export class MediaRecorderService {
}
processChunks(): void {
- if (this.state === 'inactive') {
+ if (this.state !== 'recording') {
return;
}
- this._dumpChunks();
+ if (this.usingMediaRecorder) {
+ this.mediaRecorder?.requestData();
+ } else {
+ this.encoderWorker?.postMessage(['dump', this.audioCtx?.sampleRate]);
+ }
}
stopRecording(): void {
- if (this.state === 'inactive') {
+ if (this.state === 'requesting') {
+ this.cancelRecording();
+ return;
+ }
+ if (this.state !== 'recording') {
return;
}
- this.state = 'inactive';
+ this.state = 'stopping';
if (this.usingMediaRecorder) {
- this.mediaRecorder?.stop();
- return;
+ try {
+ this.mediaRecorder?.stop();
+ } catch (error) {
+ this.onError(error as Event);
+ }
+ } else {
+ this.encoderWorker?.postMessage(['dump', this.audioCtx?.sampleRate]);
}
+ }
+
+ /** Cancel without producing a recording; safe during permission prompts and teardown. */
+ cancelRecording(): void {
+ this.requestGeneration++;
+ this.state = 'inactive';
+ if (this.mediaRecorder?.state === 'recording') {
+ this.mediaRecorder.removeEventListener('dataavailable', this.mediaRecorderDataListener);
+ try {
+ this.mediaRecorder.stop();
+ } catch {
+ // The graph cleanup below is the authoritative cancellation path.
+ }
+ }
+ this.chunks = [];
+ this.cleanupGraph();
+ }
- this.encoderWorker?.postMessage(['dump', this.audioCtx?.sampleRate]);
+ dispose(): void {
+ this.cancelRecording();
}
- private _startRecordingWithStream(stream: MediaStream): void {
+ private setupAudioGraph(): void {
+ const AudioContextConstructor =
+ window.AudioContext ||
+ (window as typeof window & {webkitAudioContext?: typeof AudioContext}).webkitAudioContext;
+ if (!AudioContextConstructor) {
+ throw new Error('Web Audio is unavailable in this browser.');
+ }
+
+ this.audioCtx = new AudioContextConstructor();
+ this.micGainNode = this.audioCtx.createGain();
+ this.outputGainNode = this.audioCtx.createGain();
+ this.dynamicsCompressorNode = this.config.createDynamicsCompressorNode
+ ? this.audioCtx.createDynamicsCompressor()
+ : null;
+ this.analyserNode = this.config.createAnalyserNode ? this.audioCtx.createAnalyser() : null;
+
+ if (
+ this.config.forceScriptProcessor ||
+ this.config.broadcastAudioProcessEvents ||
+ !this.usingMediaRecorder
+ ) {
+ this.processorNode = this.audioCtx.createScriptProcessor(
+ this.config.processorBufferSize,
+ 1,
+ 1,
+ );
+ }
+
+ this.destinationNode = this.audioCtx.createMediaStreamDestination
+ ? this.audioCtx.createMediaStreamDestination()
+ : this.audioCtx.destination;
+
+ if (!this.usingMediaRecorder) {
+ this.encoderWorker = new Worker('/assets/wav-worker.js');
+ this.encoderMimeType = 'audio/wav';
+ this.encoderWorker.addEventListener('message', this.workerMessageListener);
+ }
+ }
+
+ private startRecordingWithStream(stream: MediaStream): void {
if (!this.audioCtx || !this.micGainNode || !this.outputGainNode || !this.destinationNode) {
- return;
+ stream.getTracks().forEach((track) => track.stop());
+ throw new Error('The audio recording graph could not be created.');
}
this.micAudioStream = stream;
- this.inputStreamNode = this.audioCtx.createMediaStreamSource(this.micAudioStream);
+ this.inputStreamNode = this.audioCtx.createMediaStreamSource(stream);
this.audioCtx = this.inputStreamNode.context as AudioContext;
-
this.onGraphSetupWithInputStream?.(this.inputStreamNode);
this.inputStreamNode.connect(this.micGainNode);
@@ -193,132 +223,128 @@ export class MediaRecorderService {
this.micGainNode.connect(this.dynamicsCompressorNode);
nextNode = this.dynamicsCompressorNode;
}
-
- this.state = 'recording';
-
if (this.processorNode) {
nextNode.connect(this.processorNode);
this.processorNode.connect(this.outputGainNode);
- this.processorNode.onaudioprocess = (e: AudioProcessingEvent) => this._onAudioProcess(e);
+ this.processorNode.onaudioprocess = (event: AudioProcessingEvent) =>
+ this.onAudioProcess(event);
} else {
nextNode.connect(this.outputGainNode);
}
-
if (this.analyserNode) {
nextNode.connect(this.analyserNode);
}
-
this.outputGainNode.connect(this.destinationNode);
+ this.chunks = [];
+ this.state = 'recording';
if (this.usingMediaRecorder) {
- const streamDestination = this.destinationNode as MediaStreamAudioDestinationNode;
- this.mediaRecorder = new MediaRecorder(streamDestination.stream, {
+ const destination = this.destinationNode as MediaStreamAudioDestinationNode;
+ this.mediaRecorder = new MediaRecorder(destination.stream, {
audioBitsPerSecond: this.config.audioBitsPerSecond,
});
- this.mediaRecorder.addEventListener('dataavailable', (evt) => this._onDataAvailable(evt));
- this.mediaRecorder.addEventListener('error', (evt) => this._onError(evt));
+ this.mediaRecorder.addEventListener('dataavailable', this.mediaRecorderDataListener);
+ this.mediaRecorder.addEventListener('error', this.mediaRecorderErrorListener);
this.mediaRecorder.start();
} else {
this.outputGainNode.gain.setValueAtTime(0, this.audioCtx.currentTime);
}
}
- private _onAudioProcess(e: AudioProcessingEvent): void {
+ private onAudioProcess(event: AudioProcessingEvent): void {
if (this.config.broadcastAudioProcessEvents) {
this.em.dispatchEvent(
new CustomEvent('onaudioprocess', {
- detail: {
- inputBuffer: e.inputBuffer,
- outputBuffer: e.outputBuffer,
- },
+ detail: {inputBuffer: event.inputBuffer, outputBuffer: event.outputBuffer},
}),
);
}
-
if (!this.usingMediaRecorder && this.state === 'recording' && this.encoderWorker) {
- if (this.config.broadcastAudioProcessEvents) {
- this.encoderWorker.postMessage(['encode', e.outputBuffer.getChannelData(0)]);
- } else {
- this.encoderWorker.postMessage(['encode', e.inputBuffer.getChannelData(0)]);
- }
+ const samples = this.config.broadcastAudioProcessEvents
+ ? event.outputBuffer.getChannelData(0)
+ : event.inputBuffer.getChannelData(0);
+ this.encoderWorker.postMessage(['encode', samples]);
}
}
- private _dumpChunks(): void {
- if (this.usingMediaRecorder) {
- this.mediaRecorder?.requestData();
+ private onDataAvailable(event: BlobEvent | (Event & {data: Blob})): void {
+ if (event.data?.size > 0) {
+ this.chunks.push(event.data);
+ this.chunkType = event.data.type;
+ }
+
+ // requestData() may emit while recording; only a stop produces the final clip.
+ if (this.state === 'recording') {
return;
}
- this.encoderWorker?.postMessage(['dump', this.audioCtx?.sampleRate]);
+ const blob = new Blob(this.chunks, {type: this.chunkType || event.data?.type || ''});
+ this.state = 'inactive';
+ this.em.dispatchEvent(
+ new CustomEvent('recording', {
+ detail: {
+ recording: {
+ ts: Date.now(),
+ mimeType: blob.type,
+ size: blob.size,
+ blob,
+ },
+ },
+ }),
+ );
+ this.chunks = [];
+ this.cleanupGraph();
}
- private _onDataAvailable(evt: BlobEvent | (Event & {data: Blob})): void {
- this.chunks.push(evt.data);
- this.chunkType = evt.data.type;
-
- const blob = new Blob(this.chunks, {type: this.chunkType});
- const blobUrl = URL.createObjectURL(blob);
- const recording = {
- ts: new Date().getTime(),
- blobUrl,
- mimeType: blob.type,
- size: blob.size,
- blob,
- };
-
- this.em.dispatchEvent(new CustomEvent('recording', {detail: {recording}}));
-
+ private onError(event: Event): void {
+ const error = (event as Event & {error?: unknown}).error || event;
+ this.state = 'inactive';
+ this.dispatchError(error);
this.chunks = [];
+ this.cleanupGraph();
+ }
- if (this.state !== 'inactive') {
- return;
- }
-
- this._cleanup();
+ private dispatchError(error: unknown): void {
+ this.em.dispatchEvent(new CustomEvent('error', {detail: {error}}));
}
- private _cleanup(): void {
- this.chunkType = null;
+ private cleanupGraph(): void {
+ this.mediaRecorder?.removeEventListener('dataavailable', this.mediaRecorderDataListener);
+ this.mediaRecorder?.removeEventListener('error', this.mediaRecorderErrorListener);
+ this.mediaRecorder = null;
this.destinationNode?.disconnect();
this.destinationNode = null;
-
this.outputGainNode?.disconnect();
this.outputGainNode = null;
-
this.analyserNode?.disconnect();
this.analyserNode = null;
-
- this.processorNode?.disconnect();
+ if (this.processorNode) {
+ this.processorNode.onaudioprocess = null;
+ this.processorNode.disconnect();
+ }
this.processorNode = null;
if (this.encoderWorker) {
+ this.encoderWorker.removeEventListener('message', this.workerMessageListener);
this.encoderWorker.postMessage(['close']);
+ this.encoderWorker.terminate();
this.encoderWorker = null;
}
this.dynamicsCompressorNode?.disconnect();
this.dynamicsCompressorNode = null;
-
this.micGainNode?.disconnect();
this.micGainNode = null;
-
this.inputStreamNode?.disconnect();
this.inputStreamNode = null;
if (this.config.stopTracksAndCloseCtxWhenFinished) {
this.micAudioStream?.getTracks().forEach((track) => track.stop());
this.micAudioStream = null;
-
- this.audioCtx?.close();
+ void this.audioCtx?.close().catch(() => undefined);
this.audioCtx = null;
}
-
- this.mediaRecorder = null;
- }
-
- private _onError(_evt: Event): void {
- this.em.dispatchEvent(new Event('error'));
+ this.chunkType = null;
}
}
diff --git a/src/app/common/status-icon/status-icon.component.scss b/src/app/common/status-icon/status-icon.component.scss
index 86633746ce..2df1b9b5d5 100644
--- a/src/app/common/status-icon/status-icon.component.scss
+++ b/src/app/common/status-icon/status-icon.component.scss
@@ -1,13 +1,29 @@
-@use 'styles/mixins/task-status-colors-generator' as *;
-
:host {
font-size: 1em;
display: inline-block;
}
@mixin status-icon($status) {
- background-color: task-status-color($status);
- color: task-status-color($status, fore);
+ // Read the runtime status tokens so the chip flips with data-ot-theme. The
+ // token names match the $status keys ('ready-for-feedback' etc.), and each
+ // -on foreground is white, which every fill clears at 4.5:1 by the contract.
+ background-color: var(--ot-status-#{$status});
+ color: var(--ot-status-#{$status}-on);
+}
+
+// The glyph size lives here rather than in important utilities, so a host that
+// deliberately shows a bigger glyph (the task status card header) can still override it.
+.status-chip mat-icon {
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
+ line-height: 1;
+}
+
+.status-chip.status-chip--large mat-icon {
+ width: 20px;
+ height: 20px;
+ font-size: 20px;
}
.status-chip {
diff --git a/src/app/common/status-icon/status-icon.component.ts b/src/app/common/status-icon/status-icon.component.ts
index 7a9667694a..63789a447a 100644
--- a/src/app/common/status-icon/status-icon.component.ts
+++ b/src/app/common/status-icon/status-icon.component.ts
@@ -11,7 +11,9 @@ import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status';
export class StatusIconComponent implements OnInit {
@Input() status?: TaskStatusEnum = 'not_started';
@Input() showTooltip: boolean;
- @Input() compact = false;
+ // 'large' is the 44px circle for roomier rows such as the cross-unit dashboard;
+ // dense lists keep the default 36px.
+ @Input() size: 'default' | 'large' = 'default';
ngOnInit(): void {
if (this.showTooltip == null) {
diff --git a/src/app/common/task-badge/task-badge.component.css b/src/app/common/task-badge/task-badge.component.css
index 01f2399341..0cff0051d3 100644
--- a/src/app/common/task-badge/task-badge.component.css
+++ b/src/app/common/task-badge/task-badge.component.css
@@ -1,15 +1,19 @@
:host {
display: flex;
position: relative;
+ /* The tile shows the whole code, so it must not be squeezed by the title beside it. */
+ flex-shrink: 0;
}
+/* Theme tokens, so the tile flips with the theme instead of staying light grey. */
.task-badge {
- color: #111827;
+ background-color: var(--ot-color-divider);
+ color: var(--ot-color-text);
}
.task-badge.task-badge-highlight {
- background-color: var(--mat-theme-primary, #3939ff) !important;
- color: #ffffff;
+ background-color: var(--ot-color-primary) !important;
+ color: var(--ot-color-on-primary);
}
.mat-icon {
diff --git a/src/app/common/task-badge/task-badge.component.html b/src/app/common/task-badge/task-badge.component.html
index c0801d02c6..b242bc75dc 100644
--- a/src/app/common/task-badge/task-badge.component.html
+++ b/src/app/common/task-badge/task-badge.component.html
@@ -1,8 +1,13 @@
+
-
+
4 ? 'text-xs' : 'text-sm'"
+ >
{{ abbreviation }}
diff --git a/src/app/common/task-badge/task-badge.component.ts b/src/app/common/task-badge/task-badge.component.ts
index 9868e4fb80..05a62bdea7 100644
--- a/src/app/common/task-badge/task-badge.component.ts
+++ b/src/app/common/task-badge/task-badge.component.ts
@@ -16,8 +16,7 @@ export class FTaskBadgeComponent {
lineHeight = 12;
get abbreviation(): string {
- // return the first 3 characters of the task abbreviation
- return this.taskDef?.abbreviation.substring(0, 4);
+ return this.taskDef?.abbreviation ?? '';
}
calculateFontSize(length: number): string {
diff --git a/src/app/common/theme/theme-color.service.ts b/src/app/common/theme/theme-color.service.ts
new file mode 100644
index 0000000000..6b4804eeae
--- /dev/null
+++ b/src/app/common/theme/theme-color.service.ts
@@ -0,0 +1,51 @@
+import {Injectable, Signal, inject} from '@angular/core';
+import {ResolvedTheme, ThemeService} from './theme.service';
+
+/**
+ * Resolves --ot-* theme tokens to concrete colour values for contexts that cannot
+ * consume a CSS variable directly: ngx-charts fill/scheme inputs, canvas, and any
+ * JavaScript that needs to read or compute on a colour (e.g. a contrast calc).
+ *
+ * SCSS and template [style] bindings should keep using var(--ot-*) directly — those
+ * flip on their own. Use this only where a resolved string is required, and pair it
+ * with the `resolved` signal so a chart re-reads its colours when the theme flips.
+ */
+@Injectable({providedIn: 'root'})
+export class ThemeColorService {
+ private readonly theme = inject(ThemeService);
+
+ /** The resolved-theme signal ('light' | 'dark'). Read it inside an effect() so a
+ * chart re-renders when the user changes theme while it is on screen. */
+ readonly resolved: Signal = this.theme.resolved;
+
+ /** Computed value of a CSS custom property on :root, e.g. token('--ot-chart-1'). */
+ token(name: string, fallback = '#64748b'): string {
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') {
+ return fallback;
+ }
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return value || fallback;
+ }
+
+ /** Resolved fill for a task status. Accepts snake_case ('working_on_it') or the
+ * hyphenated token key; the token layer uses hyphens. */
+ statusColor(status: string, fallback = '#64748b'): string {
+ return this.token(`--ot-status-${String(status).replace(/_/g, '-')}`, fallback);
+ }
+
+ /** Resolved status colour for a mark drawn on the page, such as a chart slice. It
+ * clears 3:1 on the surface in both themes, which the darker fills do not in dark. */
+ statusGraphicColor(status: string, fallback = '#64748b'): string {
+ return this.token(`--ot-status-${String(status).replace(/_/g, '-')}-graphic`, fallback);
+ }
+
+ /** Resolved on-colour (label/foreground) paired with a status, fixed for AA. */
+ statusOnColor(status: string, fallback = '#ffffff'): string {
+ return this.token(`--ot-status-${String(status).replace(/_/g, '-')}-on`, fallback);
+ }
+
+ /** Resolved categorical chart colour 1..6. */
+ chart(index: number, fallback = '#64748b'): string {
+ return this.token(`--ot-chart-${index}`, fallback);
+ }
+}
diff --git a/src/app/common/user-badge/user-badge.component.scss b/src/app/common/user-badge/user-badge.component.scss
index d4762075c1..1a595e5c26 100644
--- a/src/app/common/user-badge/user-badge.component.scss
+++ b/src/app/common/user-badge/user-badge.component.scss
@@ -12,7 +12,7 @@ a h4 {
}
a {
- color: black;
+ color: var(--ot-color-text);
}
a h4,
@@ -22,7 +22,7 @@ a p {
}
a p {
- color: color.adjust(#000000, $lightness: 15%);
+ color: var(--ot-color-text-muted);
font-size: 12px;
}
diff --git a/src/app/demo/demo-scenario-registry.service.spec.ts b/src/app/demo/demo-scenario-registry.service.spec.ts
new file mode 100644
index 0000000000..7ffdccd58e
--- /dev/null
+++ b/src/app/demo/demo-scenario-registry.service.spec.ts
@@ -0,0 +1,94 @@
+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 API_URL from 'src/app/config/constants/apiUrl';
+import {DEMO_TOOLS_AVAILABLE, DemoModeStore} from './demo-mode.store';
+import {DemoScenarioContract, DemoScenarioRegistryService} from './demo-scenario-registry.service';
+
+describe('DemoScenarioRegistryService', () => {
+ let http: HttpTestingController;
+ let service: DemoScenarioRegistryService;
+ let demoMode: {
+ configureScenario: ReturnType;
+ clearScenario: ReturnType;
+ };
+
+ beforeEach(() => {
+ demoMode = {configureScenario: vi.fn(), clearScenario: vi.fn()};
+ TestBed.configureTestingModule({
+ providers: [
+ DemoScenarioRegistryService,
+ {provide: DemoModeStore, useValue: demoMode},
+ {provide: DEMO_TOOLS_AVAILABLE, useValue: true},
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ ],
+ });
+ http = TestBed.inject(HttpTestingController);
+ service = TestBed.inject(DemoScenarioRegistryService);
+ });
+
+ afterEach(() => http.verify());
+
+ it('enables availability only after the guarded contract succeeds', () => {
+ const contract = {
+ schema_version: 1,
+ scenario_id: 'mobile-feedback-v1',
+ demo_only: true,
+ } as DemoScenarioContract;
+
+ service.loadForAuthenticatedUser(42).subscribe();
+ http.expectOne(`${API_URL}/demo/scenario`).flush(contract);
+
+ expect(service.scenario).toBe(contract);
+ expect(demoMode.configureScenario).toHaveBeenCalledWith('mobile-feedback-v1', 42);
+ });
+
+ it('does not republish a response after the session registry is cleared', () => {
+ const next = vi.fn();
+ service.loadForAuthenticatedUser(42).subscribe(next);
+ const request = http.expectOne(`${API_URL}/demo/scenario`);
+ service.clear();
+ request.flush({scenario_id: 'obsolete'});
+
+ expect(service.scenario).toBeNull();
+ expect(demoMode.configureScenario).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('rejects a response when authentication says its session is obsolete', () => {
+ let currentSession = true;
+ service.loadForAuthenticatedUser(42, () => currentSession).subscribe();
+ const request = http.expectOne(`${API_URL}/demo/scenario`);
+ currentSession = false;
+ request.flush({scenario_id: 'obsolete'});
+
+ expect(service.scenario).toBeNull();
+ expect(demoMode.configureScenario).not.toHaveBeenCalled();
+ });
+
+ it('does not let an older failure clear the latest account scenario', () => {
+ service.loadForAuthenticatedUser(42).subscribe();
+ const oldRequest = http.expectOne(`${API_URL}/demo/scenario`);
+ service.loadForAuthenticatedUser(43).subscribe();
+ const newRequest = http.expectOne(`${API_URL}/demo/scenario`);
+ const contract = {scenario_id: 'new-account'} as DemoScenarioContract;
+ newRequest.flush(contract);
+ oldRequest.flush({error: 'Not found.'}, {status: 404, statusText: 'Not Found'});
+
+ expect(service.scenario).toBe(contract);
+ expect(demoMode.configureScenario).toHaveBeenCalledWith('new-account', 43);
+ expect(demoMode.clearScenario).not.toHaveBeenCalled();
+ });
+
+ it('fails closed on an ordinary runtime 404', () => {
+ service.loadForAuthenticatedUser(42).subscribe();
+ http
+ .expectOne(`${API_URL}/demo/scenario`)
+ .flush({error: 'Not found.'}, {status: 404, statusText: 'Not Found'});
+
+ expect(service.scenario).toBeNull();
+ expect(demoMode.clearScenario).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/app/demo/demo-scenario-registry.service.ts b/src/app/demo/demo-scenario-registry.service.ts
new file mode 100644
index 0000000000..16d5192941
--- /dev/null
+++ b/src/app/demo/demo-scenario-registry.service.ts
@@ -0,0 +1,124 @@
+import {HttpClient} from '@angular/common/http';
+import {Inject, Injectable} from '@angular/core';
+import {BehaviorSubject, EMPTY, Observable, catchError, filter, map, of, tap} from 'rxjs';
+import API_URL from 'src/app/config/constants/apiUrl';
+import {DEMO_TOOLS_AVAILABLE, DemoModeStore} from './demo-mode.store';
+
+export interface DemoStatusHook {
+ status: string;
+ count: number;
+ percentage: number;
+ task_abbreviations: string[];
+}
+
+export interface DemoPpiHook {
+ state: 'available' | 'unavailable';
+ unavailable_reason: string | null;
+ task_abbreviation: string;
+ task_definition_id: number;
+ submitted_percentage: number | null;
+ completed_percentage: number | null;
+ status_distribution: {status: string; percentage: number}[] | null;
+}
+
+export interface DemoUnitHook {
+ key: string;
+ code: string;
+ name: string;
+ unit_id: number;
+ project_id: number;
+ ppi: DemoPpiHook;
+}
+
+export interface DemoScenarioContract {
+ schema_version: number;
+ scenario_id: string;
+ demo_only: true;
+ generated_at: string;
+ primary_unit_key: string;
+ units: DemoUnitHook[];
+ task_lifecycle: {
+ unit_key: string;
+ total_tasks: number;
+ submitted_percentage: number;
+ completed_percentage: number;
+ statuses: DemoStatusHook[];
+ };
+ notification_hooks: {
+ key: string;
+ id: number;
+ event: string;
+ notification_type: string;
+ read: boolean;
+ created_at: string;
+ link: string;
+ }[];
+ group_hook: {
+ key: string;
+ unit_key: string;
+ unit_id: number;
+ project_id: number;
+ group_set_id: number;
+ group_id: number;
+ name: string;
+ member_count: number;
+ capacity: number;
+ route: string;
+ };
+ walkthrough_links: {key: string; label: string; route: string}[];
+}
+
+@Injectable({providedIn: 'root'})
+export class DemoScenarioRegistryService {
+ private requestGeneration = 0;
+ private readonly scenarioSubject: BehaviorSubject =
+ new BehaviorSubject(null);
+ readonly scenario$ = this.scenarioSubject.asObservable();
+
+ constructor(
+ private http: HttpClient,
+ private demoMode: DemoModeStore,
+ @Inject(DEMO_TOOLS_AVAILABLE) private toolsEligible: boolean,
+ ) {}
+
+ get scenario(): DemoScenarioContract | null {
+ return this.scenarioSubject.value;
+ }
+
+ loadForAuthenticatedUser(
+ userId: number,
+ isCurrentSession: () => boolean = () => true,
+ ): Observable {
+ const requestGeneration = ++this.requestGeneration;
+ const isCurrentRequest = () =>
+ requestGeneration === this.requestGeneration && isCurrentSession();
+ if (!this.toolsEligible) {
+ this.clear();
+ return of(void 0);
+ }
+
+ return this.http.get(`${API_URL}/demo/scenario`).pipe(
+ filter(isCurrentRequest),
+ tap((scenario) => {
+ this.scenarioSubject.next(scenario);
+ this.demoMode.configureScenario(scenario.scenario_id, userId);
+ }),
+ map(() => void 0),
+ catchError(() => {
+ if (!isCurrentRequest()) {
+ return EMPTY;
+ }
+ // A 404 is the expected answer outside the isolated runtime. Do not
+ // infer availability from a build flag or leak the guarded endpoint.
+ this.clear();
+ return of(void 0);
+ }),
+ );
+ }
+
+ clear(): void {
+ this.requestGeneration += 1;
+ this.scenarioSubject.next(null);
+ this.demoMode.clearScenario();
+ }
+}
diff --git a/src/app/demo/ppi-preview/ppi-preview.component.html b/src/app/demo/ppi-preview/ppi-preview.component.html
new file mode 100644
index 0000000000..1f873b88de
--- /dev/null
+++ b/src/app/demo/ppi-preview/ppi-preview.component.html
@@ -0,0 +1,140 @@
+
+
+
+
+ @for (option of options; track option.kind) {
+
+ {{ option.label }}
+
+ }
+
+
+
+ {{ selected.explanation }}
+
+
+
+
+
+ @if (data.isSuppressed) {
+
+ privacy_tip
+
+ Progress is hidden to protect privacy
+ {{ data.unavailableMessage }}
+
+
+ } @else {
+
+
+ {{ summaryPercentage }}%
+ of peers have completed this task
+
+
+
+
+
+ {{ summaryPercentage }}% Peer cohort
+
+
+
+ @if (advanced) {
+
+
+
+
Completed
+ {{ data.completedPercentage }}%
+
+
+
Submitted
+ {{ data.submittedPercentage }}%
+
+
+
+
+
Task status breakdown
+
Independently privacy-rounded; values may not total 100%.
+
+
+ @if (!data.distributionAvailable) {
+
+ privacy_tip
+
+ Detailed breakdown protected
+ Overall percentages are safe to show, but status detail remains hidden.
+
+
+ } @else {
+ @if (distributionTotal !== 100) {
+
+ Privacy-rounded statuses total {{ distributionTotal }}%. Each row keeps its own
+ 0–100% scale.
+
+ }
+
+
+ @for (segment of segments; track segment.status) {
+
+
+
+ {{ segment.label }}
+
+
+
+
+ {{ segment.percentage }}%
+
+ }
+
+ }
+
+ }
+ }
+
+
diff --git a/src/app/demo/ppi-preview/ppi-preview.component.scss b/src/app/demo/ppi-preview/ppi-preview.component.scss
new file mode 100644
index 0000000000..e7a28066d0
--- /dev/null
+++ b/src/app/demo/ppi-preview/ppi-preview.component.scss
@@ -0,0 +1,356 @@
+:host {
+ display: block;
+ min-width: 0;
+ color: var(--ot-color-text);
+}
+
+.ppi-preview {
+ border: 1px solid var(--ot-color-divider);
+ border-radius: 0.75rem;
+ display: grid;
+ gap: 1rem;
+ min-width: 0;
+ padding: 1rem;
+
+ > header {
+ align-items: flex-start;
+ display: flex;
+ gap: 0.75rem;
+ }
+
+ h2,
+ h3,
+ p {
+ margin: 0;
+ }
+
+ h2 {
+ font-size: 1.05rem;
+ }
+
+ header p,
+ .ppi-preview__advanced > div > p {
+ color: var(--ot-color-text-muted);
+ line-height: 1.45;
+ }
+}
+
+.ppi-preview__choices {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+
+ button {
+ min-height: 44px;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+ }
+
+ button[aria-pressed='true'] {
+ background: color-mix(in srgb, var(--ot-color-primary) 12%, transparent);
+ border-color: var(--ot-color-primary);
+ color: var(--ot-color-link);
+ }
+
+ button:focus-visible {
+ outline: 3px solid var(--ot-color-focus);
+ outline-offset: 2px;
+ }
+}
+
+.ppi-preview__explanation {
+ background: var(--ot-color-page);
+ border-left: 4px solid var(--ot-color-primary);
+ border-radius: 0.35rem;
+ color: var(--ot-color-text);
+ line-height: 1.45;
+ padding: 0.75rem;
+}
+
+.ppi-preview__card {
+ background: var(--ot-color-surface-raised);
+ border: 1px solid var(--ot-color-divider);
+ border-radius: 0.75rem;
+ box-shadow: var(--ot-elevation-1);
+ min-width: 0;
+ overflow: hidden;
+}
+
+.ppi-preview__card-header {
+ align-items: center;
+ display: flex;
+ gap: 0.75rem;
+ justify-content: space-between;
+ padding: 1rem 1rem 0.5rem;
+}
+
+.ppi-preview__identity {
+ align-items: center;
+ display: flex;
+ gap: 0.625rem;
+ min-width: 0;
+
+ strong,
+ small {
+ display: block;
+ }
+
+ small {
+ color: var(--ot-color-text-muted);
+ margin-top: 0.125rem;
+ }
+}
+
+.ppi-preview__avatar {
+ align-items: center;
+ background: color-mix(in srgb, var(--ot-color-link) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--ot-color-link) 40%, transparent);
+ border-radius: 50%;
+ color: var(--ot-color-link);
+ display: inline-flex;
+ flex: 0 0 2.5rem;
+ height: 2.5rem;
+ justify-content: center;
+}
+
+.ppi-preview__switch {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: 999px;
+ color: var(--ot-color-text);
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ font-weight: 600;
+ gap: 0.5rem;
+ min-height: 44px;
+ padding: 0.5rem 0.25rem;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+
+ > span {
+ background: var(--ot-color-text-muted);
+ border-radius: 999px;
+ display: inline-flex;
+ height: 1.25rem;
+ padding: 0.125rem;
+ width: 2.25rem;
+ }
+
+ i {
+ background: var(--ot-color-surface-raised);
+ border-radius: 50%;
+ height: 1rem;
+ transition: transform 160ms ease;
+ width: 1rem;
+ }
+
+ &[aria-checked='true'] > span {
+ background: var(--ot-color-primary);
+ }
+
+ &[aria-checked='true'] i {
+ background: var(--ot-color-on-primary);
+ transform: translateX(1rem);
+ }
+
+ &:focus-visible {
+ outline: 3px solid var(--ot-color-focus);
+ outline-offset: 2px;
+ }
+}
+
+.ppi-preview__summary {
+ align-items: center;
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
+ padding: 0.25rem 1rem 1rem 4.5rem;
+
+ > p {
+ align-items: baseline;
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ > p strong {
+ color: var(--ot-color-link);
+ font-size: clamp(1.75rem, 7vw, 2.5rem);
+ line-height: 1;
+ }
+
+ > p span {
+ font-weight: 600;
+ line-height: 1.3;
+ }
+
+ > div > small {
+ color: var(--ot-color-text-muted);
+ display: flex;
+ justify-content: space-between;
+ margin-top: 0.25rem;
+ }
+}
+
+.ppi-preview__track,
+.ppi-preview__status-track {
+ background: var(--ot-meter-track);
+ border-radius: 999px;
+ display: block;
+ height: 0.625rem;
+ overflow: hidden;
+
+ i {
+ border-radius: inherit;
+ display: block;
+ height: 100%;
+ }
+}
+
+// The link blue: dark primary is under 2:1 on the dark track.
+.ppi-preview__track i {
+ background: var(--ot-color-link);
+}
+
+.ppi-preview__advanced {
+ background: var(--ot-color-page);
+ border-top: 1px solid var(--ot-color-divider);
+ display: grid;
+ gap: 1rem;
+ padding: 1rem;
+
+ > dl {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin: 0;
+ }
+
+ > dl > div {
+ background: var(--ot-color-surface-raised);
+ border: 1px solid var(--ot-color-divider);
+ border-left: 4px solid var(--ot-color-primary);
+ border-radius: 0.5rem;
+ display: flex;
+ justify-content: space-between;
+ padding: 0.75rem;
+ }
+
+ dt,
+ dd {
+ margin: 0;
+ }
+
+ dt {
+ color: var(--ot-color-text-muted);
+ font-weight: 600;
+ }
+
+ dd {
+ font-weight: 700;
+ }
+}
+
+.ppi-preview__privacy {
+ align-items: flex-start;
+ background: color-mix(in srgb, var(--ot-color-warning) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--ot-color-warning) 45%, var(--ot-color-divider));
+ border-radius: 0.5rem;
+ color: var(--ot-color-text);
+ display: flex;
+ gap: 0.625rem;
+ line-height: 1.45;
+ margin: 0.5rem 1rem 1rem;
+ padding: 0.75rem;
+
+ strong {
+ display: block;
+ }
+}
+
+.ppi-preview__advanced .ppi-preview__privacy {
+ margin: 0;
+}
+
+.ppi-preview__rounding {
+ background: var(--ot-color-surface-raised);
+ border-left: 4px solid var(--ot-color-primary);
+ border-radius: 0.35rem;
+ color: var(--ot-color-text-muted);
+ line-height: 1.45;
+ padding: 0.75rem;
+}
+
+.ppi-preview__statuses {
+ display: grid;
+ gap: 0.5rem;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ li {
+ align-items: center;
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) 2.5rem;
+ }
+
+ li > span:first-child {
+ align-items: center;
+ display: flex;
+ font-weight: 600;
+ gap: 0.5rem;
+ min-width: 0;
+ }
+
+ li > span:first-child i {
+ border: 1px solid color-mix(in srgb, var(--ot-color-text) 20%, transparent);
+ border-radius: 50%;
+ flex: 0 0 0.75rem;
+ height: 0.75rem;
+ }
+
+ li > strong {
+ text-align: right;
+ }
+}
+
+@media (max-width: 520px) {
+ .ppi-preview {
+ padding: 0.75rem;
+ }
+
+ .ppi-preview__card-header {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .ppi-preview__switch {
+ margin-left: 3.125rem;
+ }
+
+ .ppi-preview__summary {
+ grid-template-columns: 1fr;
+ padding-left: 1rem;
+ }
+
+ .ppi-preview__advanced > dl {
+ grid-template-columns: 1fr;
+ }
+
+ .ppi-preview__statuses li {
+ grid-template-columns: 1fr auto;
+ }
+
+ .ppi-preview__status-track {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ppi-preview__switch i {
+ transition: none;
+ }
+}
diff --git a/src/app/demo/ppi-preview/ppi-preview.component.spec.ts b/src/app/demo/ppi-preview/ppi-preview.component.spec.ts
new file mode 100644
index 0000000000..c9bf71bd63
--- /dev/null
+++ b/src/app/demo/ppi-preview/ppi-preview.component.spec.ts
@@ -0,0 +1,117 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {NoopAnimationsModule} from '@angular/platform-browser/animations';
+import {PeerProgressDisplayPreferenceService} from 'src/app/common/services/peer-progress-display-preference.service';
+import {PpiPreviewComponent} from './ppi-preview.component';
+
+describe('PpiPreviewComponent', () => {
+ let fixture: ComponentFixture;
+ let preference: {enabled: boolean; setEnabled: ReturnType};
+
+ beforeEach(async () => {
+ preference = {
+ enabled: false,
+ setEnabled: vi.fn((enabled: boolean) => {
+ preference.enabled = enabled;
+ return enabled;
+ }),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [PpiPreviewComponent, NoopAnimationsModule],
+ providers: [{provide: PeerProgressDisplayPreferenceService, useValue: preference}],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(PpiPreviewComponent);
+ fixture.detectChanges();
+ });
+
+ function text(): string {
+ return String(fixture.nativeElement.textContent).replace(/\s+/g, ' ').trim();
+ }
+
+ it('updates headline, compact metrics, total and explanatory copy together', () => {
+ fixture.componentInstance.setAdvanced(true);
+ fixture.componentInstance.select('rounded-90');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.summaryPercentage).toBe(20);
+ expect(fixture.componentInstance.data.completedPercentage).toBe(20);
+ expect(fixture.componentInstance.data.submittedPercentage).toBe(50);
+ expect(text()).toContain('statuses total 90%');
+ expect(text()).toContain('50% submitted and 20% complete');
+
+ fixture.componentInstance.select('rounded-110');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.summaryPercentage).toBe(30);
+ expect(fixture.componentInstance.data.completedPercentage).toBe(30);
+ expect(fixture.componentInstance.data.submittedPercentage).toBe(80);
+ expect(text()).toContain('statuses total 110%');
+ });
+
+ it('shows all seven canonical Batch 09 statuses in the full preview', () => {
+ fixture.componentInstance.setAdvanced(true);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelectorAll('.ppi-preview__statuses li')).toHaveLength(7);
+ expect(text()).toContain('Not Started');
+ expect(text()).toContain('Working On It');
+ expect(text()).toContain('Ready for Feedback');
+ expect(text()).toContain('Resubmit');
+ expect(text()).toContain('Redo');
+ expect(text()).toContain('Complete');
+ expect(text()).toContain('Fail');
+ });
+
+ it('keeps Advanced on across preview changes and stores the user choice', () => {
+ fixture.componentInstance.setAdvanced(true);
+ fixture.componentInstance.select('rounded-90');
+ fixture.componentInstance.select('details-protected');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.advanced).toBe(true);
+ expect(preference.setEnabled).toHaveBeenCalledWith(true);
+ expect(fixture.nativeElement.querySelector('#ppi-preview-advanced')).toBeTruthy();
+ });
+
+ it('never exposes compact or detailed values for an insufficient cohort', () => {
+ fixture.componentInstance.setAdvanced(true);
+ fixture.componentInstance.select('insufficient');
+ fixture.detectChanges();
+
+ expect(text()).toContain('Progress is hidden to protect privacy');
+ expect(text()).not.toContain('Completed 10%');
+ expect(text()).not.toContain('Submitted 60%');
+ expect(fixture.nativeElement.querySelector('[role="switch"]')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.ppi-preview__statuses')).toBeNull();
+ });
+
+ it('uses selected-state styling without a permanent active-state class', () => {
+ const choice = fixture.nativeElement.querySelector('[data-testid="ppi-preview-full"]');
+
+ expect(choice.getAttribute('aria-pressed')).toBe('true');
+ expect(choice.classList.contains('active')).toBe(false);
+ });
+
+ it('uses native keyboard-operable buttons and exposes exactly one selected state', () => {
+ const choices = Array.from(
+ fixture.nativeElement.querySelectorAll('.ppi-preview__choices button'),
+ );
+
+ expect(choices).toHaveLength(5);
+ expect(choices.every((choice) => choice.tagName === 'BUTTON' && choice.type === 'button')).toBe(
+ true,
+ );
+ expect(choices.filter((choice) => choice.getAttribute('aria-pressed') === 'true')).toHaveLength(
+ 1,
+ );
+
+ fixture.componentInstance.select('rounded-110');
+ fixture.detectChanges();
+
+ expect(
+ choices.filter((choice) => choice.getAttribute('aria-pressed') === 'true')[0]?.dataset.testid,
+ ).toBe('ppi-preview-rounded-110');
+ });
+});
diff --git a/src/app/demo/ppi-preview/ppi-preview.component.ts b/src/app/demo/ppi-preview/ppi-preview.component.ts
new file mode 100644
index 0000000000..0a8f3a5a8f
--- /dev/null
+++ b/src/app/demo/ppi-preview/ppi-preview.component.ts
@@ -0,0 +1,137 @@
+import {CommonModule} from '@angular/common';
+import {ChangeDetectionStrategy, ChangeDetectorRef, Component} from '@angular/core';
+import {MatButtonModule} from '@angular/material/button';
+import {MatIconModule} from '@angular/material/icon';
+import {PeerProgressIndicator} from 'src/app/api/models/peer-progress-indicator';
+import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status';
+import {PeerProgressDisplayPreferenceService} from 'src/app/common/services/peer-progress-display-preference.service';
+import {
+ DETAIL_PROTECTED_STATE,
+ NORMAL_STATE,
+ ROUNDED_90_STATE,
+ ROUNDED_110_STATE,
+ SUPPRESSED_STATE,
+} from '../fixtures/peer-progress-demo.fixtures';
+
+type PpiPreviewKind = 'full' | 'rounded-90' | 'rounded-110' | 'insufficient' | 'details-protected';
+
+interface PpiPreviewOption {
+ kind: PpiPreviewKind;
+ label: string;
+ explanation: string;
+ data: PeerProgressIndicator;
+}
+
+interface PpiPreviewSegment {
+ status: TaskStatusEnum;
+ label: string;
+ color: string;
+ percentage: number;
+}
+
+@Component({
+ selector: 'f-ppi-preview',
+ templateUrl: './ppi-preview.component.html',
+ styleUrls: ['./ppi-preview.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [CommonModule, MatButtonModule, MatIconModule],
+ standalone: true,
+})
+export class PpiPreviewComponent {
+ readonly options: readonly PpiPreviewOption[] = [
+ {
+ kind: 'full',
+ label: 'Full status data',
+ explanation: '60% submitted, 10% complete; seven canonical lifecycle statuses total 100%.',
+ data: NORMAL_STATE,
+ },
+ {
+ kind: 'rounded-90',
+ label: 'Rounded total 90%',
+ explanation:
+ 'Independent privacy rounding gives 50% submitted and 20% complete; status rows total 90%.',
+ data: ROUNDED_90_STATE,
+ },
+ {
+ kind: 'rounded-110',
+ label: 'Rounded total 110%',
+ explanation:
+ 'Independent privacy rounding gives 80% submitted and 30% complete; status rows total 110%.',
+ data: ROUNDED_110_STATE,
+ },
+ {
+ kind: 'insufficient',
+ label: 'Insufficient cohort',
+ explanation: 'No peer percentage or detailed status is exposed below the privacy threshold.',
+ data: SUPPRESSED_STATE,
+ },
+ {
+ kind: 'details-protected',
+ label: 'Advanced details protected',
+ explanation:
+ 'The safe 70% submitted and 30% complete summary remains visible; detailed statuses stay hidden.',
+ data: DETAIL_PROTECTED_STATE,
+ },
+ ];
+
+ selectedKind: PpiPreviewKind = 'full';
+ advanced = this.displayPreference.enabled;
+
+ private readonly statusDisplayIndex = new Map(
+ TaskStatus.PEER_PROGRESS_DISPLAY_ORDER.map((status, index) => [status, index]),
+ );
+
+ constructor(
+ private readonly displayPreference: PeerProgressDisplayPreferenceService,
+ private readonly cdr: ChangeDetectorRef,
+ ) {}
+
+ select(kind: PpiPreviewKind): void {
+ this.selectedKind = kind;
+ this.cdr.markForCheck();
+ }
+
+ setAdvanced(enabled: boolean): void {
+ this.advanced = this.displayPreference.setEnabled(enabled);
+ this.cdr.markForCheck();
+ }
+
+ get selected(): PpiPreviewOption {
+ return this.options.find((option) => option.kind === this.selectedKind) ?? this.options[0];
+ }
+
+ get data(): PeerProgressIndicator {
+ return this.selected.data;
+ }
+
+ get summaryPercentage(): number {
+ return this.data.completedPercentage ?? this.data.submittedPercentage ?? 0;
+ }
+
+ get summaryWidth(): string {
+ return `${Math.min(100, Math.max(0, this.summaryPercentage))}%`;
+ }
+
+ get segments(): PpiPreviewSegment[] {
+ return this.data.statusDistribution
+ .filter((entry) => entry.percentage > 0)
+ .sort(
+ (left, right) =>
+ (this.statusDisplayIndex.get(left.status) ?? Number.MAX_SAFE_INTEGER) -
+ (this.statusDisplayIndex.get(right.status) ?? Number.MAX_SAFE_INTEGER),
+ )
+ .map((entry) => ({
+ ...entry,
+ label:
+ entry.status === 'ready_for_feedback'
+ ? 'Ready for Feedback'
+ : (TaskStatus.STATUS_LABELS.get(entry.status) ?? entry.status),
+ // The status mark tokens, as the live widget uses, so bars clear 3:1 in dark too.
+ color: `var(--ot-status-${TaskStatus.statusClass(entry.status)}-graphic, var(--ot-color-text-muted))`,
+ }));
+ }
+
+ get distributionTotal(): number {
+ return this.segments.reduce((total, segment) => total + segment.percentage, 0);
+ }
+}
diff --git a/src/app/errors/states/unauthorised/unauthorised.component.html b/src/app/errors/states/unauthorised/unauthorised.component.html
index 5f1e2da7ac..4fb76b8e71 100644
--- a/src/app/errors/states/unauthorised/unauthorised.component.html
+++ b/src/app/errors/states/unauthorised/unauthorised.component.html
@@ -1,8 +1,12 @@
-
-
warning
-
Unauthorised
-
+
+
warning
+
Unauthorised
+
You do not have sufficient permissions to access this resource, or your session has expired.
-
Go back
+
+ Go back
+
diff --git a/src/app/errors/unavailable-card/unavailable-card.component.html b/src/app/errors/unavailable-card/unavailable-card.component.html
index 151f295a19..7a5dd173e8 100644
--- a/src/app/errors/unavailable-card/unavailable-card.component.html
+++ b/src/app/errors/unavailable-card/unavailable-card.component.html
@@ -2,9 +2,9 @@
appearance="outlined"
class="maintenance flex size-full max-w-4xl flex-col items-center justify-center gap-9 border-none p-6 select-none"
>
-
Temporarily Unavailable
-
engineering
-
+
Temporarily Unavailable
+
engineering
+
We apologise for the inconvenience. Please check back again soon.
diff --git a/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.html b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.html
index 4d62d51d5b..c42445a4ca 100644
--- a/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.html
+++ b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.html
@@ -1,4 +1,5 @@
- Team Member
+ Team member
{{ member.project.student.name }}
- Target Grade
+ Target grade
-
+
Contribution
- @for (i of [].constructor(numStars); track $index) {
- member.overStar : $index + 1 > member.rating,
- }"
- (click)="selectRating(member, $index + 1)"
- (mouseenter)="hoveringOver(member, $index + 1)"
+
+
+
- person
-
- }
+ @for (star of stars; track star) {
+ = star ? 'text-ot-primary' : 'text-ot-muted'
+ "
+ (blur)="hoveringOver(member, null)"
+ (click)="selectRating(member, star)"
+ (focus)="hoveringOver(member, star)"
+ (mouseenter)="hoveringOver(member, star)"
+ >
+ person
+
+ }
+
+
+ {{ percentFor(member) }}%
+
+
+
+
+
+
+
diff --git a/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.scss b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.scss
index e69de29bb2..4c36fd8268 100644
--- a/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.scss
+++ b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.scss
@@ -0,0 +1,9 @@
+// The table sits inside the upload dialog's card: quiet column headings, and row
+// rules on the divider token so they read the same in light and dark. Its columns
+// are sticky, so the cells keep a solid fill in the card's own colour.
+.contribution-table {
+ --mat-table-background-color: var(--ot-color-surface-raised);
+ --mat-table-row-item-outline-color: var(--ot-color-divider);
+ --mat-table-header-headline-color: var(--ot-color-text-muted);
+ --mat-table-row-item-label-text-color: var(--ot-color-text);
+}
diff --git a/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.spec.ts b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.spec.ts
new file mode 100644
index 0000000000..2bacbbbdd4
--- /dev/null
+++ b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.spec.ts
@@ -0,0 +1,132 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {of} from 'rxjs';
+import {Group, MemberContribution} from 'src/app/api/models/groups/group';
+import {GroupSet} from 'src/app/api/models/groups/group-set';
+import {Project} from 'src/app/api/models/project';
+import {Task} from 'src/app/api/models/task';
+import {Unit} from 'src/app/api/models/unit';
+import {GroupMemberContributionAssignerComponent} from './group-member-contribution-assigner.component';
+
+function member(id: number): Project {
+ const project = new Project();
+ project.id = id;
+ return project;
+}
+
+describe('GroupMemberContributionAssignerComponent', () => {
+ const unit = {id: 1} as Unit;
+ let group: Group;
+ let task: Task;
+ let project: Project;
+
+ beforeEach(async () => {
+ const set = new GroupSet(unit);
+ set.id = 100;
+ group = new Group(unit);
+ group.id = 1;
+ group.groupSet = set;
+ set.groupsCache.add(group);
+ group.getMembers = vi.fn(() => of([member(1), member(2), member(3)]));
+
+ project = {getGroupForTask: () => group, unit} as unknown as Project;
+ task = {definition: {groupSet: set}} as unknown as Task;
+
+ await TestBed.configureTestingModule({
+ declarations: [GroupMemberContributionAssignerComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ })
+ .overrideComponent(GroupMemberContributionAssignerComponent, {set: {template: ''}})
+ .compileComponents();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function create(isTestSubmission = false): GroupMemberContributionAssignerComponent {
+ const component = TestBed.createComponent(
+ GroupMemberContributionAssignerComponent,
+ ).componentInstance;
+ component.task = task;
+ component.project = project;
+ component.isTestSubmission = isTestSubmission;
+ component.ngOnInit();
+ return component;
+ }
+
+ it('starts every member on an even share', () => {
+ const component = create();
+ const shares = component.team.memberContributions.map((contribution) =>
+ component.percentFor(contribution),
+ );
+
+ expect(shares).toEqual([33, 33, 33]);
+ });
+
+ it('keeps the share when the pointer leaves the rating', () => {
+ const component = create();
+ const [first] = component.team.memberContributions;
+
+ component.hoveringOver(first, 5);
+ component.hoveringOver(first, null);
+
+ expect(first.percent).toBe(33);
+ expect(component.percentFor(first)).toBe(33);
+ });
+
+ it('updates every share when one rating changes', () => {
+ const component = create();
+ const [first, second] = component.team.memberContributions;
+
+ component.selectRating(first, 5);
+ component.hoveringOver(first, null);
+
+ // 5 of 5 + 3 + 3 = 11 points in all.
+ expect(component.percentFor(first)).toBe(45);
+ expect(component.percentFor(second)).toBe(27);
+ });
+
+ it('shows 0% for a team rated all zero instead of NaN', () => {
+ const component = create();
+ component.team.memberContributions.forEach((contribution: MemberContribution) => {
+ component.selectRating(contribution, contribution.rating);
+ component.hoveringOver(contribution, null);
+ });
+
+ expect(component.team.memberContributions.map((c) => component.percentFor(c))).toEqual([
+ 0, 0, 0,
+ ]);
+ });
+
+ it('asks for the members once when it opens', () => {
+ // Bind the inputs the way the dialog does, so the first ngOnChanges runs too.
+ const fixture = TestBed.createComponent(GroupMemberContributionAssignerComponent);
+ fixture.componentRef.setInput('isTestSubmission', false);
+ fixture.componentRef.setInput('project', project);
+ fixture.componentRef.setInput('task', task);
+
+ fixture.detectChanges();
+
+ expect(group.getMembers).toHaveBeenCalledTimes(1);
+ });
+
+ it('hands back an empty team for a test submission without logging an error', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const component = TestBed.createComponent(
+ GroupMemberContributionAssignerComponent,
+ ).componentInstance;
+ const emitted: unknown[] = [];
+ component.teamChange.subscribe((team) => emitted.push(team));
+ component.task = task;
+ component.project = project;
+ component.isTestSubmission = true;
+
+ component.ngOnInit();
+
+ expect(consoleError).not.toHaveBeenCalled();
+ expect(component.team.memberContributions).toEqual([]);
+ expect(emitted).toHaveLength(1);
+ });
+});
diff --git a/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.ts b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.ts
index 6f75ea24ea..c7e8ff052b 100644
--- a/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.ts
+++ b/src/app/groups/group-member-contribution-assigner/group-member-contribution-assigner.component.ts
@@ -4,12 +4,14 @@ import {
EventEmitter,
Input,
OnChanges,
+ OnDestroy,
OnInit,
Output,
SimpleChanges,
} from '@angular/core';
import {Sort} from '@angular/material/sort';
import {MatTableDataSource} from '@angular/material/table';
+import {Subscription} from 'rxjs';
import {GroupSet} from 'src/app/api/models/doubtfire-model';
import {Group, MemberContribution} from 'src/app/api/models/groups/group';
import {Project} from 'src/app/api/models/project';
@@ -22,7 +24,7 @@ import {Task} from 'src/app/api/models/task';
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
})
-export class GroupMemberContributionAssignerComponent implements OnInit, OnChanges {
+export class GroupMemberContributionAssignerComponent implements OnInit, OnChanges, OnDestroy {
@Input() isTestSubmission: boolean;
@Input() task: Task;
@@ -36,6 +38,7 @@ export class GroupMemberContributionAssignerComponent implements OnInit, OnChang
numStars = 5;
initialStars = 3;
+ readonly stars = Array.from({length: this.numStars}, (_, index) => index + 1);
percentages = {
danger: 0,
@@ -47,20 +50,31 @@ export class GroupMemberContributionAssignerComponent implements OnInit, OnChang
displayedColumns = ['name', 'target-grade', 'contribution'];
dataSource: MatTableDataSource
= new MatTableDataSource([]);
+ private membersSub?: Subscription;
+
ngOnInit(): void {
this.initializeGroupData();
this.loadMembers();
}
ngOnChanges(changes: SimpleChanges): void {
- if (changes['task'] || changes['project']) {
+ // ngOnInit covers the first binding; loading here as well sent the request twice.
+ const taskChanged = changes['task'] && !changes['task'].firstChange;
+ const projectChanged = changes['project'] && !changes['project'].firstChange;
+ if (taskChanged || projectChanged) {
this.initializeGroupData();
this.loadMembers();
}
}
+ ngOnDestroy(): void {
+ this.membersSub?.unsubscribe();
+ }
+
private initializeGroupData(): void {
this.selectedGroupSet = this.task?.definition?.groupSet;
+ // Start clean, so a test submission does not keep the group of an earlier task.
+ this.selectedGroup = undefined;
// Check if this is an overseer test submission
if (!this.isTestSubmission) {
const group = this.project?.getGroupForTask(this.task);
@@ -72,23 +86,19 @@ export class GroupMemberContributionAssignerComponent implements OnInit, OnChang
}
private loadMembers(): void {
- if (!this.selectedGroup && this.selectedGroupSet?.groups?.length > 0) {
- console.error(`Could not find project's group`);
- this.team.memberContributions = [];
- return;
- }
+ this.membersSub?.unsubscribe();
+
if (this.selectedGroup && this.selectedGroupSet) {
- this.selectedGroup.getMembers().subscribe({
+ this.membersSub = this.selectedGroup.getMembers().subscribe({
next: (members) => {
- this.team.memberContributions = members.map((member) => {
- const result: MemberContribution = {
- project: member,
- rating: this.initialStars,
- percent: 0,
- overStar: null,
- };
- result.percent = this.memberPercentage(result, this.initialStars);
- return result;
+ this.team.memberContributions = members.map((member) => ({
+ project: member,
+ rating: this.initialStars,
+ percent: 0,
+ overStar: null,
+ }));
+ this.team.memberContributions.forEach((contribution) => {
+ contribution.percent = this.percentFor(contribution);
});
// Update percentages based on member count
@@ -101,17 +111,27 @@ export class GroupMemberContributionAssignerComponent implements OnInit, OnChang
},
});
} else {
+ // No group to rate, which is expected for a test submission. This used to log
+ // a console error on every one of them.
this.team.memberContributions = [];
this.teamChange.emit(this.team);
+ this.dataSource.data = [];
}
}
- private memberPercentage(contrib: MemberContribution, rating: number): number {
- return +(
- 100 *
- (rating /
- this.selectedGroup.contributionSum(this.team.memberContributions, contrib.project, rating))
- ).toFixed();
+ /**
+ * A member's share of the team's effort, using the rating under the pointer while
+ * the member is being rated. Worked out when shown, so every row follows a change
+ * to any one rating, and a team rated all zero shows 0% instead of NaN.
+ */
+ percentFor(contrib: MemberContribution): number {
+ const rating = contrib.overStar ?? contrib.rating;
+ const total = this.team.memberContributions.reduce(
+ (sum, current) => sum + (current === contrib ? rating : current.rating),
+ 0,
+ );
+
+ return total > 0 ? Math.round((100 * rating) / total) : 0;
}
selectRating(contrib: MemberContribution, rating: number) {
@@ -124,9 +144,11 @@ export class GroupMemberContributionAssignerComponent implements OnInit, OnChang
}
}
- hoveringOver(contrib: MemberContribution, value: number): void {
+ hoveringOver(contrib: MemberContribution, value: number | null): void {
contrib.overStar = value;
- contrib.percent = this.memberPercentage(contrib, value);
+ // Leaving the stars used to work the share out from no rating at all, so it
+ // dropped to 0% until the next hover.
+ contrib.percent = this.percentFor(contrib);
}
private sortCompare(aValue: number | string, bValue: number | string, isAsc: boolean) {
diff --git a/src/app/groups/group-member-list/group-member-list.component.html b/src/app/groups/group-member-list/group-member-list.component.html
index e87db81839..5a5d1dcc7f 100644
--- a/src/app/groups/group-member-list/group-member-list.component.html
+++ b/src/app/groups/group-member-list/group-member-list.component.html
@@ -1,55 +1,81 @@
@if (loading) {
-
+
+
Loading members...
-} @else if (!selectedGroup || selectedGroup.members.length === 0) {
-
-
group_off
-
There are no members in this group
+} @else if (loadError) {
+
+
+
+ Try again
+
+} @else if (members.length === 0) {
+
} @else {
-
-
- {{ unitRole ? 'Student ID' : '' }}
-
- @if (unitRole) {
- {{ member.student.username || 'N/A' }}
- }
-
-
-
-
- Name
-
- {{ member.student.name }}
-
-
+
+ @for (member of members; track member.id) {
+
+
+
+ {{ member.student?.name || 'Unnamed student' }}
+ @if (project?.id === member.id) {
+ (You)
+ }
+
+ @if (unitRole && member.student?.username) {
+
{{ member.student.username }}
+ }
+
-
- {{ unitRole ? 'Target Grade' : '' }}
-
@if (unitRole) {
-
+
}
-
-
-
- {{ canRemoveMembers ? 'Actions' : '' }}
-
@if (canRemoveMembers) {
@if (!project && unitRole) {
-
- delete
+
+ person_remove
} @else if (project && project.id === member.id) {
- Leave
+
+ Leave group
+
}
}
-
-
-
-
-
-
+
+ }
+
}
diff --git a/src/app/groups/group-member-list/group-member-list.component.scss b/src/app/groups/group-member-list/group-member-list.component.scss
index e69de29bb2..8347d7f5d0 100644
--- a/src/app/groups/group-member-list/group-member-list.component.scss
+++ b/src/app/groups/group-member-list/group-member-list.component.scss
@@ -0,0 +1,50 @@
+:host {
+ display: block;
+}
+
+.student-member-cards {
+ display: block;
+ list-style: none;
+ margin: 0;
+ padding: 0.75rem;
+}
+
+.student-member-card {
+ align-items: center;
+ border-bottom: 1px solid var(--ot-color-divider);
+ display: flex;
+ gap: 0.75rem;
+ justify-content: space-between;
+ min-width: 0;
+ padding-block: 0.75rem;
+}
+
+.student-member-card__identity {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+ min-width: 0;
+}
+
+.student-member-card__identity p {
+ white-space: normal;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.student-member-card button {
+ flex: 0 0 auto;
+ min-height: 44px;
+}
+
+@media (max-width: 639.98px) {
+ .student-member-cards {
+ display: block;
+ }
+
+ .student-member-card {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
diff --git a/src/app/groups/group-member-list/group-member-list.component.spec.ts b/src/app/groups/group-member-list/group-member-list.component.spec.ts
new file mode 100644
index 0000000000..a3bdcc010d
--- /dev/null
+++ b/src/app/groups/group-member-list/group-member-list.component.spec.ts
@@ -0,0 +1,121 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA, SimpleChange} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {Observable, Subject, of, throwError} from 'rxjs';
+import {Group} from 'src/app/api/models/groups/group';
+import {GroupSet} from 'src/app/api/models/groups/group-set';
+import {Project} from 'src/app/api/models/project';
+import {Unit} from 'src/app/api/models/unit';
+import {User} from 'src/app/api/models/user/user';
+import {GroupMemberListComponent} from './group-member-list.component';
+
+function student(id: number, name: string): Project {
+ const project = new Project();
+ project.id = id;
+ const user = new User();
+ user.firstName = name;
+ user.lastName = '';
+ project.student = user;
+ return project;
+}
+
+function makeGroup(id: number, members: Observable
): Group {
+ const unit = {id: 1} as Unit;
+ const set = new GroupSet(unit);
+ set.id = 100;
+ const group = new Group(unit);
+ group.id = id;
+ group.groupSet = set;
+ group.getMembers = () => members;
+ return group;
+}
+
+describe('GroupMemberListComponent', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [GroupMemberListComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ })
+ .overrideComponent(GroupMemberListComponent, {set: {template: ''}})
+ .compileComponents();
+ });
+
+ function open(component: GroupMemberListComponent, group: Group, previous?: Group) {
+ component.selectedGroup = group;
+ component.ngOnChanges({selectedGroup: new SimpleChange(previous, group, !previous)});
+ }
+
+ it('ignores a late reply for the group that was open before', () => {
+ const component = TestBed.createComponent(GroupMemberListComponent).componentInstance;
+ const firstReply: Subject = new Subject();
+ const secondReply: Subject = new Subject();
+ const first = makeGroup(1, firstReply);
+ const second = makeGroup(2, secondReply);
+ second.projectsCache.add(student(7, 'Cy'));
+
+ open(component, first);
+ open(component, second, first);
+
+ // The first group's reply lands while the second group is still loading.
+ firstReply.next([student(8, 'Di')]);
+ expect(component.loading).toBe(true);
+ expect(component.members).toEqual([]);
+
+ secondReply.next([]);
+ expect(component.loading).toBe(false);
+ expect(component.members.map((member) => member.student.name)).toEqual(['Cy']);
+ });
+
+ it('follows members added to the open group, in name order', () => {
+ const component = TestBed.createComponent(GroupMemberListComponent).componentInstance;
+ const group = makeGroup(1, of([]));
+
+ open(component, group);
+ group.projectsCache.add(student(2, 'Zoe'));
+ group.projectsCache.add(student(3, 'Amy'));
+
+ expect(component.members.map((member) => member.student.name)).toEqual(['Amy', 'Zoe']);
+ });
+
+ it('shows a load failure and can try again', () => {
+ const component = TestBed.createComponent(GroupMemberListComponent).componentInstance;
+ const group = makeGroup(
+ 1,
+ throwError(() => 'offline'),
+ );
+
+ open(component, group);
+ expect(component.loadError).toBe(true);
+ expect(component.loading).toBe(false);
+
+ group.getMembers = vi.fn(() => of([]));
+ component.loadMembers();
+
+ expect(group.getMembers).toHaveBeenCalledTimes(1);
+ expect(component.loadError).toBe(false);
+ });
+
+ it('stops a student removing members as soon as the group is locked', () => {
+ const component = TestBed.createComponent(GroupMemberListComponent).componentInstance;
+ const group = makeGroup(1, of([]));
+ group.groupSet.allowStudentsToManageGroups = true;
+ group.locked = false;
+
+ open(component, group);
+ expect(component.canRemoveMembers).toBe(true);
+
+ group.locked = true;
+ expect(component.canRemoveMembers).toBe(false);
+ });
+
+ it('lets staff remove members from a locked group', () => {
+ const component = TestBed.createComponent(GroupMemberListComponent).componentInstance;
+ const group = makeGroup(1, of([]));
+ group.locked = true;
+ component.unitRole = {} as never;
+
+ open(component, group);
+
+ expect(component.canRemoveMembers).toBe(true);
+ });
+});
diff --git a/src/app/groups/group-member-list/group-member-list.component.ts b/src/app/groups/group-member-list/group-member-list.component.ts
index 162c552139..bcda0a97fd 100644
--- a/src/app/groups/group-member-list/group-member-list.component.ts
+++ b/src/app/groups/group-member-list/group-member-list.component.ts
@@ -3,15 +3,13 @@ import {
Component,
Input,
OnChanges,
- OnInit,
+ OnDestroy,
SimpleChanges,
} from '@angular/core';
-import {MatTableDataSource} from '@angular/material/table';
import {Subscription} from 'rxjs';
import {Group, UnitRole} from 'src/app/api/models/doubtfire-model';
import {Project} from 'src/app/api/models/project';
import {Unit} from 'src/app/api/models/unit';
-import {AlertService} from 'src/app/common/services/alert.service';
@Component({
selector: 'f-group-member-list',
@@ -20,7 +18,7 @@ import {AlertService} from 'src/app/common/services/alert.service';
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
})
-export class GroupMemberListComponent implements OnInit, OnChanges {
+export class GroupMemberListComponent implements OnChanges, OnDestroy {
@Input() unit: Unit;
@Input() unitRole: UnitRole;
@Input() project: Project;
@@ -28,54 +26,77 @@ export class GroupMemberListComponent implements OnInit, OnChanges {
@Input() onMembersLoaded: () => void;
loading = false;
+ loadError = false;
+ members: Project[] = [];
- canRemoveMembers = false;
+ private membersRequest?: Subscription;
+ private groupMembersSub?: Subscription;
- displayedColumns: string[] = ['student_id', 'name', 'target_grade', 'actions'];
- groupMembers: Project[] = [];
- dataSource = new MatTableDataSource();
+ /**
+ * Worked out when asked, not once after the members load, so locking or unlocking
+ * the group while it is open changes what a student can do straight away.
+ */
+ public get canRemoveMembers(): boolean {
+ if (this.unitRole) {
+ return true;
+ }
- private groupMembersSub?: Subscription;
+ return (
+ !!this.selectedGroup?.groupSet?.allowStudentsToManageGroups && !this.selectedGroup?.locked
+ );
+ }
- constructor(private alertService: AlertService) {}
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['selectedGroup']) {
+ this.loadMembers();
+ }
+ }
- ngOnInit() {
- if (!this.selectedGroup) {
+ ngOnDestroy(): void {
+ this.membersRequest?.unsubscribe();
+ this.groupMembersSub?.unsubscribe();
+ }
+
+ public loadMembers(): void {
+ // Cancel the request for the group that was open before, so a slow reply for it
+ // cannot land on top of the group now on screen.
+ this.membersRequest?.unsubscribe();
+ this.groupMembersSub?.unsubscribe();
+ this.loadError = false;
+ this.members = [];
+
+ const group = this.selectedGroup;
+ if (!group) {
+ this.loading = false;
return;
}
- this.groupMembersSub = this.selectedGroup.projectsCache.values.subscribe((values) => {
- this.dataSource.data = values;
+ this.loading = true;
+ this.membersRequest = group.getMembers().subscribe({
+ next: () => {
+ this.loading = false;
+ this.onMembersLoaded?.();
+
+ // The group's own cache follows adds and removals made on this page.
+ this.groupMembersSub = group.projectsCache.values.subscribe((values) => {
+ this.members = this.sortedByName(values);
+ });
+ },
+ error: () => {
+ this.loading = false;
+ this.loadError = true;
+ this.members = [];
+ },
});
}
- public removeMember(member: Project) {
+ public removeMember(member: Project): void {
this.selectedGroup.removeMember(member);
}
- ngOnChanges(changes: SimpleChanges) {
- if (changes['selectedGroup'] && this.selectedGroup) {
- this.loading = true;
- this.selectedGroup.getMembers().subscribe({
- next: (members) => {
- this.loading = false;
- this.onMembersLoaded?.();
- this.canRemoveMembers =
- !!this.unitRole ||
- (this.selectedGroup.groupSet.allowStudentsToManageGroups && !this.selectedGroup.locked);
-
- this.dataSource.data = members;
-
- this.groupMembersSub?.unsubscribe();
- this.groupMembersSub = this.selectedGroup.projectsCache.values.subscribe((values) => {
- this.dataSource.data = values;
- });
- },
- error: (error) => {
- this.alertService.error(`Failed to fetch group members: ${error}`, 6000);
- this.selectedGroup = null;
- },
- });
- }
+ private sortedByName(projects: readonly Project[]): Project[] {
+ return [...projects].sort((a, b) =>
+ (a.student?.name ?? '').localeCompare(b.student?.name ?? ''),
+ );
}
}
diff --git a/src/app/groups/group-selector/group-selector.component.html b/src/app/groups/group-selector/group-selector.component.html
index ccc01c2506..908d70cd69 100644
--- a/src/app/groups/group-selector/group-selector.component.html
+++ b/src/app/groups/group-selector/group-selector.component.html
@@ -1,71 +1,217 @@
-
-
-
-
-
-
- Groups for
- @if (!showGroupSetSelector && selectedGroup) {
- "{{ selectedGroupSet?.name }}"
- }
-
-
- @if (showGroupSetSelector) {
-
-
- @for (gs of unit.groupSets; track gs.id) {
- {{ gs.name }}
- }
-
-
- }
-
- @if (unitRole || selectedGroupSet?.allowStudentsToCreateGroups) {
-
-
-
-
-
- Create
-
-
+
+
- @if (selectedGroupSet && selectedGroupSet.groups.length === 0) {
-
-
group_off
-
There are no groups in this set
+ @if (!selectedGroupSet || groups.length === 0) {
+
+
+
+ } @else if (dataSource.data.length === 0) {
+
+
+ @if (hasFilters) {
+
+ Clear filters
+
+ }
- } @else {
-
+ }
+
+ @if (project) {
+
+ @for (group of dataSource.data; track group.id) {
+
+
+
+
{{ group.name || 'Unnamed group' }}
+ @if (group.locked || selectedGroupSet.locked) {
+ lock
+ }
+
+
+
+
+
Tutorial
+ {{ groupTutorialLabel(group) }}
+
+
+
Capacity
+ {{ groupCapacityLabel(group) }}
+
+
+
+
+ @if (isPartOfGroup(project, group)) {
+ Joined
+ View members
+ } @else if (canStudentJoin(group)) {
+
+ Join group
+
+ } @else if (!group.hasSpace()) {
+ Group is full
+ } @else if (group.locked || selectedGroupSet.locked) {
+ Group is locked
+ } @else {
+ Membership is managed by the unit team
+ }
+
+
+
+ }
+
+ }
+
+
+
+
+
- @if (
- selectedGroupSet &&
- selectedGroupSet.keepGroupsInSameClass &&
- selectedGroupSet.groups.length > 0 &&
- !unitRole
- ) {
-
- Can't see the group you need to join? Groups shown are limited to those in your allocated
- tutorials. Use the
- Tutorial List to check and update
- your tutorial enrolment if needed.
+ @if (project && !unitRole && selectedGroupSet?.keepGroupsInSameClass && groups.length > 0) {
+
+ info
+
+ Only groups in your tutorials are shown. If a group you need is missing, check your
+ enrolment on the
+ tutorials page .
+
}
-
+
diff --git a/src/app/groups/group-selector/group-selector.component.spec.ts b/src/app/groups/group-selector/group-selector.component.spec.ts
new file mode 100644
index 0000000000..0c4dc27239
--- /dev/null
+++ b/src/app/groups/group-selector/group-selector.component.spec.ts
@@ -0,0 +1,357 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {FormsModule, ReactiveFormsModule} from '@angular/forms';
+import {MatButtonToggleModule} from '@angular/material/button-toggle';
+import {MatInputModule} from '@angular/material/input';
+import {MatSelectModule} from '@angular/material/select';
+import {MatTableModule} from '@angular/material/table';
+import {Subject, of, throwError} from 'rxjs';
+import {Group} from 'src/app/api/models/groups/group';
+import {GroupSet} from 'src/app/api/models/groups/group-set';
+import {Project} from 'src/app/api/models/project';
+import {Tutorial} from 'src/app/api/models/tutorial/tutorial';
+import {Unit} from 'src/app/api/models/unit';
+import {UnitRole} from 'src/app/api/models/unit-role';
+import {User} from 'src/app/api/models/user/user';
+import {GroupService} from 'src/app/api/services/group.service';
+import {UserService} from 'src/app/api/services/user.service';
+import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service';
+import {AlertService} from 'src/app/common/services/alert.service';
+import {GroupSelectorComponent} from './group-selector.component';
+
+interface Fixture {
+ unit: Unit;
+ setA: GroupSet;
+ setB: GroupSet;
+ tutorial: Tutorial;
+ untutored: Tutorial;
+}
+
+function user(id: number, name: string): User {
+ const result = new User();
+ result.id = id;
+ result.firstName = name;
+ result.lastName = '';
+ return result;
+}
+
+function makeUnit(): Fixture {
+ const unit = {id: 1} as unknown as Unit;
+
+ const tutorial = new Tutorial(unit);
+ tutorial.id = 10;
+ tutorial.abbreviation = 'LA1';
+ tutorial.tutor = user(5, 'Tess');
+
+ // A tutorial nobody has been put in charge of yet.
+ const untutored = new Tutorial(unit);
+ untutored.id = 11;
+ untutored.abbreviation = 'LA2';
+
+ const setA = new GroupSet(unit);
+ setA.id = 100;
+ setA.name = 'Assignment 1';
+ const setB = new GroupSet(unit);
+ setB.id = 200;
+ setB.name = 'Assignment 2';
+
+ Object.assign(unit, {
+ groupSets: [setA, setB],
+ tutorials: [tutorial, untutored],
+ });
+
+ return {unit, setA, setB, tutorial, untutored};
+}
+
+function addGroup(set: GroupSet, id: number, name: string, tutorial?: Tutorial): Group {
+ const group = new Group(set.unit);
+ group.id = id;
+ group.name = name;
+ group.groupSet = set;
+ group.tutorial = tutorial;
+ group.capacityAdjustment = 0;
+ set.groupsCache.add(group);
+ return group;
+}
+
+describe('GroupSelectorComponent', () => {
+ let groupService: {
+ create: ReturnType;
+ delete: ReturnType;
+ update: ReturnType;
+ };
+ let confirmationModal: {show: ReturnType};
+
+ function configure(template?: string) {
+ groupService = {create: vi.fn(), delete: vi.fn(), update: vi.fn()};
+ confirmationModal = {show: vi.fn()};
+
+ const testBed = TestBed.configureTestingModule({
+ declarations: [GroupSelectorComponent],
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+ MatTableModule,
+ MatButtonToggleModule,
+ MatInputModule,
+ MatSelectModule,
+ ],
+ providers: [
+ {provide: UserService, useValue: {currentUser: user(5, 'Tess')}},
+ {provide: GroupService, useValue: groupService},
+ {provide: AlertService, useValue: {success: vi.fn(), error: vi.fn()}},
+ {provide: ConfirmationModalService, useValue: confirmationModal},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ });
+
+ if (template !== undefined) {
+ testBed.overrideComponent(GroupSelectorComponent, {set: {template}});
+ }
+
+ return testBed.compileComponents();
+ }
+
+ function create(
+ unit: Unit,
+ inputs: Partial = {},
+ ): ComponentFixture {
+ const fixture = TestBed.createComponent(GroupSelectorComponent);
+ Object.assign(fixture.componentInstance, {unit, onSelect: vi.fn(), ...inputs});
+ fixture.detectChanges();
+ return fixture;
+ }
+
+ describe('logic', () => {
+ beforeEach(async () => {
+ await configure('');
+ });
+
+ it('lists the group set the parent chose, not always the first one', () => {
+ const {unit, setA, setB} = makeUnit();
+ addGroup(setA, 1, 'From set A');
+ addGroup(setB, 2, 'From set B');
+
+ const component = create(unit, {selectedGroupSet: setB}).componentInstance;
+
+ expect(component.selectedGroupSet).toBe(setB);
+ expect(component.dataSource.data.map((group) => group.name)).toEqual(['From set B']);
+ });
+
+ it('falls back to the first set when the parent gives none', () => {
+ const {unit, setA} = makeUnit();
+ addGroup(setA, 1, 'From set A');
+
+ const component = create(unit).componentInstance;
+
+ expect(component.selectedGroupSet).toBe(setA);
+ expect(component.dataSource.data.map((group) => group.name)).toEqual(['From set A']);
+ });
+
+ it('filters to my tutorials without throwing on a tutorial that has no tutor', () => {
+ const {unit, setA, tutorial, untutored} = makeUnit();
+ addGroup(setA, 1, 'Mine', tutorial);
+ addGroup(setA, 2, 'Nobody runs this', untutored);
+ addGroup(setA, 3, 'No tutorial at all');
+
+ const unitRole = {user: user(5, 'Tess')} as UnitRole;
+ const component = create(unit, {unitRole}).componentInstance;
+
+ component.staffTutorialFilter = 'mine';
+ expect(() => component.applyFilters()).not.toThrow();
+ expect(component.dataSource.data.map((group) => group.name)).toEqual(['Mine']);
+ });
+
+ it('redraws the list when a group leaves the cache', () => {
+ const {unit, setA} = makeUnit();
+ const doomed = addGroup(setA, 1, 'Doomed');
+ addGroup(setA, 2, 'Stays');
+
+ const component = create(unit, {unitRole: {} as UnitRole}).componentInstance;
+ expect(component.dataSource.data).toHaveLength(2);
+
+ setA.groupsCache.delete(doomed);
+
+ expect(component.dataSource.data.map((group) => group.name)).toEqual(['Stays']);
+ });
+
+ it('sorts group names the way people count', () => {
+ const {unit, setA} = makeUnit();
+ addGroup(setA, 1, 'Group 10');
+ addGroup(setA, 2, 'Group 2');
+ addGroup(setA, 3, 'Group 1');
+
+ const component = create(unit).componentInstance;
+
+ expect(component.dataSource.data.map((group) => group.name)).toEqual([
+ 'Group 1',
+ 'Group 2',
+ 'Group 10',
+ ]);
+ });
+
+ it('asks before deleting, and closes the deleted group even after an edit was cancelled', () => {
+ const {unit, setA} = makeUnit();
+ const group = addGroup(setA, 1, 'Doomed');
+ groupService.delete.mockReturnValue(of(undefined));
+
+ const component = create(unit, {
+ unitRole: {} as UnitRole,
+ selectedGroup: group,
+ }).componentInstance;
+ const onSelect = component.onSelect as ReturnType;
+
+ // Cancelling an edit leaves the edit marker as null, which used to make the
+ // delete path think the null group was being edited and stop there.
+ component.cancelEdit();
+ component.deleteGroup(new Event('click'), group);
+
+ expect(groupService.delete).not.toHaveBeenCalled();
+ expect(confirmationModal.show).toHaveBeenCalledTimes(1);
+
+ const confirm = confirmationModal.show.mock.calls[0][2] as () => void;
+ confirm();
+
+ expect(groupService.delete).toHaveBeenCalledWith(group, {cache: setA.groupsCache});
+ expect(component.selectedGroup).toBeNull();
+ expect(onSelect).toHaveBeenCalledWith(null);
+ });
+
+ it('keeps the row in edit mode and puts the old values back when a save fails', () => {
+ const {unit, setA, tutorial} = makeUnit();
+ const group = addGroup(setA, 1, 'Before', tutorial);
+ groupService.update.mockReturnValue(throwError(() => 'Name taken'));
+
+ const component = create(unit, {unitRole: {} as UnitRole}).componentInstance;
+
+ component.startEditGroup(new Event('click'), group);
+ component.formData.get('name').setValue('After');
+ component.saveEdit(new Event('click'));
+
+ expect(groupService.update).toHaveBeenCalledTimes(1);
+ expect(group.name).toBe('Before');
+ expect(component.editing(group)).toBe(true);
+ });
+
+ it('leaves edit mode without a request when nothing changed', () => {
+ const {unit, setA, tutorial} = makeUnit();
+ const group = addGroup(setA, 1, 'Same', tutorial);
+
+ const component = create(unit, {unitRole: {} as UnitRole}).componentInstance;
+
+ component.startEditGroup(new Event('click'), group);
+ component.saveEdit(new Event('click'));
+
+ expect(groupService.update).not.toHaveBeenCalled();
+ expect(component.editing(group)).toBe(false);
+ });
+
+ it('lets a student with no tutorial create a group, and shows them in it', () => {
+ const {unit, setA} = makeUnit();
+ const student = new Project(unit);
+ student.id = 42;
+
+ const created = new Group(unit);
+ created.id = 9;
+ created.name = 'Group 1';
+ created.groupSet = setA;
+ const response: Subject = new Subject();
+ groupService.create.mockReturnValue(response);
+
+ const component = create(unit, {project: student}).componentInstance;
+ const onSelect = component.onSelect as ReturnType;
+
+ expect(() => component.addGroup('')).not.toThrow();
+ const body = groupService.create.mock.calls[0][1].body;
+ expect(body.group.tutorial_id).toBe(10);
+
+ response.next(created);
+
+ expect(student.inGroup(created)).toBe(true);
+ expect(created.projects).toContain(student);
+ expect(onSelect).toHaveBeenCalledWith(created);
+ });
+
+ it('does not open a new group once the page has moved to another set', () => {
+ const {unit, setA, setB} = makeUnit();
+ const created = new Group(unit);
+ created.id = 9;
+ created.name = 'Group 1';
+ created.groupSet = setA;
+ const response: Subject = new Subject();
+ groupService.create.mockReturnValue(response);
+
+ const component = create(unit, {
+ unitRole: {user: user(5, 'Tess')} as UnitRole,
+ }).componentInstance;
+ const onSelect = component.onSelect as ReturnType;
+
+ component.addGroup('Late');
+ component.selectGroupSet(setB);
+ response.next(created);
+
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(component.selectedGroupSet).toBe(setB);
+ });
+
+ it('tells the parent when a different group set is picked', () => {
+ const {unit, setA, setB} = makeUnit();
+ addGroup(setA, 1, 'From set A');
+ addGroup(setB, 2, 'From set B');
+
+ const component = create(unit).componentInstance;
+ const emitted: GroupSet[] = [];
+ component.selectedGroupSetChange.subscribe((set) => emitted.push(set));
+
+ component.selectGroupSet(setB);
+
+ expect(emitted).toEqual([setB]);
+ expect(component.dataSource.data.map((group) => group.name)).toEqual(['From set B']);
+ });
+
+ it('only offers the set picker when the parent allows it', () => {
+ const {unit} = makeUnit();
+
+ expect(create(unit).componentInstance.canChooseGroupSet).toBe(true);
+ expect(create(unit, {showGroupSetSelector: false}).componentInstance.canChooseGroupSet).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(async () => {
+ await configure();
+ });
+
+ it('renders groups whose tutorial is missing instead of blanking the table', () => {
+ const {unit, setA, tutorial} = makeUnit();
+ addGroup(setA, 1, 'Has a tutorial', tutorial);
+ addGroup(setA, 2, 'Lost its tutorial');
+
+ const fixture = create(unit, {unitRole: {user: user(5, 'Tess')} as UnitRole});
+ const rows = Array.from(
+ (fixture.nativeElement as HTMLElement).querySelectorAll('tr.mat-mdc-row'),
+ ).map((row) => row.textContent.replace(/\s+/g, ' ').trim());
+
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toContain('Has a tutorial');
+ expect(rows[0]).toContain('LA1');
+ expect(rows[1]).toContain('Lost its tutorial');
+ expect(rows[1]).toContain('None');
+ });
+
+ it('labels every icon-only action button', () => {
+ const {unit, setA, tutorial} = makeUnit();
+ addGroup(setA, 1, 'Alpha', tutorial);
+
+ const fixture = create(unit, {unitRole: {user: user(5, 'Tess')} as UnitRole});
+ const labels = Array.from(
+ (fixture.nativeElement as HTMLElement).querySelectorAll('td button[mat-icon-button]'),
+ ).map((button) => button.getAttribute('aria-label'));
+
+ expect(labels).toEqual(['Edit Alpha', 'Lock Alpha', 'Delete Alpha']);
+ });
+ });
+});
diff --git a/src/app/groups/group-selector/group-selector.component.ts b/src/app/groups/group-selector/group-selector.component.ts
index 0a9b475738..5d2b2e3cfd 100644
--- a/src/app/groups/group-selector/group-selector.component.ts
+++ b/src/app/groups/group-selector/group-selector.component.ts
@@ -1,10 +1,13 @@
import {
- AfterViewInit,
ChangeDetectionStrategy,
Component,
+ ElementRef,
+ EventEmitter,
Input,
OnChanges,
+ OnDestroy,
OnInit,
+ Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
@@ -18,6 +21,7 @@ import {Project} from 'src/app/api/models/project';
import {Unit} from 'src/app/api/models/unit';
import {GroupService} from 'src/app/api/services/group.service';
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,20 +33,27 @@ import {AlertService} from 'src/app/common/services/alert.service';
})
export class GroupSelectorComponent
extends EntityFormComponent
- implements OnInit, OnChanges, AfterViewInit
+ implements OnInit, OnChanges, OnDestroy
{
@Input() unit: Unit;
@Input() unitRole: UnitRole;
@Input() project: Project;
@Input() selectedGroup: Group;
@Input() selectedGroupSet: GroupSet;
+ // The unit administration page picks the group set itself, so it turns this off.
+ @Input() showGroupSetSelector = true;
@Input() onSelect: (group: Group) => void;
+ @Output() selectedGroupSetChange: EventEmitter = new EventEmitter();
+
+ @ViewChild(MatPaginator, {static: true}) paginator: MatPaginator;
+ @ViewChild('newGroupInput') newGroupInput?: ElementRef;
+ @ViewChild('newGroupButton', {read: ElementRef}) newGroupButton?: ElementRef;
- @ViewChild(MatPaginator) paginator!: MatPaginator;
- displayedColumns: string[] = ['name', 'tutorial', 'capacity_adjustment', 'capacity', 'actions'];
public groups: Group[] = [];
- public newGroupName: string;
+ public searchText = '';
+ public newGroupName = '';
+ public creatingGroup = false;
public staffTutorialFilter: 'all' | 'mine' = 'all';
private groupsSub?: Subscription;
@@ -51,6 +62,7 @@ export class GroupSelectorComponent
private userService: UserService,
private groupService: GroupService,
private alertService: AlertService,
+ private confirmationModal: ConfirmationModalService,
) {
super(
{
@@ -60,67 +72,107 @@ export class GroupSelectorComponent
},
'Group',
);
+ this.dataSource = new MatTableDataSource([]);
+ }
+
+ public get canChooseGroupSet(): boolean {
+ return this.showGroupSetSelector !== false && (this.unit?.groupSets.length ?? 0) > 1;
+ }
+
+ public get canCreateGroup(): boolean {
+ return (
+ !!this.selectedGroupSet &&
+ (!!this.unitRole || this.selectedGroupSet.allowStudentsToCreateGroups)
+ );
+ }
+
+ public get displayedColumns(): string[] {
+ return this.unitRole
+ ? ['name', 'tutorial', 'capacity_adjustment', 'members', 'actions']
+ : ['name', 'tutorial', 'members', 'actions'];
}
- public get showGroupSetSelector() {
- return this.unit.groupSets.length > 1;
+ public get hasFilters(): boolean {
+ return !!this.searchText.trim() || this.staffTutorialFilter !== 'all';
}
ngOnInit(): void {
- if (this.unit.groupSets.length > 0) {
+ this.dataSource.paginator = this.paginator;
+
+ // Keep a set the parent chose. This used to be overwritten with the first set, so
+ // picking the second set in unit administration still listed the first set's groups.
+ if (!this.selectedGroupSet && this.unit?.groupSets.length > 0) {
this.selectedGroupSet = this.unit.groupSets[0];
}
- }
- selectGroupSet(groupSet: GroupSet) {
- this.selectedGroupSet = groupSet;
this.refreshGroups();
}
- ngAfterViewInit() {
- this.dataSource = new MatTableDataSource();
- this.dataSource.paginator = this.paginator;
+ ngOnChanges(changes: SimpleChanges): void {
+ const setChange = changes['selectedGroupSet'];
+ const unitChange = changes['unit'];
+ if ((!setChange || setChange.firstChange) && (!unitChange || unitChange.firstChange)) {
+ return;
+ }
- if (this.unit.groupSets.length > 0) {
- this.selectedGroupSet = this.unit.groupSets[0];
+ if (!this.selectedGroupSet || this.selectedGroupSet.unit?.id !== this.unit?.id) {
+ this.selectedGroupSet = this.unit?.groupSets[0];
}
+ this.cancelEdit();
+ this.closeNewGroup();
this.refreshGroups();
}
+ ngOnDestroy(): void {
+ this.groupsSub?.unsubscribe();
+ }
+
+ selectGroupSet(groupSet: GroupSet) {
+ this.selectedGroupSet = groupSet;
+ this.cancelEdit();
+ this.closeNewGroup();
+ this.refreshGroups();
+ this.selectedGroupSetChange.emit(groupSet);
+ }
+
refreshGroups() {
this.groupsSub?.unsubscribe();
+ this.groups = [];
+
+ // The cache announces every create, edit and delete. The table used to copy the
+ // groups here without redrawing, so a deleted group stayed on screen.
this.groupsSub = this.selectedGroupSet?.groupsCache.values.subscribe((values) => {
this.groups = [...values];
+ this.applyFilters();
});
- this.applyFilters();
- }
- onGroupNameChange() {
this.applyFilters();
}
applyFilters() {
+ const search = this.searchText.trim().toLowerCase();
+ const myUserId = this.unitRole?.user?.id;
+
+ // A tutorial with no tutor, or a group whose tutorial is gone, used to throw here
+ // and blank the list as soon as My tutorials was picked.
const filteredGroups = this.groups
.filter(
- (g) =>
+ (group) =>
this.staffTutorialFilter === 'all' ||
- (this.unitRole && g.tutorial.tutor.id === this.unitRole.user.id),
+ (myUserId != null && group.tutorial?.tutor?.id === myUserId),
)
- .filter(
- (g) => !this.newGroupName || g.name.toLowerCase().includes(this.newGroupName.toLowerCase()),
- );
+ .filter((group) => !search || (group.name ?? '').toLowerCase().includes(search));
- this.dataSource.data = filteredGroups.sort((a, b) => a.name.localeCompare(b.name));
+ this.dataSource.data = filteredGroups.sort((a, b) =>
+ (a.name ?? '').localeCompare(b.name ?? '', undefined, {numeric: true}),
+ );
}
- ngOnChanges(changes: SimpleChanges) {
- if (changes['selectedGroupSet'] && this.selectedGroupSet) {
- if (!this.dataSource) {
- this.dataSource = new MatTableDataSource();
- }
- this.refreshGroups();
- }
+ clearFilters() {
+ this.searchText = '';
+ this.staffTutorialFilter = 'all';
+ this.applyFilters();
}
onTutorialFilterChange(event: MatButtonToggleChange) {
@@ -128,7 +180,29 @@ export class GroupSelectorComponent
this.applyFilters();
}
+ openNewGroup() {
+ this.newGroupName = '';
+ this.creatingGroup = true;
+ // The field only exists after the next render, so move focus to it then.
+ setTimeout(() => this.newGroupInput?.nativeElement.focus());
+ }
+
+ closeNewGroup(returnFocus = false) {
+ const wasOpen = this.creatingGroup;
+ this.newGroupName = '';
+ this.creatingGroup = false;
+
+ // Cancel removes the button that had focus, so hand focus back to New group.
+ if (returnFocus && wasOpen) {
+ setTimeout(() => this.newGroupButton?.nativeElement.focus());
+ }
+ }
+
addGroup(name: string) {
+ if (!this.selectedGroupSet) {
+ return;
+ }
+
if (this.unit.tutorials.length == 0) {
this.alertService.error(
`Please ensure there is at least one tutorial before groups are created`,
@@ -136,28 +210,32 @@ export class GroupSelectorComponent
);
return;
}
- let tutorialId;
+
+ let tutorialId: number;
if (this.project) {
- tutorialId = this.project.tutorials[0].id || this.unit.tutorials[0].id;
+ // A student with no tutorial yet used to throw here instead of creating the group.
+ tutorialId = this.project.tutorials[0]?.id ?? this.unit.tutorials[0].id;
} else {
- const tutorName = this.unitRole?.user.name || this.userService.currentUser.name;
+ const tutorName = this.unitRole?.user?.name || this.userService.currentUser.name;
tutorialId =
this.unit.tutorials.find((t) => t.tutor?.name === tutorName)?.id ??
this.unit.tutorials[0].id;
}
+ const groupSet = this.selectedGroupSet;
+
this.groupService
.create(
{
unitId: this.unit.id,
- groupSetId: this.selectedGroupSet.id,
+ groupSetId: groupSet.id,
},
{
- cache: this.selectedGroupSet.groupsCache,
+ cache: groupSet.groupsCache,
constructorParams: this.unit,
body: {
group: {
- name,
+ name: name?.trim() ?? '',
tutorial_id: tutorialId,
},
},
@@ -166,9 +244,24 @@ export class GroupSelectorComponent
.subscribe({
next: (group) => {
this.alertService.success('Successfully created group', 3000);
- this.selectedGroup = group;
- this.newGroupName = '';
+
+ // The server puts a student into the group they create. Show that here too,
+ // or the page offers them a Join button for their own group.
+ if (this.project) {
+ this.project.groupCache.add(group);
+ group.projectsCache.add(this.project);
+ }
+
+ // Someone may have moved to another set while this was saving. Only open the
+ // new group if its set is still the one on screen.
+ if (groupSet !== this.selectedGroupSet) {
+ return;
+ }
+
+ // The form, and the button that had focus, go away; hand focus to New group.
+ this.closeNewGroup(true);
this.applyFilters();
+ this.selectGroup(group);
},
error: (error) => {
this.alertService.error(`Failed to create group: ${error}`);
@@ -180,6 +273,41 @@ export class GroupSelectorComponent
return group && project?.inGroup(group);
}
+ /** Staff can open any group. A student can only open a group they are in. */
+ canSelect(group: Group): boolean {
+ return !!group && (!this.project || !!this.project.inGroup(group));
+ }
+
+ capacityFor(group: Group): number | null {
+ const capacity = group.groupSet?.capacity;
+ return capacity == null ? null : capacity + (group.capacityAdjustment ?? 0);
+ }
+
+ public groupTutorialLabel(group: Group): string {
+ return group?.tutorial?.abbreviation || 'Not assigned';
+ }
+
+ public groupCapacityLabel(group: Group): string {
+ const configuredCapacity = group?.groupSet?.capacity;
+ if (configuredCapacity === null || configuredCapacity === undefined) {
+ return `${group?.memberCount ?? 0} members, no set limit`;
+ }
+
+ const capacity = Math.max(0, configuredCapacity + (group?.capacityAdjustment ?? 0));
+ return `${group?.memberCount ?? 0} of ${capacity} places`;
+ }
+
+ public canStudentJoin(group: Group): boolean {
+ return Boolean(
+ this.project &&
+ !this.isPartOfGroup(this.project, group) &&
+ this.selectedGroupSet?.allowStudentsToManageGroups &&
+ !group.locked &&
+ !this.selectedGroupSet.locked &&
+ group.hasSpace(),
+ );
+ }
+
joinGroup(group: Group) {
if (!this.project) {
return;
@@ -197,7 +325,7 @@ export class GroupSelectorComponent
}
selectGroup(group: Group) {
- if (this.project && !this.project.inGroup(group)) {
+ if (!this.canSelect(group)) {
// Return because we're in the student view
return;
}
@@ -207,18 +335,38 @@ export class GroupSelectorComponent
}
this.selectedGroup = group;
- this.onSelect(group);
+ this.onSelect?.(group);
}
deleteGroup(event: Event, group: Group) {
event.stopPropagation();
+ const members = group.memberCount;
+ const who =
+ members > 0
+ ? ` Its ${members} ${members === 1 ? 'member' : 'members'} will no longer be in a group.`
+ : '';
+
+ // Staff can delete a group that still has members, so ask before doing it.
+ this.confirmationModal.show(
+ 'Delete group',
+ `Delete ${group.name || 'this group'}?${who} This cannot be undone.`,
+ () => this.removeGroup(group),
+ // Cancelling needs no message; without a handler the dialog reports it as a toast.
+ () => undefined,
+ 'Delete group',
+ );
+ }
+
+ removeGroup(group: Group) {
this.groupService.delete(group, {cache: this.selectedGroupSet.groupsCache}).subscribe({
next: () => {
this.alertService.success('Deleted group', 3000);
if (group.id === this.selectedGroup?.id) {
+ // Tell the parent directly. Going through selectGroup(null) was stopped by
+ // its edit check, so the members of the deleted group stayed on screen.
this.selectedGroup = null;
- this.selectGroup(null);
+ this.onSelect?.(null);
}
},
error: (error) => {
@@ -257,11 +405,28 @@ export class GroupSelectorComponent
saveEdit(event: Event) {
event.stopPropagation();
+
+ if (this.formData.invalid) {
+ this.formData.markAllAsTouched();
+ return;
+ }
+
+ if (!this.hasChanges()) {
+ this.cancelEdit();
+ return;
+ }
+
+ // Stay in edit mode until the server answers. submit() only puts the old values
+ // back after a failed save while the row is still being edited, and leaving edit
+ // mode straight away used to keep the rejected values on screen.
super.submit(this.groupService, this.alertService, this.onSuccess.bind(this));
- this.cancelEdit();
}
onSuccess(): void {
this.refreshGroups();
}
+
+ sameEntity(a: {id: number} | null, b: {id: number} | null): boolean {
+ return a === b || (!!a && !!b && a.id === b.id);
+ }
}
diff --git a/src/app/groups/group-selector/group-selector.mobile.spec.ts b/src/app/groups/group-selector/group-selector.mobile.spec.ts
new file mode 100644
index 0000000000..f26367dccf
--- /dev/null
+++ b/src/app/groups/group-selector/group-selector.mobile.spec.ts
@@ -0,0 +1,64 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {of} from 'rxjs';
+import {Group, GroupSet, Project, Unit} from 'src/app/api/models/doubtfire-model';
+import {GroupSelectorComponent} from './group-selector.component';
+
+describe('GroupSelectorComponent student card contract', () => {
+ let component: GroupSelectorComponent;
+ let project: Project;
+ let groupSet: GroupSet;
+ let group: Group;
+
+ beforeEach(() => {
+ component = new GroupSelectorComponent(
+ {currentUser: {name: 'Demo Student'}} as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ );
+ project = {inGroup: vi.fn(() => false)} as unknown as Project;
+ groupSet = {
+ groupsCache: {values: of([])},
+ capacity: 4,
+ locked: false,
+ allowStudentsToManageGroups: true,
+ } as unknown as GroupSet;
+ group = {
+ id: 40,
+ name: 'Team Indigo',
+ groupSet,
+ memberCount: 3,
+ capacityAdjustment: 0,
+ locked: false,
+ tutorial: {abbreviation: 'ST1'},
+ hasSpace: () => true,
+ } as unknown as Group;
+
+ component.project = project;
+ component.selectedGroupSet = groupSet;
+ });
+
+ it('derives tutorial and capacity copy from the authorised group entity', () => {
+ expect(component.groupTutorialLabel(group)).toBe('ST1');
+ expect(component.groupCapacityLabel(group)).toBe('3 of 4 places');
+ });
+
+ it('enables joining only when the real group-set policy, lock, and capacity permit it', () => {
+ expect(component.canStudentJoin(group)).toBe(true);
+
+ group.locked = true;
+ expect(component.canStudentJoin(group)).toBe(false);
+ group.locked = false;
+ group.hasSpace = () => false;
+ expect(component.canStudentJoin(group)).toBe(false);
+ });
+
+ it('preserves the group set selected by the project state', () => {
+ const otherGroupSet = {id: 90} as GroupSet;
+ component.unit = {groupSets: [otherGroupSet, groupSet]} as unknown as Unit;
+
+ component.ngOnInit();
+
+ expect(component.selectedGroupSet).toBe(groupSet);
+ });
+});
diff --git a/src/app/groups/group-set-manager/group-set-manager.component.scss b/src/app/groups/group-set-manager/group-set-manager.component.scss
index e69de29bb2..e9611b7377 100644
--- a/src/app/groups/group-set-manager/group-set-manager.component.scss
+++ b/src/app/groups/group-set-manager/group-set-manager.component.scss
@@ -0,0 +1,53 @@
+:host {
+ display: block;
+}
+
+// Fields in the members card run at 48px (Material density -2), like the toolbar
+// fields in the groups card beside it.
+.group-name-form .mat-mdc-form-field,
+.member-add .mat-mdc-form-field {
+ --mat-form-field-container-height: 48px;
+ --mat-form-field-container-vertical-padding: 12px;
+}
+
+.group-work-layout {
+ display: grid;
+ gap: 1.5rem;
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
+ min-width: 0;
+ width: 100%;
+}
+
+.group-work-layout__selector,
+.group-work-layout__members {
+ min-width: 0;
+ width: 100%;
+}
+
+.group-work-layout__members {
+ align-self: stretch;
+ display: flex;
+ flex-direction: column;
+}
+
+.group-members-header {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ min-width: 0;
+}
+
+.group-members-title {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 799.98px) {
+ .group-work-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/src/app/groups/group-set-selector/group-set-selector.component.html b/src/app/groups/group-set-selector/group-set-selector.component.html
index 09ff83321d..efded6d802 100644
--- a/src/app/groups/group-set-selector/group-set-selector.component.html
+++ b/src/app/groups/group-set-selector/group-set-selector.component.html
@@ -1,4 +1,5 @@
-
+
+ Group set
@for (gs of unit.groupSets; track gs.id) {
{{ gs.name }}
diff --git a/src/app/groups/group-set-selector/group-set-selector.component.scss b/src/app/groups/group-set-selector/group-set-selector.component.scss
deleted file mode 100644
index 6dee9e802c..0000000000
--- a/src/app/groups/group-set-selector/group-set-selector.component.scss
+++ /dev/null
@@ -1,7 +0,0 @@
-.groupset-selector .dropdown {
- cursor: pointer;
-}
-
-.lockButton {
- width: 70px;
-}
diff --git a/src/app/groups/group-set-selector/group-set-selector.component.ts b/src/app/groups/group-set-selector/group-set-selector.component.ts
index b6b7103d67..03104cdbde 100644
--- a/src/app/groups/group-set-selector/group-set-selector.component.ts
+++ b/src/app/groups/group-set-selector/group-set-selector.component.ts
@@ -11,7 +11,6 @@ import {GroupSet, Unit} from 'src/app/api/models/doubtfire-model';
@Component({
selector: 'group-set-selector',
templateUrl: './group-set-selector.component.html',
- styleUrls: ['./group-set-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
})
diff --git a/src/app/home/splash-screen/LoadingService.service.ts b/src/app/home/splash-screen/LoadingService.service.ts
deleted file mode 100644
index ff66ce1229..0000000000
--- a/src/app/home/splash-screen/LoadingService.service.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import {Injectable} from '@angular/core';
-import {BehaviorSubject} from 'rxjs';
-
-@Injectable({
- providedIn: 'root',
-})
-export class LoadingService {
- private loadingSubject: BehaviorSubject = new BehaviorSubject(false);
-
- loading$ = this.loadingSubject.asObservable();
-
- loadingOn() {
- this.loadingSubject.next(true);
- }
-
- loadingOff() {
- this.loadingSubject.next(false);
- }
-}
diff --git a/src/app/home/splash-screen/splash-screen.component.html b/src/app/home/splash-screen/splash-screen.component.html
index 405c731d39..79ca0e76ff 100644
--- a/src/app/home/splash-screen/splash-screen.component.html
+++ b/src/app/home/splash-screen/splash-screen.component.html
@@ -1,7 +1,84 @@
-@if (loading$ | async) {
-
-
-
+@if (startupState$ | async; as state) {
+ @if (state.status === 'loading' && state.phase === 'authentication') {
+
+
+
+
+ OnTrack
+
+
+ @for (message of [state.message || 'Checking your session…']; track message) {
+
+ {{ message }}
+
+ }
+
+
-
+ } @else if (state.status === 'loading') {
+
+
+ @if (state.message) {
+ {{ state.message }}
+ }
+
+ } @else if (state.status === 'error' || state.status === 'offline') {
+
+
+ {{ state.status === 'offline' ? 'wifi_off' : 'sync_problem' }}
+
+
+
+ {{ state.status === 'offline' ? 'You’re offline' : 'OnTrack couldn’t finish loading' }}
+
+
{{ state.message }}
+
+ Try again
+ @if (state.phase === 'authentication') {
+ Go to sign in
+ }
+
+
+
+ }
}
diff --git a/src/app/home/splash-screen/splash-screen.component.scss b/src/app/home/splash-screen/splash-screen.component.scss
index 32454821fe..acea497488 100644
--- a/src/app/home/splash-screen/splash-screen.component.scss
+++ b/src/app/home/splash-screen/splash-screen.component.scss
@@ -1,28 +1,431 @@
+// Theme tokens only. These screens show on every start, before any page, in
+// whichever theme the pre-boot script in index.html picked.
+//
+// The layout and timings below are mirrored by the pre-boot loader in
+// src/index.html. Change one and change the other, or the hand-off jumps.
+
+$ease-draw: cubic-bezier(0.77, 0, 0.175, 1);
+$ease-out: cubic-bezier(0.23, 1, 0.32, 1);
+$ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
+// Fast sessions never see the splash, so it does not flash on every start.
+$appear-delay: 180ms;
+
.app-splash-screen {
- background: #fff;
+ background: var(--ot-color-page);
position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+ inset: 0;
+ z-index: 100;
display: flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
width: 100%;
height: 100%;
- z-index: 100;
-}
-
-.app-label {
- margin-top: 32px;
- color: #fff;
- font-size: 2.5em;
- font-family: 'Grotesk';
}
.app-splash-inner {
display: flex;
flex-direction: column;
- justify-content: center;
align-items: center;
+ opacity: 0;
+ animation: splash-appear 1ms linear $appear-delay forwards;
+}
+
+.app-splash-mark {
+ position: relative;
+ width: 88px;
+ height: 51px;
+
+ svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ }
+}
+
+// The three strokes draw along their own direction, short one first. Each is
+// clipped to nothing and the clip opens from the end the stroke starts at.
+.app-splash-stroke {
+ animation: splash-draw-down 360ms $ease-draw both;
+}
+
+.app-splash-stroke-short {
+ animation-name: splash-draw-up;
+ animation-delay: $appear-delay;
+}
+
+.app-splash-stroke-middle {
+ animation-delay: $appear-delay + 120ms;
+}
+
+.app-splash-stroke-long {
+ animation-delay: $appear-delay + 240ms;
+}
+
+// A soft band of light crossing the mark every 2.4s, cut to the logo shape.
+.app-splash-sheen {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ -webkit-mask: url('/assets/images/logo.svg') center / contain no-repeat;
+ mask: url('/assets/images/logo.svg') center / contain no-repeat;
+
+ span {
+ position: absolute;
+ inset-block: 0;
+ left: 0;
+ width: 40%;
+ background: linear-gradient(
+ 105deg,
+ transparent 0%,
+ color-mix(in srgb, var(--ot-color-on-primary) 45%, transparent) 50%,
+ transparent 100%
+ );
+ transform: translateX(-100%);
+ animation: splash-sheen 2.4s linear $appear-delay + 900ms infinite;
+ }
+}
+
+.app-splash-wordmark {
+ margin-top: 18px;
+ color: var(--ot-color-text);
+ font-family: Inter, 'open-sans', Roboto, system-ui, sans-serif;
+ font-size: 22px;
+ font-weight: 700;
+ line-height: 28px;
+ letter-spacing: -0.01em;
+ opacity: 0;
+ animation: splash-rise 200ms $ease-out $appear-delay + 520ms both;
+}
+
+.app-splash-status {
+ display: grid;
+ min-height: 20px;
+ margin-top: 16px;
+ opacity: 0;
+ animation: splash-fade 200ms $ease-out $appear-delay + 600ms both;
+
+ // Messages share one cell so the outgoing and incoming text crossfade in place.
+ p {
+ grid-area: 1 / 1;
+ margin: 0;
+ color: var(--ot-color-text-muted);
+ font-family: Inter, 'open-sans', Roboto, system-ui, sans-serif;
+ font-size: 14px;
+ line-height: 20px;
+ letter-spacing: 0.01em;
+ text-align: center;
+ }
+}
+
+.app-splash-status-enter {
+ animation: splash-status-in 200ms $ease-out both;
+}
+
+.app-splash-status-leave {
+ animation: splash-status-out 150ms $ease-out both;
+}
+
+// Leaving is quicker than arriving. The page underneath is already there.
+.app-splash-leave {
+ pointer-events: none;
+ animation: splash-fade-out 150ms $ease-out both;
+
+ // The appear animation stays in the list so a splash that leaves before its
+ // delay is up never shows its content on the way out.
+ .app-splash-inner {
+ animation:
+ splash-appear 1ms linear $appear-delay forwards,
+ splash-shrink 150ms $ease-out forwards;
+ }
+}
+
+// The pre-boot loader in index.html already drew the mark and wordmark, so pick
+// up where it left off instead of drawing them again.
+:host-context([data-ot-boot-splash]) {
+ .app-splash-inner,
+ .app-splash-wordmark {
+ opacity: 1;
+ animation: none;
+ }
+
+ .app-splash-stroke {
+ animation: none;
+ }
+
+ .app-splash-sheen span {
+ animation-delay: 400ms;
+ }
+
+ .app-splash-status {
+ animation-delay: 0ms;
+ }
+
+ .app-splash-leave .app-splash-inner {
+ animation: splash-shrink 150ms $ease-out both;
+ }
+}
+
+@keyframes splash-appear {
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes splash-draw-down {
+ from {
+ clip-path: inset(0 100% 100% 0);
+ }
+ to {
+ clip-path: inset(0);
+ }
+}
+
+@keyframes splash-draw-up {
+ from {
+ clip-path: inset(100% 100% 0 0);
+ }
+ to {
+ clip-path: inset(0);
+ }
+}
+
+@keyframes splash-sheen {
+ 0% {
+ transform: translateX(-100%);
+ animation-timing-function: $ease-in-out;
+ }
+ 45%,
+ 100% {
+ transform: translateX(250%);
+ }
+}
+
+@keyframes splash-rise {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes splash-fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes splash-status-in {
+ from {
+ opacity: 0;
+ filter: blur(2px);
+ }
+ to {
+ opacity: 1;
+ filter: blur(0);
+ }
+}
+
+@keyframes splash-status-out {
+ from {
+ opacity: 1;
+ filter: blur(0);
+ }
+ to {
+ opacity: 0;
+ filter: blur(2px);
+ }
+}
+
+@keyframes splash-fade-out {
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes splash-shrink {
+ to {
+ transform: scale(0.98);
+ }
+}
+
+.startup-progress {
+ position: fixed;
+ top: var(--header-height, 80px);
+ right: 0;
+ left: 0;
+ z-index: 90;
+ height: 0;
+ pointer-events: none;
+}
+
+// A thin indeterminate line on the top edge of the content. The track is fixed
+// edge to edge and one segment travels along it.
+.startup-progress-track {
+ position: absolute;
+ inset-inline: 0;
+ top: 0;
+ height: 2px;
+ overflow: hidden;
+ background: color-mix(in srgb, var(--ot-color-primary) 18%, transparent);
+
+ span {
+ position: absolute;
+ inset-block: 0;
+ left: 0;
+ width: 36%;
+ background: var(--ot-color-primary);
+ transform: translateX(-100%);
+ animation: startup-progress-travel 1.4s $ease-in-out infinite;
+ }
+}
+
+// Only worth reading when the wait is long, so it arrives after 1.5s.
+.startup-progress-caption {
+ position: absolute;
+ top: 10px;
+ left: 50%;
+ padding: 2px 10px;
+ border: 1px solid var(--ot-color-divider);
+ border-radius: 999px;
+ background: var(--ot-color-surface);
+ color: var(--ot-color-text-muted);
+ font-size: 12px;
+ line-height: 18px;
+ white-space: nowrap;
+ opacity: 0;
+ transform: translateX(-50%);
+ animation: splash-fade 200ms $ease-out 1500ms both;
+}
+
+@keyframes startup-progress-travel {
+ from {
+ transform: translateX(-100%);
+ }
+ to {
+ transform: translateX(278%);
+ }
+}
+
+@keyframes startup-progress-pulse {
+ 0%,
+ 100% {
+ opacity: 0.45;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+// No movement at all. The mark and wordmark are simply there, the bar breathes
+// in opacity, and the only thing kept is the short delay before anything shows.
+@media (prefers-reduced-motion: reduce) {
+ .app-splash-stroke,
+ :host-context([data-ot-boot-splash]) .app-splash-stroke {
+ animation: none;
+ }
+
+ .app-splash-sheen {
+ display: none;
+ }
+
+ .app-splash-wordmark,
+ .app-splash-status {
+ animation-name: splash-fade;
+ animation-duration: 1ms;
+ animation-timing-function: linear;
+ animation-delay: $appear-delay;
+ }
+
+ :host-context([data-ot-boot-splash]) .app-splash-status {
+ animation-delay: 0ms;
+ }
+
+ .app-splash-status-enter,
+ .app-splash-status-leave {
+ animation-name: splash-fade;
+ filter: none;
+ }
+
+ .app-splash-status-leave {
+ animation-name: splash-fade-out;
+ }
+
+ .app-splash-leave .app-splash-inner {
+ animation: splash-appear 1ms linear $appear-delay forwards;
+ }
+
+ :host-context([data-ot-boot-splash]) .app-splash-leave .app-splash-inner {
+ animation: none;
+ }
+
+ .startup-progress-track {
+ background: var(--ot-color-primary);
+ animation: startup-progress-pulse 2s ease-in-out infinite;
+
+ span {
+ display: none;
+ }
+ }
+
+ .startup-progress-caption {
+ animation-duration: 1ms;
+ }
+}
+
+.startup-recovery {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ padding: 2rem;
+ background: var(--ot-color-page);
+ color: var(--ot-color-text);
+ text-align: left;
+
+ mat-icon {
+ width: 3rem;
+ height: 3rem;
+ color: var(--ot-color-text-muted);
+ font-size: 3rem;
+ }
+
+ h1 {
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+ }
+
+ p {
+ max-width: 32rem;
+ margin: 0;
+ color: var(--ot-color-text-muted);
+ }
+}
+
+.startup-recovery-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1.25rem;
+}
+
+@media (width <= 600px) {
+ .startup-progress {
+ top: var(--mobile-header-height, 64px);
+ }
+
+ .startup-recovery {
+ align-items: flex-start;
+ padding-top: max(7rem, env(safe-area-inset-top));
+ }
}
diff --git a/src/app/home/splash-screen/splash-screen.component.spec.ts b/src/app/home/splash-screen/splash-screen.component.spec.ts
index 6f13f45e5f..36ad1849be 100644
--- a/src/app/home/splash-screen/splash-screen.component.spec.ts
+++ b/src/app/home/splash-screen/splash-screen.component.spec.ts
@@ -1,35 +1,136 @@
-import {beforeEach, describe, expect, it} from 'vitest';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {CommonModule} from '@angular/common';
import {NO_ERRORS_SCHEMA} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
-import {GlobalStateService} from 'src/app/projects/states/index/global-state.service';
-import {LoadingService} from './LoadingService.service';
+import {BehaviorSubject} from 'rxjs';
+import {GlobalStateService, StartupState} from 'src/app/projects/states/index/global-state.service';
import {SplashScreenComponent} from './splash-screen.component';
-const emptyProvider = {};
-
describe('SplashScreenComponent', () => {
- let component: SplashScreenComponent;
let fixture: ComponentFixture
;
+ let startupState: BehaviorSubject;
+ let globalState: {
+ startupStateSubject: BehaviorSubject;
+ retryStartup: ReturnType;
+ continueToSignIn: ReturnType;
+ };
beforeEach(async () => {
+ startupState = new BehaviorSubject({
+ status: 'loading',
+ phase: 'authentication',
+ message: 'Checking your session…',
+ attempt: 1,
+ startedAt: Date.now(),
+ });
+ globalState = {
+ startupStateSubject: startupState,
+ retryStartup: vi.fn(),
+ continueToSignIn: vi.fn(),
+ };
+
await TestBed.configureTestingModule({
+ imports: [CommonModule],
declarations: [SplashScreenComponent],
- providers: [
- {provide: GlobalStateService, useValue: emptyProvider},
- {provide: LoadingService, useValue: emptyProvider},
- ],
+ providers: [{provide: GlobalStateService, useValue: globalState}],
schemas: [NO_ERRORS_SCHEMA],
- })
- .overrideComponent(SplashScreenComponent, {set: {template: ''}})
- .compileComponents();
- });
+ }).compileComponents();
- beforeEach(() => {
fixture = TestBed.createComponent(SplashScreenComponent);
- component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('uses the full-screen logo only while authentication is active', () => {
+ expect(
+ fixture.nativeElement.querySelector('[data-testid="startup-auth-loading"]'),
+ ).toBeTruthy();
+
+ startupState.next({
+ status: 'loading',
+ phase: 'units-and-projects',
+ message: 'Loading your units…',
+ attempt: 1,
+ startedAt: Date.now(),
+ });
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('[data-testid="startup-auth-loading"]')).toBeFalsy();
+ expect(
+ fixture.nativeElement.querySelector('[data-testid="startup-data-loading"]'),
+ ).toBeTruthy();
+ });
+
+ it('draws the mark, the wordmark and the current status while signing in', () => {
+ const splash = fixture.nativeElement.querySelector('[data-testid="startup-auth-loading"]');
+ const mark = splash.querySelector('[data-testid="startup-mark"]');
+
+ expect(splash.getAttribute('role')).toBe('status');
+ expect(splash.getAttribute('aria-live')).toBe('polite');
+ expect(mark.querySelectorAll('svg path')).toHaveLength(3);
+ expect(mark.getAttribute('aria-hidden')).toBe('true');
+ expect(splash.querySelector('[data-testid="startup-wordmark"]').textContent.trim()).toBe(
+ 'OnTrack',
+ );
+ expect(splash.querySelector('[data-testid="startup-status"]').textContent.trim()).toBe(
+ 'Checking your session…',
+ );
+ expect(splash.querySelector('img')).toBeFalsy();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ it('swaps the status text in place when the message changes', () => {
+ startupState.next({
+ status: 'loading',
+ phase: 'authentication',
+ message: 'Still checking your session…',
+ attempt: 2,
+ startedAt: Date.now(),
+ });
+ fixture.detectChanges();
+
+ const statuses = fixture.nativeElement.querySelectorAll('[data-testid="startup-status"]');
+ expect(statuses).toHaveLength(1);
+ expect(statuses[0].textContent.trim()).toBe('Still checking your session…');
+ });
+
+ it('shows a thin progress line with the message as a caption while data loads', () => {
+ startupState.next({
+ status: 'loading',
+ phase: 'units-and-projects',
+ message: 'Loading your units…',
+ attempt: 1,
+ startedAt: Date.now(),
+ });
+ fixture.detectChanges();
+
+ const progress = fixture.nativeElement.querySelector('[data-testid="startup-data-loading"]');
+ expect(progress.getAttribute('role')).toBe('status');
+ expect(progress.getAttribute('aria-live')).toBe('polite');
+ expect(progress.querySelector('.startup-progress-track span')).toBeTruthy();
+ expect(progress.querySelector('.startup-progress-caption').textContent.trim()).toBe(
+ 'Loading your units…',
+ );
+ });
+
+ it('renders a terminal recovery action instead of an indefinite logo', () => {
+ startupState.next({
+ status: 'error',
+ phase: 'authentication',
+ message: 'OnTrack could not finish starting. Please try again.',
+ attempt: 1,
+ startedAt: Date.now(),
+ elapsedMs: 12_000,
+ });
+ fixture.detectChanges();
+
+ const recovery = fixture.nativeElement.querySelector('[data-testid="startup-recovery"]');
+ const buttons = Array.from(recovery.querySelectorAll('button')) as HTMLButtonElement[];
+ expect(recovery.textContent).toContain('OnTrack couldn’t finish loading');
+ expect(buttons.map((button) => button.textContent.trim())).toEqual([
+ 'Try again',
+ 'Go to sign in',
+ ]);
+
+ buttons[0].click();
+ expect(globalState.retryStartup).toHaveBeenCalledOnce();
});
});
diff --git a/src/app/home/splash-screen/splash-screen.component.ts b/src/app/home/splash-screen/splash-screen.component.ts
index f53ae1d1ad..d20b22fcfa 100644
--- a/src/app/home/splash-screen/splash-screen.component.ts
+++ b/src/app/home/splash-screen/splash-screen.component.ts
@@ -1,8 +1,6 @@
-import {AnimationOptions} from 'ngx-lottie';
-import {ChangeDetectionStrategy, Component, ContentChild, OnInit, TemplateRef} from '@angular/core';
+import {ChangeDetectionStrategy, Component} from '@angular/core';
import {Observable} from 'rxjs';
-import {GlobalStateService} from 'src/app/projects/states/index/global-state.service';
-import {LoadingService} from './LoadingService.service';
+import {GlobalStateService, StartupState} from 'src/app/projects/states/index/global-state.service';
@Component({
selector: 'splash-screen',
@@ -11,34 +9,18 @@ import {LoadingService} from './LoadingService.service';
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
})
-export class SplashScreenComponent implements OnInit {
- constructor(
- private globalState: GlobalStateService,
- private loadingService: LoadingService,
- ) {
- this.loading$ = this.loadingService.loading$;
- }
-
- loading$: Observable;
+export class SplashScreenComponent {
+ readonly startupState$: Observable;
- @ContentChild('loading')
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- customLoadingIndicator: TemplateRef | null = null;
+ constructor(private globalState: GlobalStateService) {
+ this.startupState$ = this.globalState.startupStateSubject.asObservable();
+ }
- options: AnimationOptions = {
- loop: true,
- autoplay: true,
- path: '../../../assets/images/formatif-isolated-lottie.json',
- };
+ retry(): void {
+ this.globalState.retryStartup();
+ }
- public ngOnInit(): void {
- this.globalState.isLoadingSubject.subscribe((isLoading) => {
- if (isLoading) {
- this.loadingService.loadingOn();
- }
- if (!isLoading) {
- this.loadingService.loadingOff();
- }
- });
+ continueToSignIn(): void {
+ this.globalState.continueToSignIn();
}
}
diff --git a/src/app/home/states/lti-dashboard/lti-dashboard.component.scss b/src/app/home/states/lti-dashboard/lti-dashboard.component.scss
index e69de29bb2..0864295936 100644
--- a/src/app/home/states/lti-dashboard/lti-dashboard.component.scss
+++ b/src/app/home/states/lti-dashboard/lti-dashboard.component.scss
@@ -0,0 +1,16 @@
+// A calm error note in the error tone, in place of the old red Tailwind palette box.
+.lti-callout {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+ border: 1px solid color-mix(in srgb, var(--ot-color-error) 35%, var(--ot-color-divider));
+ border-radius: var(--ot-radius-md);
+ background: color-mix(in srgb, var(--ot-color-error) 8%, var(--ot-color-surface));
+ color: var(--ot-color-text);
+
+ > .mat-icon {
+ flex: none;
+ color: var(--ot-color-error);
+ }
+}
diff --git a/src/app/home/states/lti-dashboard/lti-dashboard.component.spec.ts b/src/app/home/states/lti-dashboard/lti-dashboard.component.spec.ts
new file mode 100644
index 0000000000..01f83096b5
--- /dev/null
+++ b/src/app/home/states/lti-dashboard/lti-dashboard.component.spec.ts
@@ -0,0 +1,114 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MatButtonModule} from '@angular/material/button';
+import {MatIconModule, MatIconRegistry} from '@angular/material/icon';
+import {Router} from '@angular/router';
+import {Subscription, of, throwError} from 'rxjs';
+import {ProjectService} from 'src/app/api/models/doubtfire-model';
+import {AuthenticationService} from 'src/app/api/services/authentication.service';
+import {LtiService} from 'src/app/api/services/lti.service';
+import {UnitService} from 'src/app/api/services/unit.service';
+import {UserService} from 'src/app/api/services/user.service';
+import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service';
+import {CsvResultModalService} from 'src/app/common/modals/csv-result-modal/csv-result-modal.service';
+import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service';
+import {AlertService} from 'src/app/common/services/alert.service';
+import {LtiDashboardComponent} from './lti-dashboard.component';
+
+describe('LtiDashboardComponent', () => {
+ let fixture: ComponentFixture;
+ let component: LtiDashboardComponent;
+ let ltiService: Record>;
+
+ beforeEach(async () => {
+ // The page scrolls itself on a timer that can fire after a spy is restored, and jsdom
+ // has no scrollTo, so stand one in for the whole file.
+ Object.defineProperty(window, 'scrollTo', {
+ configurable: true,
+ writable: true,
+ value: () => undefined,
+ });
+ ltiService = {
+ getUnitLink: vi.fn(() => of({unitId: 7})),
+ enrolUser: vi.fn(() => of(null)),
+ syncStudentsGrades: vi.fn(() => throwError(() => 'LMS is down')),
+ removeUnitLink: vi.fn(() => throwError(() => 'Not allowed')),
+ };
+
+ await TestBed.configureTestingModule({
+ declarations: [LtiDashboardComponent],
+ imports: [MatButtonModule, MatIconModule],
+ providers: [
+ {provide: Router, useValue: {navigateByUrl: vi.fn()}},
+ {provide: LtiService, useValue: ltiService},
+ {provide: UserService, useValue: {currentUser: {systemRole: 'Convenor', ltik: 'x'}}},
+ {
+ provide: AuthenticationService,
+ useValue: {
+ afterAuthCall: (callback: () => void) => {
+ callback();
+ return new Subscription();
+ },
+ },
+ },
+ {provide: AlertService, useValue: {error: vi.fn(), success: vi.fn()}},
+ {provide: UnitService, useValue: {get: vi.fn(() => of({id: 7, code: 'SIT374'}))}},
+ {provide: ProjectService, useValue: {}},
+ // Run the confirm step straight away, as if the convenor pressed yes.
+ {
+ provide: ConfirmationModalService,
+ useValue: {show: (...args: unknown[]) => (args[2] as () => void)()},
+ },
+ {provide: CsvResultModalService, useValue: {show: vi.fn()}},
+ {provide: SidekiqProgressModalService, useValue: {show: vi.fn()}},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ // The logo is an svg icon from the registry, which a spec has no reason to load.
+ vi.spyOn(TestBed.inject(MatIconRegistry), 'getNamedSvgIcon').mockReturnValue(
+ of(document.createElementNS('http://www.w3.org/2000/svg', 'svg')),
+ );
+
+ fixture = TestBed.createComponent(LtiDashboardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function button(label: string): HTMLButtonElement {
+ return Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('button')).find(
+ (candidate) => candidate.textContent.includes(label),
+ ) as HTMLButtonElement;
+ }
+
+ // Both sync buttons were round icons with aria-label="", so they had no name at all.
+ it('names both sync actions in words', () => {
+ expect(button('Sync enrolments')).toBeTruthy();
+ expect(button('Sync grades')).toBeTruthy();
+ });
+
+ // A failed grade sync set the syncing flag back to true, so both buttons stayed
+ // disabled and spinning until the page was reloaded.
+ it('lets the convenor try again after a grade sync fails', () => {
+ component.syncStudentsGrades();
+ fixture.detectChanges();
+
+ expect(component.isSyncingGrades).toBe(false);
+ expect(button('Sync grades').disabled).toBe(false);
+ });
+
+ it('shows the message from a failed unlink instead of undefined', () => {
+ const alerts = TestBed.inject(AlertService) as unknown as {error: ReturnType};
+
+ component.removeLink();
+
+ expect(alerts.error).toHaveBeenCalledWith('Failed to unlink the unit: Not allowed', 6000);
+ });
+});
diff --git a/src/app/home/states/lti-unit-link/lti-unit-link.component.html b/src/app/home/states/lti-unit-link/lti-unit-link.component.html
index 452bfc8bc6..7db217b833 100644
--- a/src/app/home/states/lti-unit-link/lti-unit-link.component.html
+++ b/src/app/home/states/lti-unit-link/lti-unit-link.component.html
@@ -1,44 +1,55 @@
-
-
+
+
-
OnTrack
+ OnTrack
@if (loadingUnits) {
-
Loading...
+
+
+ Loading your units...
+
+ } @else if (loadUnitsFailed) {
+
+
error_outline
+
+
Your units could not be loaded.
+
Try again
+
+
} @else if (!activeUnits.length) {
-
- You must already be a Convenor or Admin to link a unit. You are not currently assigned to
- any units.
+
+ You must already be a convenor or admin of a unit to link it. You are not assigned to any
+ units yet.
} @else {
-
- Students will be automatically enrolled in this unit when they launch the OnTrack tool from
- this course.
+
+ Students are enrolled in the unit you link when they launch OnTrack from this course.
-
- Select a unit to link
+
+ Unit to link
- @for (unit of activeUnits; track unit) {
- {{ unit.code }} {{ unit.name }} ({{ getTeachingPeriod(unit) }})
+ @for (unit of activeUnits; track unit.id) {
+
+ {{ unit.code }} {{ unit.name }} ({{ getTeachingPeriod(unit) }})
+
}
- Confirm
+ Link unit
}
diff --git a/src/app/home/states/lti-unit-link/lti-unit-link.component.scss b/src/app/home/states/lti-unit-link/lti-unit-link.component.scss
index e69de29bb2..0864295936 100644
--- a/src/app/home/states/lti-unit-link/lti-unit-link.component.scss
+++ b/src/app/home/states/lti-unit-link/lti-unit-link.component.scss
@@ -0,0 +1,16 @@
+// A calm error note in the error tone, in place of the old red Tailwind palette box.
+.lti-callout {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+ border: 1px solid color-mix(in srgb, var(--ot-color-error) 35%, var(--ot-color-divider));
+ border-radius: var(--ot-radius-md);
+ background: color-mix(in srgb, var(--ot-color-error) 8%, var(--ot-color-surface));
+ color: var(--ot-color-text);
+
+ > .mat-icon {
+ flex: none;
+ color: var(--ot-color-error);
+ }
+}
diff --git a/src/app/home/states/lti-unit-link/lti-unit-link.component.spec.ts b/src/app/home/states/lti-unit-link/lti-unit-link.component.spec.ts
new file mode 100644
index 0000000000..28538b5c32
--- /dev/null
+++ b/src/app/home/states/lti-unit-link/lti-unit-link.component.spec.ts
@@ -0,0 +1,84 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MatButtonModule} from '@angular/material/button';
+import {MatIconModule, MatIconRegistry} from '@angular/material/icon';
+import {Router} from '@angular/router';
+import {Subscription, of, throwError} from 'rxjs';
+import {CreateNewUnitModal} from 'src/app/admin/modals/create-new-unit-modal/create-new-unit-modal.component';
+import {AuthenticationService} from 'src/app/api/services/authentication.service';
+import {LtiService} from 'src/app/api/services/lti.service';
+import {UnitService} from 'src/app/api/services/unit.service';
+import {UserService} from 'src/app/api/services/user.service';
+import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service';
+import {AlertService} from 'src/app/common/services/alert.service';
+import {LtiUnitLinkComponent} from './lti-unit-link.component';
+
+describe('LtiUnitLinkComponent', () => {
+ let fixture: ComponentFixture
;
+ let fetchAll: ReturnType;
+
+ beforeEach(async () => {
+ // The page scrolls itself on a timer that can fire after a spy is restored, and jsdom
+ // has no scrollTo, so stand one in for the whole file.
+ Object.defineProperty(window, 'scrollTo', {
+ configurable: true,
+ writable: true,
+ value: () => undefined,
+ });
+ fetchAll = vi.fn(() => throwError(() => 'Server error'));
+
+ await TestBed.configureTestingModule({
+ declarations: [LtiUnitLinkComponent],
+ imports: [MatButtonModule, MatIconModule],
+ providers: [
+ {provide: CreateNewUnitModal, useValue: {}},
+ {provide: UnitService, useValue: {fetchAll}},
+ {
+ provide: AuthenticationService,
+ useValue: {
+ afterAuthCall: (callback: () => void) => {
+ callback();
+ return new Subscription();
+ },
+ },
+ },
+ {provide: ConfirmationModalService, useValue: {show: vi.fn()}},
+ {provide: AlertService, useValue: {error: vi.fn(), success: vi.fn()}},
+ {provide: LtiService, useValue: {}},
+ {provide: UserService, useValue: {currentUser: {ltik: 'x'}}},
+ {provide: Router, useValue: {navigateByUrl: vi.fn()}},
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ // The logo is an svg icon from the registry, which a spec has no reason to load.
+ vi.spyOn(TestBed.inject(MatIconRegistry), 'getNamedSvgIcon').mockReturnValue(
+ of(document.createElementNS('http://www.w3.org/2000/svg', 'svg')),
+ );
+
+ fixture = TestBed.createComponent(LtiUnitLinkComponent);
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // A failed request left the page on "Loading..." for good, with no way to try again.
+ it('stops loading and offers a retry when the units cannot be fetched', () => {
+ fixture.detectChanges();
+ const page = fixture.nativeElement as HTMLElement;
+
+ expect(page.querySelector('[role="status"]')).toBeNull();
+ const alert = page.querySelector('[role="alert"]');
+ expect(alert?.textContent).toContain('Your units could not be loaded.');
+
+ fetchAll.mockReturnValue(of([]));
+ (alert?.querySelector('button') as HTMLButtonElement).click();
+ fixture.detectChanges();
+
+ expect(fetchAll).toHaveBeenCalledTimes(2);
+ expect(page.textContent).toContain('You are not assigned to any');
+ });
+});
diff --git a/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.scss b/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.scss
index 3a2b24d37b..8a04e7951e 100644
--- a/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.scss
+++ b/src/app/projects/project-progress-dashboard/project-progress-dashboard.component.scss
@@ -7,5 +7,5 @@ h2,
h3,
h4,
p {
- color: black;
+ color: var(--ot-color-text);
}
diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/engagement-passport-card.component.ts b/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/engagement-passport-card.component.ts
index 305e2c4e60..5acbaa3844 100644
--- a/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/engagement-passport-card.component.ts
+++ b/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/engagement-passport-card.component.ts
@@ -49,34 +49,34 @@ export class EngagementPassportCardComponent implements OnChanges {
private readonly fallbackPresentation: EngagementPresentation = {
label: 'Other engagement',
icon: 'star',
- classes: 'border-gray-300 bg-gray-50 text-gray-700',
+ classes: 'border-ot-border bg-ot-raised text-ot-muted',
};
private readonly presentations: Record = {
attendance: {
label: 'Class attendance',
icon: 'groups',
- classes: 'border-green-300 bg-green-50 text-green-700',
+ classes: 'border-ot-success bg-ot-raised text-ot-success',
},
discussion: {
label: 'Discussion',
icon: 'record_voice_over',
- classes: 'border-cyan-300 bg-cyan-50 text-cyan-700',
+ classes: 'border-ot-info bg-ot-raised text-ot-info',
},
forum: {
label: 'Forum post',
icon: 'forum',
- classes: 'border-blue-300 bg-blue-50 text-blue-700',
+ classes: 'border-ot-link bg-ot-raised text-ot-link',
},
email: {
label: 'Tutor email',
icon: 'mail',
- classes: 'border-violet-300 bg-violet-50 text-violet-700',
+ classes: 'border-ot-primary bg-ot-raised text-ot-primary',
},
attention: {
label: 'Needs attention',
icon: 'feedback',
- classes: 'border-yellow-300 bg-yellow-50 text-yellow-700',
+ classes: 'border-ot-warning bg-ot-raised text-ot-warning',
},
};
@@ -94,6 +94,10 @@ export class EngagementPassportCardComponent implements OnChanges {
return this.project?.unit?.currentUnitWeek ?? null;
}
+ get hasStamps(): boolean {
+ return this.weeks.some((week) => week.stamps.length > 0);
+ }
+
get currentUserCanAddEngagement(): boolean {
const currentUserId = this.userService.currentUser?.id;
return (
diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.scss b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.scss
index e69de29bb2..5d99684744 100644
--- a/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.scss
+++ b/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.component.scss
@@ -0,0 +1,35 @@
+:host {
+ display: block;
+ color: var(--ot-color-text);
+}
+
+// Establish an inline-size container so the grid inside responds to the width of the
+// dashboard pane (which the resizable task list changes) rather than the viewport. The
+// @lg:* and @3xl:* utilities in this template and the planner card's are anonymous
+// container queries and bind to this.
+// Tailwind's own @container utility does not emit container-type in this build, so set
+// it explicitly here.
+.dashboard-frame {
+ container-type: inline-size;
+}
+
+a {
+ color: var(--ot-color-link);
+ text-underline-offset: 3px;
+
+ &:focus-visible {
+ outline: 2px solid var(--ot-color-focus);
+ outline-offset: 3px;
+ }
+}
+
+mat-form-field {
+ --mat-form-field-filled-container-color: var(--ot-color-surface-raised);
+ --mat-form-field-filled-disabled-container-color: var(--ot-color-disabled-surface);
+ --mat-form-field-filled-label-text-color: var(--ot-color-text-muted);
+ --mat-form-field-filled-hover-label-text-color: var(--ot-color-text);
+ --mat-form-field-filled-input-text-color: var(--ot-color-text);
+ --mat-form-field-filled-input-text-placeholder-color: var(--ot-color-text-muted);
+ --mat-form-field-filled-active-indicator-color: var(--ot-color-border);
+ --mat-form-field-filled-hover-active-indicator-color: var(--ot-color-text-muted);
+}
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.html
index b2ea951404..725244ba65 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.html
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.html
@@ -1,9 +1,9 @@
-
-
- comment
-
Student Notes for {{ project?.student?.name }}
-
-
Use these notes for private staff discussions about the student. Students cannot view them.
+
-
-
+
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.spec.ts
new file mode 100644
index 0000000000..701b3cfc3d
--- /dev/null
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.spec.ts
@@ -0,0 +1,37 @@
+import {beforeEach, describe, expect, it} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {StaffNotesViewComponent} from './staff-notes-view.component';
+
+describe('StaffNotesViewComponent', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [StaffNotesViewComponent],
+ // The notes list itself is covered by its own spec.
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(StaffNotesViewComponent);
+ });
+
+ it('heads the tab with a title and says who the notes are about', () => {
+ fixture.componentRef.setInput('project', {student: {name: 'Ada Lovelace'}});
+ fixture.detectChanges();
+
+ const header = fixture.nativeElement.querySelector('header') as HTMLElement;
+ expect(header.querySelector('h2').textContent.trim()).toBe('Student notes');
+ expect(header.querySelector('p').textContent.replace(/\s+/g, ' ').trim()).toBe(
+ 'Private notes about Ada Lovelace for staff. Students cannot see them.',
+ );
+ expect(fixture.nativeElement.querySelector('f-staff-notes')).not.toBeNull();
+ });
+
+ it('lays itself out as a full-height flex column', () => {
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.classList).toContain('h-full');
+ expect(fixture.nativeElement.classList).toContain('flex-col');
+ });
+});
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.ts
index e61a9d1a43..9ac19923f2 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.ts
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/staff-notes-view/staff-notes-view.component.ts
@@ -4,6 +4,8 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
selector: 'f-staff-notes-view',
templateUrl: './staff-notes-view.component.html',
styleUrls: ['./staff-notes-view.component.scss'],
+ // The notes list scrolls between the header and the composer, so the tab fills its host.
+ host: {class: 'flex h-full min-h-0 flex-col'},
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
})
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.scss
index e69de29bb2..7a131e6421 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.scss
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.scss
@@ -0,0 +1,169 @@
+$dot-size: 12px;
+
+// The card measures its own width, so the timeline and the buttons can change shape
+// when the column is narrow, whatever the page around it is doing.
+:host {
+ display: block;
+ container-type: inline-size;
+}
+
+// Start, due and feedback dates as a timeline: one column per date, a dot on a
+// line that runs to the next date's dot.
+.task-dates {
+ display: grid;
+ grid-auto-columns: minmax(0, 1fr);
+ grid-auto-flow: column;
+ margin: 1.25rem 0 0.25rem;
+ padding: 0;
+ list-style: none;
+}
+
+.task-dates__step {
+ position: relative;
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 2px;
+ padding: calc(#{$dot-size} + 10px) 12px 0 0;
+}
+
+// The line to the next date. It fills in as the time between the two dates runs out.
+.task-dates__step:not(:last-child)::before {
+ position: absolute;
+ top: calc(#{$dot-size} / 2 - 1px);
+ right: 0;
+ left: $dot-size;
+ height: 2px;
+ background: linear-gradient(
+ to right,
+ var(--ot-color-primary) calc(var(--task-dates-progress, 0) * 100%),
+ var(--ot-color-border) 0
+ );
+ content: '';
+}
+
+.task-dates__dot {
+ position: absolute;
+ top: 0;
+ left: 0;
+ box-sizing: border-box;
+ width: $dot-size;
+ height: $dot-size;
+ border: 2px solid var(--ot-color-border);
+ border-radius: 50%;
+ background: var(--ot-color-surface);
+}
+
+.task-dates__step--passed .task-dates__dot {
+ border-color: var(--ot-color-primary);
+ background: var(--ot-color-primary);
+}
+
+.task-dates__step--next .task-dates__dot {
+ border-color: var(--ot-color-primary);
+ box-shadow: 0 0 0 3px var(--ot-color-selected);
+}
+
+.task-dates__label {
+ color: var(--ot-color-text-muted);
+ font-size: 0.75rem;
+ line-height: 1.2;
+ overflow-wrap: break-word;
+}
+
+.task-dates__date {
+ white-space: nowrap;
+ color: var(--ot-color-text);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.task-dates__when {
+ color: var(--ot-color-link);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.task-dates__note {
+ align-self: flex-start;
+ margin-top: 2px;
+ padding: 1px 6px;
+ border: 1px solid var(--ot-color-divider);
+ border-radius: 999px;
+ color: var(--ot-color-text-muted);
+ font-size: 0.6875rem;
+ line-height: 1.4;
+}
+
+.task-dates__warning {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ color: var(--ot-color-warning);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.2;
+
+ mat-icon {
+ width: 14px;
+ height: 14px;
+ font-size: 14px;
+ }
+}
+
+// Too narrow for one column per date: the dates run down the card instead, each dot
+// on a vertical line to the next, so labels keep their words and dates never touch.
+@container (max-width: 440px) {
+ .task-dates {
+ grid-auto-flow: row;
+ grid-auto-columns: auto;
+ gap: 0;
+ }
+
+ .task-dates__step {
+ padding: 0 0 0.875rem calc(#{$dot-size} + 12px);
+ }
+
+ .task-dates__step:not(:last-child)::before {
+ top: $dot-size;
+ right: auto;
+ bottom: 0;
+ left: calc(#{$dot-size} / 2 - 1px);
+ width: 2px;
+ height: auto;
+ background: linear-gradient(
+ to bottom,
+ var(--ot-color-primary) calc(var(--task-dates-progress, 0) * 100%),
+ var(--ot-color-border) 0
+ );
+ }
+
+ .task-dates__dot {
+ top: 2px;
+ }
+}
+
+// The action buttons wrap, keep a 44px touch height and let a long label wrap
+// inside the button rather than run past its edge.
+.task-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+
+ .mat-mdc-button-base {
+ min-width: 0;
+ max-width: 100%;
+ height: auto;
+ min-height: 44px;
+ white-space: normal;
+ }
+}
+
+// On a narrow card each button takes a full-width row of its own.
+@container (max-width: 440px) {
+ .task-actions .mat-mdc-button-base {
+ flex: 1 1 100%;
+ }
+}
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html
index 0e308f0ffa..2b560106a3 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.html
@@ -5,12 +5,12 @@
@if (task?.isDueSoon() && !task?.isPastDueDate()) {
-
+
- Aim To Complete Soon - Due in {{ task?.timeUntilDueDateDescription() }}
+
+ Aim to complete soon – due in {{ task?.timeUntilDueDateDescription() }}
+
@@ -55,7 +55,7 @@
@if (task?.betweenDueDateAndDeadlineDate()) {
-
+
warning
@if (flexibleDatesEnabled) {
@@ -69,7 +69,7 @@
@@ -120,7 +120,7 @@
@if (task?.isPastDeadline()) {
-
+
error
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.scss
index e69de29bb2..bff52bc12a 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.scss
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.scss
@@ -0,0 +1,20 @@
+.task-due-card--soon {
+ mat-card-header {
+ padding: 1rem 1rem 0.5rem;
+ }
+
+ mat-card-title {
+ overflow-wrap: anywhere;
+ font-size: clamp(1.125rem, 4vw, 1.5rem);
+ line-height: 1.25;
+ }
+
+ mat-card-content {
+ padding: 0.5rem 1rem 1rem;
+
+ p {
+ margin: 0;
+ line-height: 1.5;
+ }
+ }
+}
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.spec.ts
index ea4330e5de..1bd3ffec7b 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.spec.ts
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-due-card/task-due-card.component.spec.ts
@@ -11,9 +11,7 @@ describe('TaskDueCardComponent', () => {
await TestBed.configureTestingModule({
declarations: [TaskDueCardComponent],
schemas: [NO_ERRORS_SCHEMA],
- })
- .overrideComponent(TaskDueCardComponent, {set: {template: ''}})
- .compileComponents();
+ }).compileComponents();
});
beforeEach(() => {
@@ -24,4 +22,31 @@ describe('TaskDueCardComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('renders the due-soon message in sentence case with a breathable responsive card', () => {
+ component.task = {
+ unit: {allowFlexibleDates: false},
+ inFinalState: () => false,
+ inTimeExceeded: () => false,
+ isPastDeadline: () => false,
+ inAwaitingFeedbackState: () => false,
+ isDueSoon: () => true,
+ isPastDueDate: () => false,
+ timeUntilDueDateDescription: () => '3 days',
+ inDiscussState: () => false,
+ localDueDateString: () => '1 Sep',
+ betweenDueDateAndDeadlineDate: () => false,
+ } as never;
+ fixture.detectChanges();
+
+ const card = fixture.nativeElement.querySelector('.task-due-card--soon');
+ expect(card).not.toBeNull();
+ expect(card.textContent).toContain('Aim to complete soon – due in 3 days');
+
+ const styles = (TaskDueCardComponent as unknown as {ɵcmp: {styles: string[]}}).ɵcmp.styles.join(
+ '\n',
+ );
+ expect(styles).toContain('padding: 1rem 1rem 0.5rem');
+ expect(styles).toContain('overflow-wrap: anywhere');
+ });
});
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.html
index a1b1b889de..5f48266105 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.html
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.html
@@ -1,133 +1,203 @@
-
- Refresh
-
-@if (loading) {
-
-
+
+
+
+ @if (!inDialog) {
+
+ Submission history
+
+ }
+
{{ historySummary }}
-} @else {
-
- @for (history of histories; track history.id; let idx = $index) {
-
-
-
- @if (assessmentFor(history); as oa) {
- @if (oa.taskStatus) {
+
+
+ refresh
+
+
+
+
+ @if (loading) {
+
+
+
+ } @else if (loadError) {
+
+
+ Try again
+
+ } @else if (histories.length === 0) {
+
+ } @else {
+
+ @for (history of histories; track history.id; let idx = $index) {
+ @let oa = assessmentFor(history);
+
+
+
+ @if (oa?.taskStatus) {
}
- }
-
-
- Submission {{ histories.length - idx }}
- @if (idx === 0) {
- (Most recent)
- }
- @if (isComparisonSource(history)) {
- (Selected for comparison)
- }
-
-
- {{ history.timestamp | date: 'dd/MM/yyyy HH:mm' }}
+
+
+ Submission {{ histories.length - idx }}
+ @if (idx === 0) {
+ (Most recent)
+ }
+ @if (isComparisonSource(history)) {
+ (Selected for comparison)
+ }
+
+
+ {{ history.timestamp | date: 'dd/MM/yyyy HH:mm' }}
+
-
-
- @if (assessmentFor(history); as oa) {
- @if (oa.reportReady) {
-
Click to view Overseer report
-
{{ oa.passedSteps }} / {{ oa.totalSteps }}
- @if (oa.passedSteps === oa.totalSteps) {
-
done
+
+ @if (oa) {
+ @if (oa.reportReady) {
+
+
+ {{ oa.passedSteps }}/{{ oa.totalSteps }}
+
+
+ {{ oa.passedSteps === oa.totalSteps ? 'check_circle' : 'cancel' }}
+
+
} @else {
- cancel
+
+
+ Tests in progress
+
}
- } @else {
-
- Tests In Progress
-
-
}
- }
- @if (history.hasSubmissionFiles && currentUnitRole) {
-
- more_vert
-
-
-
- folder_zip
- View Submission Files
+ @if (history.hasSubmissionFiles && currentUnitRole) {
+
+
+ more_vert
- @if (hasComparisonSourceFor(history)) {
-
- compare_arrows
- Compare With Selected
-
- } @else if (isComparisonSource(history)) {
-
- close
- Clear Compare Selection
-
- } @else {
+
- task_alt
- Select For Comparison
+ folder_zip
+ View submission files
- }
-
- }
-
-
+ @if (hasComparisonSourceFor(history)) {
+
+ compare_arrows
+ Compare with selected
+
+ } @else if (isComparisonSource(history)) {
+
+ close
+ Clear compare selection
+
+ } @else {
+
+ task_alt
+ Select for comparison
+
+ }
+
+ }
+
+
- @if (assessmentFor(history); as oa) {
-
-
+ @if (oa) {
+
@for (
result of oa.stepResultsCache.values | async;
track result.id;
- let idx = $index
+ let stepIdx = $index
) {
-
-
-
- Step {{ idx + 1 }}: {{ result.overseerStep?.displayName }}
-
- @if (result.pass) {
-
done
- } @else {
-
cancel
- }
+
+
+
+
+ Step {{ stepIdx + 1 }}: {{ result.overseerStep?.displayName }}
+
+
+ {{ result.pass ? 'check_circle' : 'cancel' }}
+
+ {{ result.pass ? 'Passed' : 'Failed' }}
-
-
-
@if (!result.pass) {
-
+
{{ result.feedbackMessage }}
}
@@ -137,36 +207,46 @@
result.expectedOutput !== result.stdout &&
(result.overseerStep?.stepType === 'output_diff' || !result.overseerStep)
) {
-
+
- Your Output
+ Your output
-
- Expected Output
+ Expected output
Diff
-
- Split Diff
+ Split diff
@if (viewOutput === 'diff' || viewOutput === 'split_diff') {
@@ -185,76 +265,72 @@
}
} @else if (viewOutput === 'your_output') {
} @else if (viewOutput === 'expected_output') {
}
} @else if (result.stdout) {
} @else if (result.pass) {
-
- SUCCESS
-
-
- (No Output)
-
+
Passed with no output.
}
}
@if (loadingAssessments.has(oa.id)) {
-
-
+
+
} @else {
- @for (skipped of oa.stepsSkipped; track skipped.id; let idx = $index) {
+ @for (skipped of oa.stepsSkipped; track skipped.id; let skippedIdx = $index) {
-
-
-
- Step {{ oa.stepResultsCache.currentValues.length + idx + 1 }}:
- {{ skipped?.displayName ?? '-' }}
- (Skipped)
-
-
- pause
+
+
+
+
+ Step {{ oa.stepResultsCache.currentValues.length + skippedIdx + 1 }}:
+ {{ skipped?.displayName ?? '-' }}
+ (Skipped)
+
+ pause
+
}
}
-
- }
- @if (history.hasSubmissionFiles && currentUnitRole) {
-
-
- View Submission
-
-
- }
-
- } @empty {
-
-
subtitles_off
-
No retained submissions for this task.
-
- }
-
-}
+ }
+ @if (history.hasSubmissionFiles && currentUnitRole) {
+
+
+ folder_zip
+ View submission files
+
+
+ }
+
+ }
+
+ }
+
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.scss
index e69de29bb2..8e9a076d16 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.scss
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.scss
@@ -0,0 +1,6 @@
+@use 'styles/mixins/token-expansion-panel' as *;
+
+// Each submission, and each Overseer step inside one, is its own card on the tab.
+.history-panel {
+ @include token-expansion-panel;
+}
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.spec.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.spec.ts
new file mode 100644
index 0000000000..a42885a555
--- /dev/null
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.spec.ts
@@ -0,0 +1,165 @@
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MatButtonModule} from '@angular/material/button';
+import {MatDialog} from '@angular/material/dialog';
+import {Observable, Subject, of, throwError} from 'rxjs';
+import {UserService} from 'src/app/api/models/doubtfire-model';
+import {SubmissionHistory} from 'src/app/api/models/submission-history';
+import {Task} from 'src/app/api/models/task';
+import {OverseerAssessmentService} from 'src/app/api/services/overseer-assessment.service';
+import {OverseerStepResultService} from 'src/app/api/services/overseer-step-result.service';
+import {SubmissionHistoryService} from 'src/app/api/services/submission-history.service';
+import {EmptyStateComponent} from 'src/app/common/empty-state/empty-state.component';
+import {AlertService} from 'src/app/common/services/alert.service';
+import {TaskOverseerReportComponent} from './task-overseer-report.component';
+
+// The files dialog imports Monaco, which reaches for browser APIs jsdom does not have as
+// soon as it loads. The dialog is never opened in these tests.
+vi.mock('monaco-editor', () => ({}));
+
+function taskStub(): Task {
+ return {
+ unit: {staff: []},
+ definition: {overseerStepsCache: {currentValues: []}},
+ } as unknown as Task;
+}
+
+function historyStub(id: number): SubmissionHistory {
+ return {id, timestamp: new Date(2026, 8, id, 10, 30)} as SubmissionHistory;
+}
+
+describe('TaskOverseerReportComponent', () => {
+ let fixture: ComponentFixture;
+ let nextHistories: () => Observable;
+ let queryHistories: ReturnType;
+
+ beforeEach(async () => {
+ nextHistories = () => of([]);
+ queryHistories = vi.fn(() => nextHistories());
+
+ await TestBed.configureTestingModule({
+ declarations: [TaskOverseerReportComponent],
+ // The real button, so disabledInteractive behaves as it does in the app.
+ imports: [EmptyStateComponent, MatButtonModule],
+ providers: [
+ {provide: AlertService, useValue: {error: vi.fn(), message: vi.fn()}},
+ {provide: SubmissionHistoryService, useValue: {queryForTask: queryHistories}},
+ {provide: OverseerAssessmentService, useValue: {queryForTask: () => of([])}},
+ {provide: OverseerStepResultService, useValue: {}},
+ {provide: MatDialog, useValue: {}},
+ {provide: UserService, useValue: {currentUser: {id: 1}}},
+ ],
+ // The Material panels, menus and editors are not under test here.
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+ });
+
+ function render(loadOverseerAssessmentId?: number, inDialog = false): void {
+ fixture = TestBed.createComponent(TaskOverseerReportComponent);
+ fixture.componentRef.setInput('task', taskStub());
+ if (loadOverseerAssessmentId) {
+ fixture.componentRef.setInput('loadOverseerAssessmentId', loadOverseerAssessmentId);
+ }
+ fixture.componentRef.setInput('inDialog', inDialog);
+ fixture.detectChanges();
+ }
+
+ function refreshButton(): HTMLButtonElement {
+ return fixture.nativeElement.querySelector('button[aria-label="Refresh history"]');
+ }
+
+ function text(): string {
+ return fixture.nativeElement.textContent.replace(/\s+/g, ' ');
+ }
+
+ function buttonLabelled(label: string): HTMLButtonElement | undefined {
+ return Array.from(fixture.nativeElement.querySelectorAll('button')).find(
+ (button) => button.textContent.trim() === label,
+ );
+ }
+
+ it('gives the tab a header with a named refresh button that reloads the history', () => {
+ render();
+
+ expect(fixture.nativeElement.querySelector('h2').textContent).toContain('Submission history');
+ const refresh = refreshButton();
+ expect(refresh).not.toBeNull();
+ expect(refresh.getAttribute('type')).toBe('button');
+
+ refresh.click();
+ fixture.detectChanges();
+
+ expect(queryHistories).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps refresh focusable while loading and ignores it until the load ends', () => {
+ const pending: Subject = new Subject();
+ nextHistories = () => pending;
+ render();
+
+ const refresh = refreshButton();
+ expect(refresh.hasAttribute('disabled')).toBe(false);
+ expect(refresh.getAttribute('aria-disabled')).toBe('true');
+
+ refresh.click();
+ expect(queryHistories).toHaveBeenCalledTimes(1);
+
+ pending.next([]);
+ pending.complete();
+ fixture.detectChanges();
+ expect(refresh.getAttribute('aria-disabled')).toBeNull();
+ });
+
+ it('leaves the title to the Overseer dialog but keeps the count and refresh', () => {
+ render(undefined, true);
+
+ expect(fixture.nativeElement.querySelector('h2')).toBeNull();
+ expect(text()).toContain('Past submissions of this task, newest first.');
+ expect(refreshButton()).not.toBeNull();
+ });
+
+ it('shows the empty state when no earlier submissions are kept', () => {
+ render();
+
+ expect(text()).toContain('No earlier submissions');
+ expect(text()).toContain(
+ 'A copy is kept each time the student submits, for files the task marks Keep in history.',
+ );
+ expect(fixture.nativeElement.querySelector('mat-accordion')).toBeNull();
+ });
+
+ it('lists the kept submissions newest first and counts them in the header', () => {
+ nextHistories = () => of([historyStub(2), historyStub(1)]);
+ render();
+
+ const panels = fixture.nativeElement.querySelectorAll('mat-expansion-panel');
+ expect(panels.length).toBe(2);
+ expect(panels[0].textContent).toContain('Submission 2');
+ expect(panels[0].textContent).toContain('(Most recent)');
+ expect(panels[1].textContent).toContain('Submission 1');
+ expect(panels[1].textContent).not.toContain('(Most recent)');
+ expect(text()).toContain('2 submissions kept, newest first.');
+ expect(text()).not.toContain('No earlier submissions');
+ });
+
+ it('shows an error state with Try again when the history fails to load', () => {
+ nextHistories = () => throwError(() => new Error('offline'));
+ render(5);
+
+ expect(text()).toContain('The history did not load');
+ expect(text()).not.toContain('No earlier submissions');
+
+ nextHistories = () => of([historyStub(1)]);
+ const tryAgain = buttonLabelled('Try again');
+ expect(tryAgain).toBeDefined();
+ tryAgain.click();
+ fixture.detectChanges();
+
+ expect(queryHistories).toHaveBeenCalledTimes(2);
+ expect(text()).not.toContain('The history did not load');
+ expect(text()).toContain('Submission 1');
+ // A retry is not a refresh, so the assessment the tab was opened on stays selected.
+ expect(fixture.componentInstance.loadOverseerAssessmentId).toBe(5);
+ });
+});
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.ts
index 37c24bb6e0..061ebeb382 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.ts
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component.ts
@@ -4,7 +4,7 @@ import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
import {MatDialog} from '@angular/material/dialog';
import {MatMenuTrigger} from '@angular/material/menu';
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
-import {forkJoin} from 'rxjs';
+import {Subscription, forkJoin} from 'rxjs';
import {OverseerAssessment, UnitRole, UserService} from 'src/app/api/models/doubtfire-model';
import {SubmissionHistory} from 'src/app/api/models/submission-history';
import {Task} from 'src/app/api/models/task';
@@ -18,16 +18,23 @@ import {SubmissionFilesModalComponent} from './submission-files-modal/submission
selector: 'f-task-overseer-report',
templateUrl: './task-overseer-report.component.html',
styleUrl: './task-overseer-report.component.scss',
+ // The tab owns its scroll: the header stays put and only the list moves.
+ host: {class: 'flex h-full min-h-0 flex-col'},
changeDetection: ChangeDetectionStrategy.Eager,
standalone: false,
})
export class TaskOverseerReportComponent implements OnInit {
@Input() task: Task;
@Input() loadOverseerAssessmentId?: number;
+ /** The Overseer assessment dialog shows its own title, so the report leaves its heading out. */
+ @Input() inDialog = false;
public histories: SubmissionHistory[] = [];
public overseerAssessments: OverseerAssessment[] = [];
public comparisonSourceHistoryId: number | null = null;
public loading = false;
+ /** The last load failed, so the tab offers a retry instead of an empty list. */
+ public loadError = false;
+ private historySub?: Subscription;
constructor(
private alerts: AlertService,
@@ -102,8 +109,8 @@ export class TaskOverseerReportComponent implements OnInit {
private ansi = new Convert({
newline: false,
escapeXML: true,
- fg: '#1f2937',
- bg: '#f9fafb',
+ fg: 'var(--ot-code-text)',
+ bg: 'var(--ot-code-surface)',
});
protected renderOutput(output?: string | null): SafeHtml {
@@ -152,13 +159,37 @@ export class TaskOverseerReportComponent implements OnInit {
this.loadHistory();
}
+ public get historySummary(): string {
+ const count = this.histories.length;
+ if (this.loading || this.loadError || count === 0) {
+ return 'Past submissions of this task, newest first.';
+ }
+
+ return `${count} ${count === 1 ? 'submission' : 'submissions'} kept, newest first.`;
+ }
+
+ // The refresh button stays focusable while a load runs, so ignore a click until it ends.
+ refreshHistory(): void {
+ if (this.loading) {
+ return;
+ }
+ this.loadHistory(true);
+ }
+
+ overseerResultLabel(assessment: OverseerAssessment): string {
+ return `Overseer: ${assessment.passedSteps} of ${assessment.totalSteps} steps passed`;
+ }
+
loadHistory(isRefresh: boolean = false) {
if (isRefresh) {
this.loadOverseerAssessmentId = null;
}
this.loading = true;
+ this.loadError = false;
- forkJoin({
+ // A refresh can start before the previous load returns, so drop the older one.
+ this.historySub?.unsubscribe();
+ this.historySub = forkJoin({
histories: this.submissionHistoryService.queryForTask(this.task),
assessments: this.overseerAssessmentService.queryForTask(this.task),
}).subscribe({
@@ -184,6 +215,7 @@ export class TaskOverseerReportComponent implements OnInit {
},
error: (error) => {
this.loading = false;
+ this.loadError = true;
this.alerts.error(`Failed to load submission history: ${error}`, 6000);
},
});
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html
index f0f16f4ff5..4a992cd343 100644
--- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html
+++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html
@@ -1,7 +1,7 @@
@if (isPassed) {
@if (this.task.latestCompletedTestAttempt.scoreScaled === 1) {
-
+
Knowledge Check Passed Without Mistakes
}
@if (this.task.latestCompletedTestAttempt.scoreScaled !== 1) {
-
+
Knowledge Check Passed
}
-
+
You have successfully completed this knowledge check. You can now proceed to submitting task
files.
@@ -35,7 +35,7 @@
@if (!isPassed) {
@if (isPassed === false) {
-
+
Knowledge Check Unsuccessful
-
-
crisis_alert
-
Similarities
-
-
+
+ @if (jplagOpenState) {
+
+ arrow_back
+ Back to summary
+
+ } @else if (hasOpenPanel(similarities)) {
+
+ unfold_less
+
+ }
+
+
@if (!jplagOpenState) {
-
- @for (
- similarity of $safeNavigationMigration(task?.similarityCache.values) | async;
- track similarity
- ) {
-
- @for (part of similarity.parts; track part; let i = $index) {
-
-
-
- {{ similarity.friendlyTypeName }}
-
- {{ part.description }}
- @if (similarity.readyForViewer) {
- @if (similarity.type === 'JplagTaskSimilarity') {
-
-