-
Notifications
You must be signed in to change notification settings - Fork 461
Feature/Support Multi-Student Selection For Grant Extension Modal #949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
20b07c0
7c338a6
570433a
5a629c4
206e271
f862719
42bb815
764179b
afb0dc3
a3b44c7
42b2f2f
56d91e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ruby file needs to be removed |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { Component } from '@angular/core'; | ||
| import { MatDialogRef } from '@angular/material/dialog'; | ||
| import { GrantExtensionFormComponent } from './grant-extension-form.component'; | ||
| import { MatDialogModule } from '@angular/material/dialog'; | ||
| import { CommonModule } from '@angular/common'; | ||
|
|
||
| @Component({ | ||
| selector: 'f-grant-extension-dialog', | ||
| standalone: true, | ||
| imports: [MatDialogModule, CommonModule, GrantExtensionFormComponent], | ||
| template: ` | ||
| <h2 mat-dialog-title>Grant Extension</h2> | ||
| <mat-dialog-content> | ||
| <f-grant-extension-form></f-grant-extension-form> | ||
| </mat-dialog-content> | ||
| <mat-dialog-actions align="end"> | ||
| <button mat-button (click)="close()">Close</button> | ||
| </mat-dialog-actions> | ||
| ` | ||
| }) | ||
| export class GrantExtensionDialogComponent { | ||
| constructor(private dialogRef: MatDialogRef<GrantExtensionDialogComponent>) {} | ||
|
|
||
| close(): void { | ||
| this.dialogRef.close(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| <h2 mat-dialog-title>Grant Extension</h2> | ||
| <form [formGroup]="grantExtensionForm" (ngSubmit)="submitForm()"> | ||
|
|
||
|
|
||
| <mat-dialog-content> | ||
| <!-- Student Selection --> | ||
| <mat-form-field appearance="fill" class="w-full mb-4"> | ||
| <mat-label>Students</mat-label> | ||
| <mat-select formControlName="student_ids" multiple> | ||
| <mat-option *ngFor="let student of students" [value]="student.id"> | ||
| {{ student.name }} | ||
| </mat-option> | ||
| </mat-select> | ||
| <mat-error *ngIf="grantExtensionForm.get('student_ids')?.touched && grantExtensionForm.get('student_ids')?.invalid"> | ||
| Please select at least one student. | ||
| </mat-error> | ||
| </mat-form-field> | ||
|
|
||
|
|
||
| <!-- Extension Duration --> | ||
| <div class="mb-4"> | ||
| <label for="extension" class="block text-xl font-semibold text-gray-900"> | ||
| Extension Duration: <strong>{{ grantExtensionForm.get('extension')?.value }}</strong> day(s) | ||
| </label> | ||
|
|
||
| <mat-slider | ||
| min="1" | ||
| max="30" | ||
| step="1" | ||
| tickInterval="5" | ||
| thumbLabel | ||
| class="w-full" | ||
| style="width: 100%; max-width: 600px; min-width: 300px;" | ||
| > | ||
| <input matSliderThumb formControlName="extension" /> | ||
| </mat-slider> | ||
| </div> | ||
|
|
||
| <!-- Reason Field --> | ||
| <mat-form-field appearance="fill" class="w-full mb-4"> | ||
| <mat-label>Reason</mat-label> | ||
| <textarea | ||
| matInput | ||
| formControlName="reason" | ||
| rows="4" | ||
| required | ||
| ></textarea> | ||
| <mat-error *ngIf="grantExtensionForm.get('reason')?.touched && grantExtensionForm.get('reason')?.invalid"> | ||
| Please provide a reason for the extension. | ||
| </mat-error> | ||
| </mat-form-field> | ||
|
|
||
| <!-- Notes Field (Optional) --> | ||
| <mat-form-field appearance="fill" class="w-full mb-4"> | ||
| <mat-label>Additional Notes (optional)</mat-label> | ||
| <textarea matInput formControlName="notes" rows="3"></textarea> | ||
| </mat-form-field> | ||
| </mat-dialog-content> | ||
|
|
||
| <!-- Form Buttons --> | ||
| <mat-dialog-actions align="end"> | ||
| <button mat-button type="button" (click)="close()" [disabled]="isSubmitting">Cancel</button> | ||
| <button mat-flat-button color="primary" type="submit" [disabled]="isSubmitting"> | ||
| Grant Extension | ||
| </button> | ||
| </mat-dialog-actions> | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||
| import { GrantExtensionFormComponent } from './grant-extension-form.component'; | ||
|
|
||
| describe('GrantExtensionFormComponent', () => { | ||
| let component: GrantExtensionFormComponent; | ||
| let fixture: ComponentFixture<GrantExtensionFormComponent>; | ||
|
|
||
| beforeEach(async () => { | ||
| await TestBed.configureTestingModule({ | ||
| imports: [GrantExtensionFormComponent] | ||
| }) | ||
| .compileComponents(); | ||
|
|
||
| fixture = TestBed.createComponent(GrantExtensionFormComponent); | ||
| component = fixture.componentInstance; | ||
| fixture.detectChanges(); | ||
| }); | ||
|
|
||
| it('should create', () => { | ||
| expect(component).toBeTruthy(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import { Component, OnInit, Inject } from '@angular/core'; | ||
| import { FormGroup, FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; | ||
| import { CommonModule } from '@angular/common'; | ||
| import { MatFormFieldModule } from '@angular/material/form-field'; | ||
| import { MatSelectModule } from '@angular/material/select'; | ||
| import { MatInputModule } from '@angular/material/input'; | ||
| import { MatSliderModule } from '@angular/material/slider'; | ||
| import { MatButtonModule } from '@angular/material/button'; | ||
| import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; | ||
| import { MatSnackBar } from '@angular/material/snack-bar'; | ||
| import { ExtensionService } from 'src/app/api/services/extension.service'; | ||
|
|
||
| @Component({ | ||
| selector: 'f-grant-extension-form', | ||
| standalone: true, | ||
| imports: [ | ||
| ReactiveFormsModule, | ||
| CommonModule, | ||
| MatFormFieldModule, | ||
| MatSelectModule, | ||
| MatInputModule, | ||
| MatSliderModule, | ||
| MatButtonModule, | ||
| MatDialogModule | ||
| ], | ||
| templateUrl: './grant-extension-form.component.html', | ||
| }) | ||
| export class GrantExtensionFormComponent implements OnInit { | ||
| grantExtensionForm!: FormGroup; | ||
| isSubmitting = false; | ||
| // List of test students to be displayed in the dropdown | ||
| students = [ | ||
| { id: 1, name: 'Joe M' }, | ||
| { id: 2, name: 'Sahiru W' }, | ||
| { id: 3, name: 'Samindi M' }, | ||
| { id: 4, name: 'Samantha W' }, | ||
| { id: 5, name: 'Samantha M' }, | ||
| { id: 6, name: 'Samantha S' }, | ||
| { id: 7, name: 'Samantha T' }, | ||
| { id: 8, name: 'Samantha U' }, | ||
| { id: 9, name: 'Samantha V' }, | ||
| { id: 10, name: 'Samantha W' }, | ||
| { id: 11, name: 'Samantha X' }, | ||
| { id: 12, name: 'Samantha Y' }, | ||
| { id: 13, name: 'Samantha Z' } | ||
| ]; | ||
|
Comment on lines
+32
to
+46
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Students list also needs to be fetched properly instead of these placeholders |
||
|
|
||
| constructor( | ||
| private fb: FormBuilder, | ||
| private dialogRef: MatDialogRef<GrantExtensionFormComponent>, | ||
| @Inject(MAT_DIALOG_DATA) public data: { unitId: number; taskDefinitionId: number }, | ||
| private snackBar: MatSnackBar, | ||
| private extensionService: ExtensionService | ||
| ) { | ||
| console.log('[GrantExtensionFormComponent] Constructor data:', data); | ||
| } | ||
|
|
||
| ngOnInit(): void { | ||
| this.grantExtensionForm = this.fb.group({ | ||
| student_ids: [[], Validators.required], | ||
| extension: [1, [Validators.required, Validators.min(1)]], | ||
| reason: ['', Validators.required], | ||
| notes: [''], | ||
| }); | ||
| } | ||
|
|
||
| submitForm(): void { | ||
| if (this.grantExtensionForm.invalid) { | ||
| this.grantExtensionForm.markAllAsTouched(); | ||
| return; | ||
| } | ||
|
|
||
| this.isSubmitting = true; | ||
|
|
||
| const { student_ids, extension, reason, notes } = this.grantExtensionForm.value; | ||
|
|
||
| const payload = { | ||
| student_ids, | ||
| task_definition_id: this.data.taskDefinitionId, | ||
| weeks_requested: extension, | ||
| comment: reason, | ||
| notes | ||
| }; | ||
|
|
||
|
|
||
| console.log('Sending request to grant extension:', payload); | ||
| this.extensionService.grantExtension(this.data.unitId, payload).subscribe({ | ||
| next: () => { | ||
| this.snackBar.open('Extension granted successfully!', 'Close', { duration: 3000 }); | ||
| this.dialogRef.close(true); | ||
| }, | ||
| error: (error) => { | ||
| const errorMsg = error?.error?.message || 'An unexpected error occurred. Please try again.'; | ||
| this.snackBar.open(`Failed to grant extension: ${errorMsg}`, 'Close', { duration: 5000 }); | ||
| console.error('Grant Extension Error:', error); | ||
| }, | ||
| complete: () => { | ||
| this.isSubmitting = false; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| close(): void { | ||
| this.dialogRef.close(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,7 +136,7 @@ export class AuthenticationService { | |
| remember: boolean; | ||
| }, | ||
| ): Observable<any> { | ||
| return this.httpClient.post(this.AUTH_URL, userCredentials).pipe( | ||
| return this.httpClient.post(this.AUTH_URL, userCredentials, { withCredentials: true }).pipe( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| map((response: any) => { | ||
| // Extract relevant data from response and construct user object to store in cache. | ||
| const user: User = this.userService.cache.getOrCreate( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { Injectable } from '@angular/core'; | ||
| import { HttpClient, HttpHeaders } from '@angular/common/http'; | ||
| import { Observable } from 'rxjs'; | ||
| import { UserService } from 'src/app/api/models/doubtfire-model'; | ||
|
|
||
| interface GrantExtensionPayload { | ||
| student_ids: number[]; | ||
| task_definition_id: number; | ||
| weeks_requested: number; | ||
| comment: string; | ||
| notes?: string; | ||
| } | ||
|
|
||
| @Injectable({ | ||
| providedIn: 'root' | ||
| }) | ||
| export class ExtensionService { | ||
| constructor( | ||
| private http: HttpClient, | ||
| private userService: UserService | ||
| ) {} | ||
|
|
||
| grantExtension(unitId: number, payload: GrantExtensionPayload): Observable<any> { | ||
| const authToken = this.userService.currentUser?.authenticationToken ?? ''; | ||
| const username = this.userService.currentUser?.username ?? ''; | ||
|
|
||
| const headers = new HttpHeaders({ | ||
| 'Auth-Token': authToken, | ||
| 'Username': username | ||
| }); | ||
|
Comment on lines
+27
to
+30
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. auth token should not have to be passed manually |
||
|
|
||
| return this.http.post( | ||
| `/api/units/${unitId}/staff-grant-extension`, | ||
| payload, | ||
| { headers } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,44 +1,40 @@ | ||
| <mat-card appearance="outlined"> | ||
| @if (triggers?.length > 0) { | ||
| <mat-form-field class="px-4 pt-4" appearance="outline"> | ||
| <mat-select [value]="task?.status" (selectionChange)="triggerTransition($event.value)"> | ||
| <mat-select-trigger> | ||
| <span style="display: inline-flex"> | ||
| <status-icon [status]="task?.status" style="margin-right: 10px"></status-icon> | ||
| <h2 class="mb-0">{{ task?.statusLabel() }}</h2> | ||
| </span> | ||
| </mat-select-trigger> | ||
| @for (trigger of triggers; track trigger) { | ||
| <mat-option [value]="trigger.status"> | ||
| <status-icon [status]="trigger.status" [showTooltip]="false"></status-icon> | ||
| <span style="display: inline-flex; margin-left: 10px" | ||
| ><h5>{{ trigger.label }}</h5></span | ||
| > | ||
| </mat-option> | ||
| } | ||
| </mat-select> | ||
| </mat-form-field> | ||
| } @if (triggers?.length < 0) { | ||
| <mat-card-title> | ||
| <span style="display: inline-flex"> | ||
| <status-icon [status]="task?.status" style="margin-right: 10px"></status-icon> | ||
| <h5>{{ task?.statusLabel() }}</h5> | ||
| </span> | ||
| </mat-card-title> | ||
| <mat-form-field class="px-4 pt-4" appearance="outline"> | ||
| <mat-select [value]="task?.status" (selectionChange)="triggerTransition($event.value)"> | ||
| <mat-select-trigger> | ||
| <span style="display: inline-flex"> | ||
| <status-icon [status]="task?.status" style="margin-right: 10px"></status-icon> | ||
| <h2 class="mb-0">{{ task?.statusLabel() }}</h2> | ||
| </span> | ||
| </mat-select-trigger> | ||
| @for (trigger of triggers; track trigger) { | ||
| <mat-option [value]="trigger.status"> | ||
| <status-icon [status]="trigger.status" [showTooltip]="false"></status-icon> | ||
| <span style="display: inline-flex; margin-left: 10px"> | ||
| <h5>{{ trigger.label }}</h5> | ||
| </span> | ||
| </mat-option> | ||
| } | ||
| </mat-select> | ||
| </mat-form-field> | ||
| } | ||
|
|
||
| <mat-card-content> | ||
| <p>{{ task?.statusHelp().reason }} {{ task?.statusHelp().action }}</p> | ||
| </mat-card-content> | ||
|
|
||
| @if ( task?.unit.currentUserIsStaff || task?.canApplyForExtension() || (task?.inSubmittedState() && | ||
| task?.requiresFileUpload()) ) { | ||
| <mat-card-actions class="space-x-2"> | ||
| @if (task?.canApplyForExtension()) { | ||
| <button mat-stroked-button (click)="applyForExtension()">Request extension</button> | ||
| } @if (task?.inSubmittedState() && task?.requiresFileUpload()) { | ||
| <button mat-stroked-button (click)="updateFilesInSubmission()">Upload new files</button> | ||
| <button mat-stroked-button (click)="applyForExtension()">Request extension</button> | ||
| } | ||
| </mat-card-actions> | ||
| } | ||
| </mat-card> | ||
| @if (task?.inSubmittedState() && task?.requiresFileUpload()) { | ||
| <button mat-stroked-button (click)="updateFilesInSubmission()">Upload new files</button> | ||
| } | ||
| @if (currentUser?.role === 'Admin' || currentUser?.role === 'Convenor') { | ||
| <button mat-stroked-button (click)="openGrantExtensionDialog()"> | ||
| Grant Extension | ||
| </button> | ||
|
|
||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ruby file needs to be removed