Skip to content

Commit 8ae09a9

Browse files
Adds support for adding new exercises individually or in bulk
A new feature has been added that allows teachers to add new exercises to their courses by choosing a directory from their local file system and choosing whether to upload that directory as an exercise (a single one) or to generate one for each subdirectory (multiple uploads).
1 parent 80f2808 commit 8ae09a9

17 files changed

+754
-17
lines changed

vscode4teaching-webapp/src/app/app.module.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import { HttpRequestInterceptor } from "./services/rest-api/interceptor/http-req
3636
import { UrlService } from "./services/url/url.service";
3737
import { WebSocketHandler } from "./services/ws/web-socket-handler";
3838
import { WebSocketHandlerFactory } from "./services/ws/web-socket-handler-factory.service";
39+
import { AddExercisesComponent } from './components/private/teacher/course/add-exercises/add-exercises.component';
40+
import { ExerciseDirectoryComponent } from './components/private/teacher/course/add-exercises/exercise-directory/exercise-directory.component';
3941

4042
@NgModule({
4143
declarations: [
@@ -74,7 +76,9 @@ import { WebSocketHandlerFactory } from "./services/ws/web-socket-handler-factor
7476
TeacherExerciseComponent,
7577
GeneralStatisticsComponent,
7678
StudentsProgressComponent,
77-
IndividualStudentProgressComponent
79+
IndividualStudentProgressComponent,
80+
AddExercisesComponent,
81+
ExerciseDirectoryComponent
7882
],
7983
imports: [
8084
BrowserModule,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<button class="btn btn-sm btn-v4t" (click)="this.openAddExercisesModal()">
2+
<i class="fa fa-plus"></i> Add exercises
3+
</button>
4+
<div class="modal modal-lg fade" tabindex="-1" #addExercisesModal>
5+
<div class="modal-dialog">
6+
<div class="modal-content">
7+
<div class="modal-header">
8+
<h5 class="modal-title"><i class="fa fa-plus"></i> Add new exercises</h5>
9+
<button type="button" class="btn-close" (click)="this.closeAddExercisesModal()"></button>
10+
</div>
11+
<div class="modal-body">
12+
<div class="description">
13+
Pick a directory from your local file system. When chosen, directory will be displayed and a checkbox will be available to choose if directory contains subdirectories with exercises or not.
14+
</div>
15+
<div class="text-center" style="display: flex; flex-direction: row; align-items: center">
16+
<button class="btn btn-v4t" style="flex: 1 0" (click)="this.pickDirectory()" [disabled]="this.status === 'IN_PROGRESS'">
17+
<i class="fa fa-folder-open"></i> Pick directory
18+
</button>
19+
<div style="flex: 1 0" class="form-check form-switch p-0 d-flex justify-content-center">
20+
<input class="form-check-input v4t" type="checkbox" id="subdirectories" [(ngModel)]="this.checkSubdirectories" (change)="this.refreshSelection()" [disabled]="this.status === 'IN_PROGRESS'">
21+
<label class="form-check-label" for="subdirectories">Look for exercises in subdirectories</label>
22+
</div>
23+
</div>
24+
25+
<app-teacher-course-add-exercise-exercise-directory #exerciseDirectory
26+
*ngFor="let entry of this.potentialEntries" [entry]="entry"
27+
[course]="this.course" (uploadFinished)="this.refreshParentCourses()">
28+
</app-teacher-course-add-exercise-exercise-directory>
29+
30+
<div class="text-center mt-2">
31+
<button class="btn btn-v4t" [disabled]="this.potentialEntries?.length === 0 || this.status !== 'NOT_STARTED'" (click)="this.uploadExercises()">
32+
<div *ngIf="this.status === 'NOT_STARTED'"><i class="fa fa-save"></i> Save new exercises</div>
33+
<div *ngIf="this.status === 'IN_PROGRESS'"><i class="fa fa-circle-notch fa-spin"></i> In progress…</div>
34+
<div *ngIf="this.status === 'FINISHED'"><i class="fa fa-check"></i> Finished</div>
35+
</button>
36+
</div>
37+
</div>
38+
</div>
39+
</div>
40+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.description {
2+
font-size: 0.9rem;
3+
font-weight: 400;
4+
margin: .25rem 0;
5+
text-align: justify;
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { AddExercisesComponent } from './add-exercises.component';
4+
5+
describe('AddExercisesComponent', () => {
6+
let component: AddExercisesComponent;
7+
let fixture: ComponentFixture<AddExercisesComponent>;
8+
9+
beforeEach(() => {
10+
TestBed.configureTestingModule({
11+
declarations: [AddExercisesComponent]
12+
});
13+
fixture = TestBed.createComponent(AddExercisesComponent);
14+
component = fixture.componentInstance;
15+
fixture.detectChanges();
16+
});
17+
18+
it('should create', () => {
19+
expect(component).toBeTruthy();
20+
});
21+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild, ViewChildren } from '@angular/core';
2+
import { Modal } from "bootstrap";
3+
import { Course } from "../../../../../model/course.model";
4+
import { ExerciseUserInfoStatus } from "../../../../../model/exercise-user-info.model";
5+
import { ExerciseDirectoryComponent } from "./exercise-directory/exercise-directory.component";
6+
7+
@Component({
8+
selector: 'app-teacher-course-add-exercises',
9+
templateUrl: './add-exercises.component.html',
10+
styleUrls: ['./add-exercises.component.scss']
11+
})
12+
export class AddExercisesComponent implements AfterViewInit {
13+
@Input("course") course!: Course;
14+
15+
// Output event to notify the parent component that the upload of all exercises has finished
16+
@Output("uploadFinished") uploadFinished: EventEmitter<void>;
17+
public finishedUploads: number;
18+
19+
// View children to access the exercise directories
20+
@ViewChildren("exerciseDirectory") exerciseDirectories!: ExerciseDirectoryComponent[];
21+
22+
// Directory picker management
23+
public directoryPicked?: FileSystemDirectoryHandle;
24+
public checkSubdirectories = false;
25+
public potentialEntries: FileSystemDirectoryHandle[] = [];
26+
27+
// Upload status
28+
public status: ExerciseUserInfoStatus;
29+
30+
// Modal management
31+
private addExercisesModal!: Modal;
32+
@ViewChild("addExercisesModal") private addExercisesModalElementRef!: ElementRef;
33+
34+
constructor() {
35+
this.uploadFinished = new EventEmitter<void>();
36+
this.finishedUploads = 0;
37+
this.status = "NOT_STARTED";
38+
}
39+
40+
41+
public ngAfterViewInit(): void {
42+
this.addExercisesModal = new Modal(this.addExercisesModalElementRef.nativeElement, { backdrop: "static" });
43+
}
44+
45+
46+
public openAddExercisesModal(): void {
47+
this.status = "NOT_STARTED";
48+
this.addExercisesModal.show();
49+
}
50+
51+
public closeAddExercisesModal(): void {
52+
if (this.status !== "IN_PROGRESS") {
53+
this.addExercisesModal.hide();
54+
this.directoryPicked = undefined;
55+
this.potentialEntries = [];
56+
}
57+
}
58+
59+
60+
public refreshParentCourses(): void {
61+
this.finishedUploads++;
62+
if (this.finishedUploads === this.exerciseDirectories.length) {
63+
this.uploadFinished.emit();
64+
this.status = "FINISHED";
65+
}
66+
}
67+
68+
69+
public async pickDirectory(): Promise<void> {
70+
try {
71+
this.directoryPicked = await showDirectoryPicker({ mode: "read" });
72+
await this.refreshSelection();
73+
} catch (e) {
74+
}
75+
}
76+
77+
public uploadExercises(): void {
78+
const validExerciseNames = this.exerciseDirectories.map((directory: ExerciseDirectoryComponent) => directory.exerciseName.valid).reduce((a, b) => a && b, true);
79+
if (validExerciseNames) {
80+
this.status = "IN_PROGRESS";
81+
this.exerciseDirectories.forEach((directory: ExerciseDirectoryComponent) => directory.zipAndUpload());
82+
}
83+
}
84+
85+
86+
public async refreshSelection(): Promise<void> {
87+
if (this.directoryPicked) {
88+
this.potentialEntries = [];
89+
if (this.checkSubdirectories) {
90+
for await (const entry of this.directoryPicked.values()) {
91+
if (entry.kind === "directory" && entry.name.match(/^[^.]/)) {
92+
this.potentialEntries.push(entry as FileSystemDirectoryHandle);
93+
}
94+
}
95+
this.potentialEntries.sort((a, b) => a.name.localeCompare(b.name));
96+
} else {
97+
this.potentialEntries = [this.directoryPicked];
98+
}
99+
this.status = "NOT_STARTED";
100+
}
101+
}
102+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<div *ngIf="!this.directory">
2+
<div class="alert alert-v4t my-2 text-center">
3+
<i class="fas fa-circle-notch fa-spin"></i> Loading directory information…
4+
</div>
5+
</div>
6+
<div *ngIf="this.directory" class="exercise">
7+
<div class="exerciseInfo">
8+
<div class="directory">
9+
<div style="display: flex; flex-direction: row; align-items: center">
10+
<i class="fa fa-folder"></i>
11+
<div style="margin-left: .5rem">
12+
<div>{{ this.directory.name }}</div>
13+
</div>
14+
</div>
15+
<div class="bg-in-progress color-in-progress badge py-2 text-warning-emphasis bg-warning-subtle border border-warning-subtle rounded-pill" *ngIf="!this.exerciseIncludesSolution">
16+
No solution included
17+
</div>
18+
<div class="bg-finished color-finished badge py-2 text-success-emphasis bg-success-subtle border border-success-subtle rounded-pill" *ngIf="this.exerciseIncludesSolution">
19+
Solution included
20+
</div>
21+
</div>
22+
<div class="name" style="display: flex; flex-direction: row">
23+
<label [for]="'exerciseName_' + this.directory.name" class="form-label" style="flex: 0 0 30%; align-self: center; margin-bottom: 0; text-align: center">Exercise name</label>
24+
<div class="input-group has-validation" style="flex: 1 0">
25+
<input type="text" [id]="'exerciseName_' + this.directory.name" [formControl]="this.exerciseName" placeholder="Name for the exercise…" class="form-control form-control-sm form-control-v4t">
26+
<div class="invalid-feedback" *ngIf="!this.exerciseName.valid && this.exerciseName.hasError('required')">
27+
Exercise name is required
28+
</div>
29+
<div class="invalid-feedback" *ngIf="!this.exerciseName.valid && this.exerciseName.hasError('minlength')">
30+
Exercise name must be at least 4 characters long
31+
</div>
32+
</div>
33+
</div>
34+
</div>
35+
<div class="uploadStatus" *ngIf="this.uploadStatus.status !== 'NOT_STARTED'">
36+
<div class="progressBadges">
37+
<ng-container [ngSwitch]="this.uploadStatus.steps.createdExercise">
38+
<div *ngSwitchCase="'NOT_STARTED'" class="badge py-2 text-secondary-emphasis bg-secondary-subtle border border-secondary-subtle rounded-pill">
39+
<i class="far fa-circle" style="margin-right: .25rem"></i> Exercise info not saved
40+
</div>
41+
<div *ngSwitchCase="'IN_PROGRESS'" class="badge py-2 text-info-emphasis bg-info-subtle border border-info-subtle rounded-pill">
42+
<i class="far fa-circle-play" style="margin-right: .25rem"></i> Saving exercise info…
43+
</div>
44+
<div *ngSwitchCase="'FINISHED'" class="badge py-2 text-success-emphasis bg-success-subtle border border-success-subtle rounded-pill">
45+
<i class="fa fa-circle-check" style="margin-right: .25rem"></i> Exercise info saved
46+
</div>
47+
</ng-container>
48+
<ng-container [ngSwitch]="this.uploadStatus.steps.uploadedTemplate">
49+
<div *ngSwitchCase="'NOT_STARTED'" class="badge py-2 text-secondary-emphasis bg-secondary-subtle border border-secondary-subtle rounded-pill">
50+
<i class="far fa-circle" style="margin-right: .25rem"></i> Template not uploaded
51+
</div>
52+
<div *ngSwitchCase="'IN_PROGRESS'" class="badge py-2 text-info-emphasis bg-info-subtle border border-info-subtle rounded-pill">
53+
<i class="far fa-circle-play" style="margin-right: .25rem"></i> Uploading template…
54+
</div>
55+
<div *ngSwitchCase="'FINISHED'" class="badge py-2 text-success-emphasis bg-success-subtle border border-success-subtle rounded-pill">
56+
<i class="fa fa-circle-check" style="margin-right: .25rem"></i> Template uploaded
57+
</div>
58+
</ng-container>
59+
<ng-container *ngIf="this.uploadStatus.steps.uploadedSolution" [ngSwitch]="this.uploadStatus.steps.uploadedSolution">
60+
<div *ngSwitchCase="'NOT_STARTED'" class="badge py-2 text-secondary-emphasis bg-secondary-subtle border border-secondary-subtle rounded-pill">
61+
<i class="far fa-circle" style="margin-right: .25rem"></i> Solution not uploaded
62+
</div>
63+
<div *ngSwitchCase="'IN_PROGRESS'" class="badge py-2 text-info-emphasis bg-info-subtle border border-info-subtle rounded-pill">
64+
<i class="far fa-circle-play" style="margin-right: .25rem"></i> Uploading solution…
65+
</div>
66+
<div *ngSwitchCase="'FINISHED'" class="badge py-2 text-success-emphasis bg-success-subtle border border-success-subtle rounded-pill">
67+
<i class="fa fa-circle-check" style="margin-right: .25rem"></i> Solution uploaded
68+
</div>
69+
</ng-container>
70+
</div>
71+
<app-helper-progress-bar [info]="this.uploadStatus.progress"></app-helper-progress-bar>
72+
</div>
73+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
.exercise {
2+
background-color: #FFF0F0;
3+
border-radius: 16px;
4+
padding: .5rem 1rem;
5+
margin: .5rem 0;
6+
7+
display: flex;
8+
flex-direction: column;
9+
justify-content: center;
10+
row-gap: .5rem;
11+
12+
> .exerciseInfo {
13+
display: flex;
14+
flex-direction: row;
15+
justify-content: space-between;
16+
align-items: stretch;
17+
18+
> * {
19+
flex: 1 0;
20+
border-right: 1px solid #F44A3E;
21+
padding: .75rem;
22+
23+
display: flex;
24+
justify-content: center;
25+
align-items: center;
26+
27+
&:first-child {
28+
justify-content: flex-start;
29+
padding-left: 0;
30+
}
31+
32+
&:last-child {
33+
padding-right: 0;
34+
border-right: 0;
35+
}
36+
37+
&.directory {
38+
flex-direction: column;
39+
justify-content: center;
40+
align-items: flex-start;
41+
42+
> .badge {
43+
align-self: center;
44+
}
45+
}
46+
47+
&.name {
48+
flex-grow: 2;
49+
flex-direction: column;
50+
justify-content: flex-start;
51+
}
52+
53+
label {
54+
font-size: 1rem;
55+
font-weight: 500;
56+
padding-bottom: 0;
57+
}
58+
}
59+
}
60+
61+
&:nth-child(2n) {
62+
background-color: #FEE2E1;
63+
}
64+
65+
div.invalid-feedback {
66+
display: block;
67+
}
68+
}
69+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { ExerciseDirectoryComponent } from './exercise-directory.component';
4+
5+
describe('ExerciseDirectoryComponent', () => {
6+
let component: ExerciseDirectoryComponent;
7+
let fixture: ComponentFixture<ExerciseDirectoryComponent>;
8+
9+
beforeEach(() => {
10+
TestBed.configureTestingModule({
11+
declarations: [ExerciseDirectoryComponent]
12+
});
13+
fixture = TestBed.createComponent(ExerciseDirectoryComponent);
14+
component = fixture.componentInstance;
15+
fixture.detectChanges();
16+
});
17+
18+
it('should create', () => {
19+
expect(component).toBeTruthy();
20+
});
21+
});

0 commit comments

Comments
 (0)