Skip to content

Commit 6513784

Browse files
authored
Development: Migrate exam mode client module to Angular 21 signals, Vitest, and PrimeNG dialogs (#12603)
1 parent 374beff commit 6513784

187 files changed

Lines changed: 4812 additions & 3741 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ jobs:
238238

239239
client-tests:
240240
runs-on: aet-large-ubuntu
241-
timeout-minutes: 30
241+
timeout-minutes: 45
242242
steps:
243243
- uses: actions/checkout@v6
244244
# It seems like there is some memory issue with these tests with the project-wide default node option

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ Organized by feature module:
169169
- All new UI elements must be implemented using PrimeNG components
170170
- We are migrating from Bootstrap to PrimeNG; do not introduce new Bootstrap components
171171
- Existing Bootstrap usage will be migrated incrementally
172+
- **`@ng-bootstrap/ng-bootstrap` is deprecated** — do not use `NgbModal`, `NgbActiveModal`, `NgbModalRef`, `NgbTooltip`, `NgbDropdown`, etc. in new code. Use PrimeNG's `DialogService` (`primeng/dynamicdialog`) for modals, `p-tooltip` for tooltips, etc. ng-bootstrap is incompatible with Angular signal inputs (assigning to `modalRef.componentInstance.X` silently fails when `X` is `input()`/`input.required()`). Existing usages are being migrated.
172173

173174
### General
174175
- LF line endings

documentation/docs/developer/guidelines/client-development.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,16 @@ Use Angular's signal-based APIs instead. An ESLint rule (`enforce-signal-apis-in
6969

7070
**UI Component Library: PrimeNG**
7171

72+
<Callout variant={CalloutVariant.danger}>
73+
**`@ng-bootstrap/ng-bootstrap` is deprecated.** Do not use `NgbModal`, `NgbActiveModal`, `NgbModalRef`, `NgbTooltip`, `NgbDropdown`, or any other ng-bootstrap component in new code. ng-bootstrap is incompatible with Angular signal inputs (`input()`/`input.required()`) — assigning to a signal input via `modalRef.componentInstance.X = Y` silently fails. We are removing the dependency entirely; existing usages are being migrated to PrimeNG incrementally.
74+
</Callout>
75+
7276
<Callout variant={CalloutVariant.info}>
7377
**We are migrating from Bootstrap to PrimeNG.** All new UI elements must be implemented using [PrimeNG](https://primeng.org/) components. Do not introduce new Bootstrap components. Existing Bootstrap usage will be migrated incrementally.
7478
</Callout>
7579

7680
* Use PrimeNG components (buttons, tables, dialogs, inputs, etc.) for all new UI development.
81+
* For modals/dialogs, use PrimeNG's `DialogService` (`primeng/dynamicdialog`) — see `src/main/webapp/app/exam/manage/exam-management/exam-management.component.ts` for a reference implementation.
7782
* Refer to the PrimeNG documentation for available components and usage: https://primeng.org/
7883
* Bootstrap CSS utility classes (e.g. `d-flex`, `ms-2`) may still be used for layout until the migration is complete, but prefer PrimeNG's built-in layout capabilities where available.
7984

jest.config.js

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,17 @@ module.exports = {
109109
'!<rootDir>/src/main/webapp/app/shared/table-view/**', // table view module uses Vitest (see vitest.config.ts)
110110
'!<rootDir>/src/main/webapp/app/shared/components/buttons/**', // buttons module uses Vitest (see vitest.config.ts)
111111
'!<rootDir>/src/main/webapp/app/exam/manage/students/**', // exam manage students module uses Vitest (see vitest.config.ts)
112+
'!<rootDir>/src/main/webapp/app/exam/manage/student-exams/**', // exam manage student-exams module uses Vitest (see vitest.config.ts)
113+
'!<rootDir>/src/main/webapp/app/exam/manage/test-runs/**', // exam manage test-runs module uses Vitest (see vitest.config.ts)
114+
'!<rootDir>/src/main/webapp/app/exam/manage/exercise-groups/**', // exam manage exercise groups module uses Vitest (see vitest.config.ts)
115+
'!<rootDir>/src/main/webapp/app/exam/manage/suspicious-behavior/**', // exam manage suspicious behavior module uses Vitest (see vitest.config.ts)
116+
'!<rootDir>/src/main/webapp/app/exam/manage/services/**', // exam manage services module uses Vitest (see vitest.config.ts)
117+
'!<rootDir>/src/main/webapp/app/exam/manage/exam-management/**', // exam management module uses Vitest (see vitest.config.ts)
118+
'!<rootDir>/src/main/webapp/app/exam/manage/exam-scores/**', // exam scores module uses Vitest (see vitest.config.ts)
119+
'!<rootDir>/src/main/webapp/app/exam/manage/exam-status/**', // exam status module uses Vitest (see vitest.config.ts)
120+
'!<rootDir>/src/main/webapp/app/exam/manage/exams/**', // exam manage exams (detail/import/update/checklist/mode-picker) uses Vitest (see vitest.config.ts)
121+
'!<rootDir>/src/main/webapp/app/exam/shared/**', // exam shared module uses Vitest (see vitest.config.ts)
122+
'!<rootDir>/src/main/webapp/app/exam/overview/**', // exam overview module uses Vitest (see vitest.config.ts)
112123
'!<rootDir>/src/main/webapp/app/shared/feature-toggle/**', // feature-toggle service uses Vitest (see vitest.config.ts)
113124
'!<rootDir>/src/main/webapp/app/shared/sort/**', // sort directives use vitest (see vitest.config.ts)
114125
'!<rootDir>/src/main/webapp/app/shared/user-import/util/**', // user import utils use Vitest (see vitest.config.ts)
@@ -142,6 +153,17 @@ module.exports = {
142153
'<rootDir>/src/main/webapp/app/atlas/', // atlas module uses Vitest
143154
'<rootDir>/src/main/webapp/app/shared/components/buttons/', // buttons module uses Vitest
144155
'<rootDir>/src/main/webapp/app/exam/manage/students/', // exam manage students module uses Vitest
156+
'<rootDir>/src/main/webapp/app/exam/manage/student-exams/', // exam manage student-exams module uses Vitest
157+
'<rootDir>/src/main/webapp/app/exam/manage/test-runs/', // exam manage test-runs module uses Vitest
158+
'<rootDir>/src/main/webapp/app/exam/manage/exercise-groups/', // exam manage exercise groups module uses Vitest
159+
'<rootDir>/src/main/webapp/app/exam/manage/suspicious-behavior/', // exam manage suspicious behavior module uses Vitest
160+
'<rootDir>/src/main/webapp/app/exam/manage/services/', // exam manage services module uses Vitest
161+
'<rootDir>/src/main/webapp/app/exam/manage/exam-management/', // exam management module uses Vitest
162+
'<rootDir>/src/main/webapp/app/exam/manage/exam-scores/', // exam scores module uses Vitest
163+
'<rootDir>/src/main/webapp/app/exam/manage/exam-status/', // exam status module uses Vitest
164+
'<rootDir>/src/main/webapp/app/exam/manage/exams/', // exam manage exams uses Vitest
165+
'<rootDir>/src/main/webapp/app/exam/shared/', // exam shared module uses Vitest
166+
'<rootDir>/src/main/webapp/app/exam/overview/', // exam overview module uses Vitest
145167
'<rootDir>/src/main/webapp/app/shared/sort/', // sort directives use Vitest
146168
'<rootDir>/src/main/webapp/app/shared/user-import/util/', // user import utils use Vitest
147169
'<rootDir>/src/main/webapp/app/shared/table-view/', // table view module uses Vitest
@@ -159,12 +181,15 @@ module.exports = {
159181
],
160182
// Global coverage thresholds for Jest. Modules using Vitest (e.g., fileupload) have their own
161183
// coverage thresholds in vitest.config.ts. Per-module thresholds are enforced by check-client-module-coverage.mjs
184+
// Lowered with the exam-module Vitest migration: when ~100 spec files moved from Jest to Vitest,
185+
// a few peripheral helpers stopped being touched by the Jest run, dropping each global metric by 1-2pp.
186+
// Per-file coverage is unchanged — the exam-module tests still cover the same files, just under Vitest.
162187
coverageThreshold: {
163188
global: {
164-
statements: 85.5,
165-
branches: 74.1,
166-
functions: 75.4,
167-
lines: 86.5,
189+
statements: 83.5,
190+
branches: 73,
191+
functions: 73,
192+
lines: 84.5,
168193
},
169194
},
170195
// 'json-summary' reporter is used by supporting_scripts/code-coverage/module-coverage-client/check-client-module-coverage.mjs
@@ -201,6 +226,17 @@ module.exports = {
201226
'<rootDir>/src/main/webapp/app/tutorialgroup/', // tutorialgroup module
202227
'<rootDir>/src/main/webapp/app/atlas/', // atlas module
203228
'<rootDir>/src/main/webapp/app/exam/manage/students/', // exam manage students module
229+
'<rootDir>/src/main/webapp/app/exam/manage/student-exams/', // exam manage student-exams module
230+
'<rootDir>/src/main/webapp/app/exam/manage/test-runs/', // exam manage test-runs module
231+
'<rootDir>/src/main/webapp/app/exam/manage/exercise-groups/', // exam manage exercise groups module
232+
'<rootDir>/src/main/webapp/app/exam/manage/suspicious-behavior/', // exam manage suspicious behavior module
233+
'<rootDir>/src/main/webapp/app/exam/manage/services/', // exam manage services module
234+
'<rootDir>/src/main/webapp/app/exam/manage/exam-management/', // exam management module (vitest)
235+
'<rootDir>/src/main/webapp/app/exam/manage/exam-scores/', // exam scores module (vitest)
236+
'<rootDir>/src/main/webapp/app/exam/manage/exam-status/', // exam status module (vitest)
237+
'<rootDir>/src/main/webapp/app/exam/manage/exams/', // exam manage exams (detail/import/update/checklist/mode-picker) (vitest)
238+
'<rootDir>/src/main/webapp/app/exam/shared/', // exam shared module (vitest)
239+
'<rootDir>/src/main/webapp/app/exam/overview/', // exam overview module (vitest)
204240
'<rootDir>/src/main/webapp/app/shared/components/buttons/', // shared/buttons components
205241
'<rootDir>/src/main/webapp/app/shared/table-view/', // shared/table-view component
206242
'<rootDir>/src/main/webapp/app/shared/feature-toggle/', // feature-toggle service (vitest)

src/main/webapp/app/core/course/overview/course-overview/course-overview.component.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,11 @@ export class CourseOverviewComponent extends BaseCourseContainerComponent implem
115115

116116
async ngOnInit() {
117117
this.toggleSidebarEventSubscription = this.courseSidebarService.toggleSidebar$.subscribe(() => {
118-
this.isSidebarCollapsed.update((value) => this.activatedComponentReference()?.isCollapsed ?? !value);
118+
this.isSidebarCollapsed.update((value) => {
119+
const componentRef = this.activatedComponentReference();
120+
const componentCollapsed = typeof componentRef?.isCollapsed === 'function' ? componentRef.isCollapsed() : (componentRef?.isCollapsed as boolean | undefined);
121+
return componentCollapsed ?? !value;
122+
});
119123
});
120124

121125
this.subscription = this.route?.params.subscribe(async (params: { courseId: string }) => {
@@ -149,7 +153,9 @@ export class CourseOverviewComponent extends BaseCourseContainerComponent implem
149153
});
150154

151155
this.courseActionItems.set(this.getCourseActionItems());
152-
this.isSidebarCollapsed.set(this.activatedComponentReference()?.isCollapsed ?? false);
156+
const componentRef = this.activatedComponentReference();
157+
const componentCollapsed = typeof componentRef?.isCollapsed === 'function' ? componentRef.isCollapsed() : (componentRef?.isCollapsed as boolean | undefined);
158+
this.isSidebarCollapsed.set(componentCollapsed ?? false);
153159
this.sidebarItems.set(this.getSidebarItems());
154160
await this.initAfterCourseLoad();
155161
}
@@ -293,7 +299,8 @@ export class CourseOverviewComponent extends BaseCourseContainerComponent implem
293299
}
294300
const childRouteComponent = this.activatedComponentReference();
295301
childRouteComponent?.toggleSidebar();
296-
this.isSidebarCollapsed.set(childRouteComponent!.isCollapsed);
302+
const componentCollapsed = typeof childRouteComponent!.isCollapsed === 'function' ? childRouteComponent!.isCollapsed() : (childRouteComponent!.isCollapsed as boolean);
303+
this.isSidebarCollapsed.set(componentCollapsed);
297304
}
298305

299306
getShowRefreshButton(): void {

src/main/webapp/app/exam/manage/exam-management/exam-management.component.spec.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
22
import { HttpResponse, provideHttpClient } from '@angular/common/http';
33
import { LocalStorageService } from 'app/shared/service/local-storage.service';
44
import { SessionStorageService } from 'app/shared/service/session-storage.service';
@@ -27,8 +27,12 @@ import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
2727
import { MockDialogService } from 'test/helpers/mocks/service/mock-dialog.service';
2828
import { MockRouter } from 'test/helpers/mocks/mock-router';
2929
import { provideHttpClientTesting } from '@angular/common/http/testing';
30+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
31+
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
3032

3133
describe('Exam Management Component', () => {
34+
setupTestBed({ zoneless: true });
35+
3236
const course = { id: 456 } as Course;
3337
const exam = new Exam();
3438
exam.course = course;
@@ -45,9 +49,9 @@ describe('Exam Management Component', () => {
4549

4650
const route = { snapshot: { paramMap: convertToParamMap({ courseId: course.id }) }, url: new Observable<UrlSegment[]>() } as any as ActivatedRoute;
4751

48-
beforeEach(() => {
49-
TestBed.configureTestingModule({
50-
declarations: [
52+
beforeEach(async () => {
53+
await TestBed.configureTestingModule({
54+
imports: [
5155
ExamManagementComponent,
5256
MockDirective(HasAnyAuthorityDirective),
5357
MockPipe(ArtemisTranslatePipe),
@@ -82,13 +86,13 @@ describe('Exam Management Component', () => {
8286

8387
afterEach(() => {
8488
// completely restore all fakes created through the sandbox
85-
jest.restoreAllMocks();
89+
vi.restoreAllMocks();
8690
});
8791

8892
it('should call find of courseManagementService to get course on init', () => {
8993
// GIVEN
9094
const responseFakeCourse = { body: course as Course } as HttpResponse<Course>;
91-
jest.spyOn(courseManagementService, 'find').mockReturnValue(of(responseFakeCourse));
95+
vi.spyOn(courseManagementService, 'find').mockReturnValue(of(responseFakeCourse));
9296

9397
// WHEN
9498
comp.ngOnInit();
@@ -101,9 +105,9 @@ describe('Exam Management Component', () => {
101105
it('should call loadAllExamsForCourse on init', () => {
102106
// GIVEN
103107
const responseFakeCourse = { body: course as Course } as HttpResponse<Course>;
104-
jest.spyOn(courseManagementService, 'find').mockReturnValue(of(responseFakeCourse));
108+
vi.spyOn(courseManagementService, 'find').mockReturnValue(of(responseFakeCourse));
105109
const responseFakeExams = { body: [exam] } as HttpResponse<Exam[]>;
106-
jest.spyOn(service, 'findAllExamsForCourse').mockReturnValue(of(responseFakeExams));
110+
vi.spyOn(service, 'findAllExamsForCourse').mockReturnValue(of(responseFakeExams));
107111

108112
// WHEN
109113
comp.ngOnInit();
@@ -116,14 +120,14 @@ describe('Exam Management Component', () => {
116120
it('should call getLatestIndividualDate on init', () => {
117121
// GIVEN
118122
const responseFakeCourse = { body: course as Course } as HttpResponse<Course>;
119-
jest.spyOn(courseManagementService, 'find').mockReturnValue(of(responseFakeCourse));
123+
vi.spyOn(courseManagementService, 'find').mockReturnValue(of(responseFakeCourse));
120124
const responseFakeExams = { body: [exam] } as HttpResponse<Exam[]>;
121-
jest.spyOn(service, 'findAllExamsForCourse').mockReturnValue(of(responseFakeExams));
125+
vi.spyOn(service, 'findAllExamsForCourse').mockReturnValue(of(responseFakeExams));
122126

123127
const examInformationDTO = new ExamInformationDTO();
124128
examInformationDTO.latestIndividualEndDate = dayjs();
125129
const responseFakeLatestIndividualEndDateOfExam = { body: examInformationDTO } as HttpResponse<ExamInformationDTO>;
126-
jest.spyOn(service, 'getLatestIndividualEndDateOfExam').mockReturnValue(of(responseFakeLatestIndividualEndDateOfExam));
130+
vi.spyOn(service, 'getLatestIndividualEndDateOfExam').mockReturnValue(of(responseFakeLatestIndividualEndDateOfExam));
127131

128132
// WHEN
129133
comp.ngOnInit();
@@ -137,7 +141,7 @@ describe('Exam Management Component', () => {
137141
// GIVEN
138142
comp.course = course;
139143
const responseFakeExams = { body: [exam] } as HttpResponse<Exam[]>;
140-
jest.spyOn(service, 'findAllExamsForCourse').mockReturnValue(of(responseFakeExams));
144+
vi.spyOn(service, 'findAllExamsForCourse').mockReturnValue(of(responseFakeExams));
141145

142146
// WHEN
143147
comp.registerChangeInExams();
@@ -156,7 +160,7 @@ describe('Exam Management Component', () => {
156160
const examHasFinished = comp.examHasFinished(exam);
157161

158162
// THEN
159-
expect(examHasFinished).toBeFalse();
163+
expect(examHasFinished).toBe(false);
160164
});
161165

162166
it('should return true for examHasFinished when exam is in the past', () => {
@@ -167,7 +171,7 @@ describe('Exam Management Component', () => {
167171
const examHasFinished = comp.examHasFinished(exam);
168172

169173
// THEN
170-
expect(examHasFinished).toBeTrue();
174+
expect(examHasFinished).toBe(true);
171175
});
172176

173177
it('should return false for examHasFinished when exam is in the future', () => {
@@ -178,7 +182,7 @@ describe('Exam Management Component', () => {
178182
const examHasFinished = comp.examHasFinished(exam);
179183

180184
// THEN
181-
expect(examHasFinished).toBeFalse();
185+
expect(examHasFinished).toBe(false);
182186
});
183187

184188
it('should return exam.id, when item in the exam table is being tracked', () => {
@@ -191,7 +195,7 @@ describe('Exam Management Component', () => {
191195

192196
it('should call sortService when sortRows is called', () => {
193197
// GIVEN
194-
jest.spyOn(sortService, 'sortByProperty').mockReturnValue([]);
198+
vi.spyOn(sortService, 'sortByProperty').mockReturnValue([]);
195199

196200
// WHEN
197201
comp.sortRows();
@@ -200,21 +204,21 @@ describe('Exam Management Component', () => {
200204
expect(sortService.sortByProperty).toHaveBeenCalledOnce();
201205
});
202206

203-
it('should open the import dialog for exams', fakeAsync(() => {
207+
it('should open the import dialog for exams', async () => {
204208
const onCloseSubject = new Subject<Exam | undefined>();
205209
const mockDialogRef = { onClose: onCloseSubject.asObservable() } as DynamicDialogRef;
206-
jest.spyOn(dialogService, 'open').mockReturnValue(mockDialogRef);
207-
jest.spyOn(router, 'navigate');
210+
vi.spyOn(dialogService, 'open').mockReturnValue(mockDialogRef);
211+
vi.spyOn(router, 'navigate');
208212

209213
comp.course = { id: 1 } as Course;
210214
comp.openImportModal();
211215

212216
// Simulate dialog closing with result
213217
onCloseSubject.next(exam);
214218
onCloseSubject.complete();
215-
tick();
219+
await fixture.whenStable();
216220

217221
expect(dialogService.open).toHaveBeenCalledOnce();
218222
expect(router.navigate).toHaveBeenCalledOnce();
219-
}));
223+
});
220224
});

0 commit comments

Comments
 (0)