From d2dd9df9ca356a40bcec3d1f9506445480aec9e8 Mon Sep 17 00:00:00 2001 From: Lolretrorat Date: Fri, 5 Sep 2025 01:33:28 +1000 Subject: [PATCH] feat: enhance search in courseflow with autocomplete and remove --- .../services/course-map-state.service.ts | 16 ++++ .../states/coursemap/coursemap.component.html | 6 +- .../states/coursemap/coursemap.component.ts | 8 ++ .../elective-units-list.component.html | 5 +- .../elective-units-list.component.ts | 5 ++ .../unit-search/unit-search.component.html | 14 +++- .../unit-search/unit-search.component.scss | 21 +++++ .../unit-search/unit-search.component.ts | 76 +++++++++++++++++-- 8 files changed, 139 insertions(+), 12 deletions(-) diff --git a/src/app/courseflow/services/course-map-state.service.ts b/src/app/courseflow/services/course-map-state.service.ts index 3289ef30e..a4ba3ea28 100644 --- a/src/app/courseflow/services/course-map-state.service.ts +++ b/src/app/courseflow/services/course-map-state.service.ts @@ -197,6 +197,22 @@ export class CourseMapStateService { return true; } + removeElectiveUnit(index: number): void { + const currentState = this.currentState; + // index validation + if (index < 0 || index >= currentState.electiveUnits.length) { + return; + } + + const updatedElectiveUnits = [...currentState.electiveUnits]; + updatedElectiveUnits.splice(index, 1); + + this.updateState({ + ...currentState, + electiveUnits: updatedElectiveUnits, + }); + } + removeUnitFromSlot( yearIndex: number, trimesterKey: 'trimester1' | 'trimester2' | 'trimester3', diff --git a/src/app/courseflow/states/coursemap/coursemap.component.html b/src/app/courseflow/states/coursemap/coursemap.component.html index c8c32910e..b901335e8 100644 --- a/src/app/courseflow/states/coursemap/coursemap.component.html +++ b/src/app/courseflow/states/coursemap/coursemap.component.html @@ -11,10 +11,14 @@ [remainingSlots]="getRemainingElectiveSlots()" [maxElectiveUnits]="state.maxElectiveUnits" (dropEvent)="handleDrop($event)" + (removeUnit)="removeElectiveUnit($event)" > - + diff --git a/src/app/courseflow/states/coursemap/coursemap.component.ts b/src/app/courseflow/states/coursemap/coursemap.component.ts index a21050fbe..d3152be53 100644 --- a/src/app/courseflow/states/coursemap/coursemap.component.ts +++ b/src/app/courseflow/states/coursemap/coursemap.component.ts @@ -231,11 +231,19 @@ export class CoursemapComponent implements OnInit, OnDestroy { return this.stateService.addElectiveUnit(unit); } + removeElectiveUnit(index: number): void { + this.stateService.removeElectiveUnit(index); + } + getAvailableUnits(): Unit[] { const allRequiredIds = new Set(this.state.allRequiredUnits.map((u) => u.id)); return this.units.filter((unit) => !allRequiredIds.has(unit.id)); } + getAllAddedUnits(): Unit[] { + return [...this.state.allRequiredUnits, ...this.state.electiveUnits]; + } + getRemainingElectiveSlots(): number { return this.stateService.getRemainingElectiveSlots(); } diff --git a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html index 0044d55bc..5eb97fe20 100644 --- a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html +++ b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html @@ -14,10 +14,11 @@

Elective Units

class="units-list" > diff --git a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.ts b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.ts index 1e3812a90..a19c5e0db 100644 --- a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.ts +++ b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.ts @@ -17,12 +17,17 @@ export class ElectiveUnitsListComponent { @Input() maxElectiveUnits!: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any @Output() dropEvent = new EventEmitter(); + @Output() removeUnit = new EventEmitter(); // Emit the index of the unit to remove // eslint-disable-next-line @typescript-eslint/no-explicit-any onDrop(event: any): void { this.dropEvent.emit(event); } + onRemoveUnit(index: number): void { + this.removeUnit.emit(index); + } + getDragData(unit: Unit) { return { unit: unit, diff --git a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.html b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.html index 6ac5275fa..7bf4e3777 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.html +++ b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.html @@ -4,11 +4,21 @@ + + + {{ unit.code }} + - {{ unit.name }} + + diff --git a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss index 000e3a863..22b62a2e9 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss +++ b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss @@ -38,3 +38,24 @@ gap: 8px; font-weight: 800; } + +// Autocomplete option styling +.unit-code { + font-weight: 600; + color: #1976d2; +} + +.unit-name { + color: #666; + font-style: italic; +} + +::ng-deep .mat-autocomplete-panel { + max-height: 300px; +} + +::ng-deep .mat-option { + line-height: 1.4 !important; + height: auto !important; + padding: 12px 16px !important; +} diff --git a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts index 8b1c22846..8bcd2d03c 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts +++ b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts @@ -1,10 +1,14 @@ -import {Component, Input, Output, EventEmitter} from '@angular/core'; +import {Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges} from '@angular/core'; import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; +import {FormsModule, ReactiveFormsModule, FormControl} from '@angular/forms'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatInputModule} from '@angular/material/input'; import {MatButtonModule} from '@angular/material/button'; import {MatIconModule} from '@angular/material/icon'; +import {MatAutocompleteModule} from '@angular/material/autocomplete'; +import {MatOptionModule} from '@angular/material/core'; +import {Observable} from 'rxjs'; +import {map, startWith} from 'rxjs/operators'; import {Unit} from 'src/app/api/models/doubtfire-model'; @Component({ @@ -15,34 +19,92 @@ import {Unit} from 'src/app/api/models/doubtfire-model'; imports: [ CommonModule, FormsModule, + ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, + MatAutocompleteModule, + MatOptionModule, ], }) -export class UnitSearchComponent { +export class UnitSearchComponent implements OnInit, OnChanges { @Input() availableUnits!: Unit[]; + @Input() addedUnits: Unit[] = []; // New input for already added units @Output() unitAdded = new EventEmitter(); - unitCode = ''; + unitCodeControl = new FormControl(''); + filteredUnits: Observable; errorMessage: string | null = null; + constructor() { + this.filteredUnits = this.unitCodeControl.valueChanges.pipe( + startWith(''), + map(value => this._filter(this._normalizeValue(value))) + ); + } + + ngOnInit(): void { + // Component initialization if needed + } + + ngOnChanges(changes: SimpleChanges): void { + // Reinitialize filtered units when addedUnits changes + if (changes['addedUnits']) { + this.filteredUnits = this.unitCodeControl.valueChanges.pipe( + startWith(this.unitCodeControl.value || ''), + map(value => this._filter(this._normalizeValue(value))) + ); + } + } + + private _normalizeValue(value: string | Unit | null): string { + if (!value) return ''; + if (typeof value === 'string') return value; + return value.code; // If it's a Unit object, use the code + } + + private _filter(value: string): Unit[] { + const filterValue = value.toLowerCase(); + // Get unit codes that have already been added + const addedUnitCodes = this.addedUnits.map(unit => unit.code); + + return this.availableUnits.filter(unit => ( + (unit.code.toLowerCase().includes(filterValue) || + unit.name.toLowerCase().includes(filterValue)) && + // exclude already added units + !addedUnitCodes.includes(unit.code) + )); + } + onSubmit(): void { - if (!this.unitCode) { + const unitCode = this.unitCodeControl.value; + if (!unitCode) { this.errorMessage = 'Please enter a unit code'; return; } - const trimmedCode = this.unitCode.trim().toUpperCase(); + // Normalize the value to a string + const normalizedCode = this._normalizeValue(unitCode); + const trimmedCode = normalizedCode.trim().toUpperCase(); const foundUnit = this.availableUnits.find((unit) => unit.code === trimmedCode); if (foundUnit) { this.unitAdded.emit(foundUnit); - this.unitCode = ''; + this.unitCodeControl.setValue(''); this.errorMessage = null; } else { this.errorMessage = `Unit code ${trimmedCode} not found in available units`; } } + + onUnitSelected(unit: Unit): void { + this.unitAdded.emit(unit); + this.unitCodeControl.setValue(''); + this.errorMessage = null; + } + + displayUnit(unit: Unit): string { + return unit ? `${unit.code} - ${unit.name}` : ''; + } }