diff --git a/backend/extra/seedDatabase.ts b/backend/extra/seedDatabase.ts index 3e28be40..5a48982e 100644 --- a/backend/extra/seedDatabase.ts +++ b/backend/extra/seedDatabase.ts @@ -80,7 +80,7 @@ export async function seedDatabase(): Promise { const teacherInput: Teacher = new Teacher( email, firstName, - lastName, + 'T. '+lastName, passwordHash, schoolName, ) @@ -113,7 +113,7 @@ export async function seedDatabase(): Promise { const studentInput: Student = new Student( email, firstName, - lastName, + 'S. '+lastName, passwordHash, schoolName, ) @@ -231,12 +231,11 @@ export async function seedDatabase(): Promise { const visibilityOptions = [ VisibilityType.PRIVATE, VisibilityType.GROUP, - VisibilityType.PUBLIC ]; for (const { id: assignmentId, classId } of assignments) { - const students = await userRep.getByClassId(classId); - const teachers = await userRep.getByClassId(classId); + const students = await userRep.getStudentsByClassId(classId); + const teachers = await userRep.getTeachersByClassId(classId); const teacherIds = teachers.map(t => t.id); // Select 5–6 students for threads @@ -258,7 +257,6 @@ export async function seedDatabase(): Promise { const visibility = faker.helpers.arrayElement(visibilityOptions); - const thread = new QuestionThread( student.id!, assignmentId, @@ -288,27 +286,6 @@ export async function seedDatabase(): Promise { await messageRep.create(teacherMessage); } } - - // Bonus: Add 2 public/global threads for this assignment - for (let k = 0; k < 2; k++) { - const globalThread = new QuestionThread( - faker.helpers.arrayElement(students).id!, - assignmentId, - `step-${faker.number.int({ min: 1, max: 5 })}`, - false, - VisibilityType.PUBLIC, - [] - ); - const savedThread = await threadRep.create(globalThread) as { id: string }; - - const msg = new Message( - faker.helpers.arrayElement(teacherIds)!, - new Date(), - savedThread.id!, - faker.lorem.sentence() - ); - await messageRep.create(msg); - } } } catch (err) { console.error('Error during DB seeding:', err); diff --git a/frontend/src/app/components/assignment/assignment.component.html b/frontend/src/app/components/assignment/assignment.component.html index 1415eeee..2a535463 100644 --- a/frontend/src/app/components/assignment/assignment.component.html +++ b/frontend/src/app/components/assignment/assignment.component.html @@ -9,29 +9,36 @@ [progress]="progress" [step]="furthestStep" (selectedNodeChanged)="onSelectedNodeChanged($event)" (emitGraph)="retrieveGraph($event)"> - @if(!alreadySubmitted && isStudent){ - @if (taskFetched ){ - - } - @if (noTask ){ - - } - }@else if(isStudent) { - @if(submissionType === 'accepted') { -

Your submission is marked Correct!

- } - @else if(submissionType === 'rejected'){ -

Your submission has been marked as Incorrect!

- } - @else { -

Already submitted!

- } - } @else if(!isStudent && !initializeTasks && submissionStatsReady) { - - } +
+ @if(!alreadySubmitted && isStudent){ + @if (taskFetched ){ + + } + @if (noTask ){ + + } + }@else if(isStudent) { + @if(submissionType === 'accepted') { +

Your submission is marked Correct!

+ } + @else if(submissionType === 'rejected'){ +

Your submission has been marked as Incorrect!

+ } + @else { +

Already submitted!

+ } + } @else if(!isStudent && !initializeTasks && submissionStatsReady) { + + } + + @if (isStudent){ + + + } +
} diff --git a/frontend/src/app/components/assignment/assignment.component.less b/frontend/src/app/components/assignment/assignment.component.less index bf97062a..9c5636e4 100644 --- a/frontend/src/app/components/assignment/assignment.component.less +++ b/frontend/src/app/components/assignment/assignment.component.less @@ -55,4 +55,11 @@ .stats { width: 80%; +} + +.inline { + display: flex; + flex-direction: row; + width: 100%; + justify-content: center; } \ No newline at end of file diff --git a/frontend/src/app/components/assignment/assignment.component.ts b/frontend/src/app/components/assignment/assignment.component.ts index e073d651..8b7b41fb 100644 --- a/frontend/src/app/components/assignment/assignment.component.ts +++ b/frontend/src/app/components/assignment/assignment.component.ts @@ -28,13 +28,27 @@ import { SubmissionService } from '../../services/submission.service'; import { UserService } from '../../services/user.service'; import { forkJoin, of, switchMap } from 'rxjs'; import { Submission } from '../../interfaces/submissions'; +import { ChatPopupComponent } from '../chat-popup/chat-popup.component'; import { MatIcon, MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; @Component({ selector: 'app-assignment', - imports: [LearningPathComponent, MatSelectModule, MatIcon, MatIconModule, MatTooltipModule, MatButtonModule, MatFormFieldModule, MatOptionModule, CreateSubmissionComponent, MatProgressBar, MatCardModule, LoadingComponent, CreateTaskComponent, AssignmentStatsComponent], + imports: [ + LearningPathComponent, + MatSelectModule, + MatIcon, MatIconModule, MatTooltipModule, MatButtonModule, + MatFormFieldModule, + MatOptionModule, + CreateSubmissionComponent, + MatProgressBar, + MatCardModule, + LoadingComponent, + CreateTaskComponent, + AssignmentStatsComponent, + ChatPopupComponent, + ], templateUrl: './assignment.component.html', styleUrl: './assignment.component.less' }) @@ -177,11 +191,12 @@ export class AssignmentComponent implements OnInit { progressObservable.subscribe( (res) => { this.progress = res; - this.step = this.progress.step; + this.step = this.progress.step - 1; this.furthestStep = this.progress.step; // furthest step is always returned by progress this.alreadySubmitted = this.step < this.furthestStep this.maxStep = this.progress.maxStep; this.loading = false; + console.log(this.step, this.furthestStep, this.maxStep) } ) } diff --git a/frontend/src/app/components/authenticated-menu/authenticated-menu.component.html b/frontend/src/app/components/authenticated-menu/authenticated-menu.component.html index 253052d5..a02c9599 100644 --- a/frontend/src/app/components/authenticated-menu/authenticated-menu.component.html +++ b/frontend/src/app/components/authenticated-menu/authenticated-menu.component.html @@ -4,10 +4,12 @@ + } @else { + } diff --git a/frontend/src/app/components/chat-popup/chat-popup.component.html b/frontend/src/app/components/chat-popup/chat-popup.component.html new file mode 100644 index 00000000..475137a8 --- /dev/null +++ b/frontend/src/app/components/chat-popup/chat-popup.component.html @@ -0,0 +1,26 @@ + + + +
+
+ + Chat + + + + +
+ + +
+
\ No newline at end of file diff --git a/frontend/src/app/components/chat-popup/chat-popup.component.less b/frontend/src/app/components/chat-popup/chat-popup.component.less new file mode 100644 index 00000000..6fb0d221 --- /dev/null +++ b/frontend/src/app/components/chat-popup/chat-popup.component.less @@ -0,0 +1,55 @@ +.chat-fab { + margin-left: 3rem; +} + +.chat-dialog-container { + .mat-mdc-dialog-container { + height: 100% !important; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + + .mdc-dialog__surface { + height: 100%; + display: flex; + flex-direction: column; + } + } +} + +.chat-dialog { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + + app-chat { + flex: 1; + overflow-y: auto; + } +} + +.chat-custom-toolbar { + position: sticky; + top: 0; + z-index: 2; + height: 64px; + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + + .spacer { + flex: 1; + } + + .mat-icon-button { + background: transparent !important; + border: none !important; + + &:hover { + background: rgba(255, 255, 255, 0.1) !important; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/chat-popup/chat-popup.component.ts b/frontend/src/app/components/chat-popup/chat-popup.component.ts new file mode 100644 index 00000000..3ca96685 --- /dev/null +++ b/frontend/src/app/components/chat-popup/chat-popup.component.ts @@ -0,0 +1,122 @@ +import { Component, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { Overlay } from '@angular/cdk/overlay'; +import { NavigationStart, Router } from '@angular/router'; +import { QuestionThreadService } from '../../services/questionThread.service'; +import { ChatComponent } from '../../components/chat/chat.component'; +import { TemplateRef } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { MatButtonModule } from '@angular/material/button'; +import { QuestionThread } from '../../interfaces/questionThread'; + +@Component({ + selector: 'app-chat-popup', + standalone: true, + imports: [ + MatDialogModule, + MatIconModule, + MatToolbarModule, + MatButtonModule, + ChatComponent, + ], + templateUrl: './chat-popup.component.html', + styleUrls: ['./chat-popup.component.less'] +}) +export class ChatPopupComponent implements OnDestroy { + @Input() assignmentId!: string; + @Input() currentLearningObjectId!: string; + @Output() chatToggled = new EventEmitter(); + + @ViewChild('chatDialog') chatDialogTemplate!: TemplateRef; + private routerSubscription: Subscription; + private chatDialogRef?: MatDialogRef; + + public currentThreadId: string = ""; + public isOpen = false; + + constructor( + private chatService: QuestionThreadService, + private dialog: MatDialog, + private overlay: Overlay, + private router: Router + ) { + // Subscribe to router events to close chat on navigation + this.routerSubscription = this.router.events.subscribe(event => { + if (event instanceof NavigationStart && this.isOpen) { + this.close(); + } + }); + } + + ngOnDestroy() { + this.routerSubscription.unsubscribe(); + } + + async open() { + if (this.isOpen) { + this.close(); + return; + } + + await this.checkOrCreateThread(); + + this.chatDialogRef = this.dialog.open(this.chatDialogTemplate, { + width: '400px', + height: '600px', + panelClass: 'chat-dialog-container', + position: { bottom: '80px', right: '20px' }, + hasBackdrop: false, + disableClose: true, + scrollStrategy: this.overlay.scrollStrategies.noop(), + autoFocus: false + }); + + this.isOpen = true; + this.chatToggled.emit(true); + + this.chatDialogRef.afterClosed().subscribe(() => { + this.isOpen = false; + this.chatToggled.emit(false); + }); + } + + close() { + if (this.chatDialogRef) { + this.chatDialogRef.close(); + } + } + + navigateToFullChat() { + this.router.navigate(['/student/chat', this.currentThreadId]); + } + + private async checkOrCreateThread() { + if (!this.assignmentId) { + this.currentThreadId = "new"; + return; + } + + if (!this.currentLearningObjectId) { + this.currentThreadId = "new"; + return; + } + + this.chatService.retrieveQuestionThreadByStep( + this.assignmentId, + this.currentLearningObjectId + ).subscribe({ + next: (thread: QuestionThread | null) => { + if (thread && thread.id) { + this.currentThreadId = thread.id; + } else { + this.currentThreadId = "new"; + } + }, + error: () => { + this.currentThreadId = "new"; + } + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/chat/chat.component.html b/frontend/src/app/components/chat/chat.component.html index 7c231d3e..7c33b340 100644 --- a/frontend/src/app/components/chat/chat.component.html +++ b/frontend/src/app/components/chat/chat.component.html @@ -1,47 +1,53 @@
+ @if (showHeader) { - Thread + {{ title }} + + + + - - - - + } +
@for (msg of messages; track msg.id) { - - - {{ usernamesMap[msg.senderId] || 'User' }} - {{ msg.createdAt | date: 'short' }} - - {{ msg.content }} - + + + {{ usernamesMap[msg.senderId] || 'User' }} + {{ msg.createdAt | date: 'short' }} + + {{ msg.content }} + }
- + + - - - @for (thread of questionThreads; track thread.id) { - - - {{ thread.visibility === VisibilityType.PRIVATE ? '💬' : (thread.visibility === VisibilityType.GROUP ? '👥' : '🌐') }} - - Chat {{ thread.id.slice(0, 12) }}... - - } - -
- -
- @if (!validChatId) { -
Invalid chat ID
- } @else { - - } -
- +
+
\ No newline at end of file diff --git a/frontend/src/app/pages/chat-page/chat-page.component.less b/frontend/src/app/pages/chat-page/chat-page.component.less index 3ca5a782..dafda8ae 100644 --- a/frontend/src/app/pages/chat-page/chat-page.component.less +++ b/frontend/src/app/pages/chat-page/chat-page.component.less @@ -1,54 +1,18 @@ .chat-page { - display: flex; - flex-direction: column; - height: 100vh; - margin: 0; - padding: 0; - overflow: hidden; - - app-authenticated-header { - flex-shrink: 0; - height: 64px; /* Match your header height */ - } - - .content { - flex: 1; - display: flex; - min-height: 0; - } + display: flex; + flex-direction: column; + height: 100vh; + margin: 0; + padding: 0; + overflow: hidden; - .sidebar { - width: 300px; - border-right: 1px solid #ccc; - overflow-y: auto; - flex-shrink: 0; - - .toggle-button { - width: calc(100% - 32px); - margin: 16px; - text-align: center; - white-space: normal; - line-height: 1.4; - } - - .visibility-badge { - font-size: 0.75rem; - padding: 2px 6px; - border-radius: 4px; - margin-right: 8px; - color: white; - display: inline-block; - } - - .active { - background-color: #f5f5f5; - font-weight: 500; - } - } - - .chatbox { - flex: 1; - position: relative; - min-height: 0; - } + app-authenticated-header { + flex-shrink: 0; + height: 64px; + } + .content { + flex: 1; + display: flex; + min-height: 0; + } } \ No newline at end of file diff --git a/frontend/src/app/pages/chat-page/chat-page.component.ts b/frontend/src/app/pages/chat-page/chat-page.component.ts index 26d7aad5..81aacfe7 100644 --- a/frontend/src/app/pages/chat-page/chat-page.component.ts +++ b/frontend/src/app/pages/chat-page/chat-page.component.ts @@ -1,93 +1,21 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { QuestionThreadService } from '../../services/questionThread.service'; -import { MatListModule } from '@angular/material/list'; -import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; -import { ChatComponent } from '../../components/chat/chat.component'; -import { QuestionThread } from '../../interfaces/questionThread'; import { AuthenticatedHeaderComponent } from '../../components/authenticated-header/authenticated-header.component'; -import { AuthenticationService } from '../../services/authentication.service'; -import { VisibilityType } from '../../interfaces/questionThread/questionThread'; -import { UserType } from '../../interfaces'; +import { ChatViewComponent } from '../chat-view/chat-view.component'; @Component({ selector: 'app-chat-page', standalone: true, imports: [ CommonModule, - MatListModule, - MatButtonModule, RouterModule, - ChatComponent, AuthenticatedHeaderComponent, + ChatViewComponent ], templateUrl: './chat-page.component.html', styleUrls: ['./chat-page.component.less'] }) -export class ChatPageComponent implements OnInit { - public chatId: string = ''; - public validChatId: boolean = false; - public questionThreads: QuestionThread[] = []; - public showPublicChats = false; - public currentLearningObjectId: string | null = null; - public VisibilityType = VisibilityType; // For template binding - public userType: string = this.authService.retrieveUserType() === UserType.STUDENT? 'student' : 'teacher'; - - constructor( - private route: ActivatedRoute, - private threadService: QuestionThreadService, - private authService: AuthenticationService - ){} - - ngOnInit(): void { - this.loadChats(); - - // Watch for route changes to get current thread's learning object ID - this.route.paramMap.subscribe(params => { - this.chatId = params.get('id') || ''; - this.updateCurrentLearningObjectId(); - }); - - this.threadService.threadUpdate$.subscribe(threadUpdate => { - // Update the sidebar badges based on the latest thread visibilities - // console.log('[Sidebar Update] Got update for thread:', threadUpdate); - this.questionThreads = this.questionThreads.map(thread => { - if (thread.id === threadUpdate.id) { - // console.log('[Sidebar Update] Updating thread:', thread.id, 'with visibility:', threadUpdate.update.visibility); - return { ...thread, visibility: threadUpdate.update.visibility }; - } - return thread; - }); - }); - } - - private loadChats(): void { - const userId = this.authService.retrieveUserId(); - this.threadService.loadSideBarQuestionThreads( - userId || '', - this.currentLearningObjectId || '', - this.showPublicChats - ).subscribe({ - next: threads => { - this.questionThreads = threads; - this.updateCurrentLearningObjectId(); - // console.log('Loaded question threads:', this.questionThreads); - }, - }); - } - - private updateCurrentLearningObjectId(): void { - if (this.chatId) { - const currentThread = this.questionThreads.find(t => t.id === this.chatId); - this.currentLearningObjectId = currentThread?.learningObjectId || null; - } - this.validChatId = this.questionThreads.some(t => t.id === this.chatId); - } - - toggleChatVisibility(): void { - this.showPublicChats = !this.showPublicChats; - this.loadChats(); // Reload chats with new filter - } +export class ChatPageComponent { + constructor() {} } \ No newline at end of file diff --git a/frontend/src/app/pages/chat-view/chat-view.component.html b/frontend/src/app/pages/chat-view/chat-view.component.html new file mode 100644 index 00000000..b48012f1 --- /dev/null +++ b/frontend/src/app/pages/chat-view/chat-view.component.html @@ -0,0 +1,37 @@ + + +
+ @if (!validChatId) { + + {{ INVALID_CHAT_ID }} + + } @else { + + } +
\ No newline at end of file diff --git a/frontend/src/app/pages/chat-view/chat-view.component.less b/frontend/src/app/pages/chat-view/chat-view.component.less new file mode 100644 index 00000000..c9657c46 --- /dev/null +++ b/frontend/src/app/pages/chat-view/chat-view.component.less @@ -0,0 +1,52 @@ +:host { + flex: 1; + display: flex; + min-height: 0; +} + +.sidebar { + width: 300px; + border-right: 1px solid #ccc; + overflow-y: auto; + flex-shrink: 0; + + .toggle-button { + width: calc(100% - 32px); + margin: 16px; + text-align: center; + white-space: normal; + line-height: 1.4; + } + + a[mat-list-item].active { + background-color: #f5f5f5; + font-weight: 500; + } + a[mat-list-item] { + cursor: pointer; + &:hover, &:focus { + background-color: #eeeeee; + } + } +} + +.chatbox { + flex: 1; + position: relative; + min-height: 0; + display: flex; + flex-direction: column; + + .toolbar { + height: 64px; + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + } + + app-chat { + flex-grow: 1; + min-height: 0; + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/chat-view/chat-view.component.spec.ts b/frontend/src/app/pages/chat-view/chat-view.component.spec.ts new file mode 100644 index 00000000..951a9a4f --- /dev/null +++ b/frontend/src/app/pages/chat-view/chat-view.component.spec.ts @@ -0,0 +1,136 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ChatViewComponent } from './chat-view.component'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { of, Subject, throwError } from 'rxjs'; +import { QuestionThreadService } from '../../services/questionThread.service'; +import { AuthenticationService } from '../../services/authentication.service'; +import { UserType } from '../../interfaces'; +import { Router } from '@angular/router'; +import { Component, Input } from '@angular/core'; +import { QuestionThread, VisibilityType } from '../../interfaces/questionThread/questionThread'; + +@Component({ selector: 'app-chat', template: '' }) +class MockChatComponent { @Input() questionThreadId!: string; } + +describe('ChatViewComponent', () => { + let component: ChatViewComponent; + let fixture: ComponentFixture; + + let routeParamSubject: Subject; + let threadServiceSpy: jasmine.SpyObj; + let authServiceSpy: jasmine.SpyObj; + let threadUpdateSubject: Subject<{ id: string; update: Partial }>; + let routerSpy: jasmine.SpyObj; + + beforeEach(() => { + routeParamSubject = new Subject(); + threadUpdateSubject = new Subject<{ id: string; update: Partial }>(); + + threadServiceSpy = jasmine.createSpyObj('QuestionThreadService', ['loadSideBarQuestionThreads']); + Object.defineProperty(threadServiceSpy, 'threadUpdate$', { get: () => threadUpdateSubject.asObservable() }); + threadServiceSpy.loadSideBarQuestionThreads.and.returnValue(of([])); + + authServiceSpy = jasmine.createSpyObj('AuthenticationService', ['retrieveUserType']); + authServiceSpy.retrieveUserType.and.returnValue(UserType.STUDENT); + + routerSpy = jasmine.createSpyObj('Router', ['navigate']); + + TestBed.configureTestingModule({ + imports: [ChatViewComponent, MockChatComponent], + providers: [ + { provide: QuestionThreadService, useValue: threadServiceSpy }, + { provide: AuthenticationService, useValue: authServiceSpy }, + { provide: ActivatedRoute, useValue: { paramMap: routeParamSubject.asObservable() } }, + { provide: Router, useValue: routerSpy } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ChatViewComponent); + component = fixture.componentInstance; + }); + + it('should set userType and OTHER_CHATS on init for student', () => { + spyOn(component, 'loadChats'); + component.ngOnInit(); + expect(authServiceSpy.retrieveUserType).toHaveBeenCalled(); + expect(component.userType).toBe('student'); + expect(component.OTHER_CHATS).toBe(component.GROUP_CHATS); + }); + + it('loadChats success should populate questionThreads and validChatId false', fakeAsync(() => { + const threads: QuestionThread[] = [{ id: '1', assignmentId: 'a1', visibility: VisibilityType.PRIVATE, creatorId: '', learningObjectId: '', isClosed: false }]; + threadServiceSpy.loadSideBarQuestionThreads.and.returnValue(of(threads)); + component.currentAssignmentId = null; + component.showOtherChats = false; + component.loadChats(); + tick(); + expect(component.questionThreads).toEqual(threads); + expect(component.validChatId).toBeFalse(); + })); + + it('loadChats error should clear questionThreads and validChatId false', fakeAsync(() => { + threadServiceSpy.loadSideBarQuestionThreads.and.returnValue(throwError(() => new Error('Load error'))); + component.loadChats(); + tick(); + expect(component.questionThreads).toEqual([]); + expect(component.validChatId).toBeFalse(); + })); + + it('should respond to route param changes', fakeAsync(() => { + const threads: QuestionThread[] = [{ id: '1', assignmentId: 'a1', visibility: VisibilityType.PRIVATE, creatorId: '', learningObjectId: '', isClosed: false }]; + threadServiceSpy.loadSideBarQuestionThreads.and.returnValue(of(threads)); + component.ngOnInit(); + tick(); + + const fakeParamMap = new Map(); + fakeParamMap.set('id', '1'); + routeParamSubject.next({ + get: (key: string) => fakeParamMap.get(key), + has: (key: string) => fakeParamMap.has(key), + } as unknown as ParamMap); + tick(); + + expect(component.currentSelectedChatId).toBe('1'); + expect(component.validChatId).toBeTrue(); + expect(component.currentAssignmentId).toBe('a1'); + })); + + it('toggleChatVisibility should flip flag and reload chats', () => { + threadServiceSpy.loadSideBarQuestionThreads.and.returnValue(of([])); + component.showOtherChats = false; + component.currentAssignmentId = null; + component.toggleChatVisibility(); + expect(component.showOtherChats).toBeTrue(); + expect(threadServiceSpy.loadSideBarQuestionThreads).toHaveBeenCalledWith('', true); + }); + + it('handleChatItemClick in compact mode should update selection without navigation', fakeAsync(() => { + component.compact = true; + component.questionThreads = [{ + id: '2', assignmentId: 'a2', visibility: VisibilityType.PRIVATE, creatorId: '', learningObjectId: '', isClosed: false + }]; + component.currentSelectedChatId = ''; + component.handleChatItemClick('2'); + expect(component.currentSelectedChatId).toBe('2'); + expect(component.validChatId).toBeTrue(); + expect(routerSpy.navigate).not.toHaveBeenCalled(); + })); + + it('handleChatItemClick in full mode should navigate', () => { + component.compact = false; + component.userType = 'teacher'; + component.handleChatItemClick('3'); + expect(routerSpy.navigate).toHaveBeenCalledWith(['/', 'teacher', 'chat', '3']); + }); + + it('should update thread on threadUpdate$', () => { + const threads: QuestionThread[] = [{ + id: '1', assignmentId: 'a1', visibility: VisibilityType.PRIVATE, creatorId: '', learningObjectId: '', isClosed: false + }]; + component.questionThreads = threads; + spyOn(component, 'loadChats'); + component.ngOnInit(); + threadUpdateSubject.next({ id: '1', update: { visibility: VisibilityType.GROUP } }); + expect(component.questionThreads[0].visibility).toBe(VisibilityType.GROUP); + }); +}); diff --git a/frontend/src/app/pages/chat-view/chat-view.component.ts b/frontend/src/app/pages/chat-view/chat-view.component.ts new file mode 100644 index 00000000..36d1d478 --- /dev/null +++ b/frontend/src/app/pages/chat-view/chat-view.component.ts @@ -0,0 +1,143 @@ +import { Component, OnInit, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { QuestionThreadService } from '../../services/questionThread.service'; +import { MatListModule } from '@angular/material/list'; +import { MatButtonModule } from '@angular/material/button'; +import { ChatComponent } from '../../components/chat/chat.component'; +import { QuestionThread } from '../../interfaces/questionThread'; +import { AuthenticationService } from '../../services/authentication.service'; +import { UserType } from '../../interfaces'; +import { VisibilityType } from '../../interfaces/questionThread/questionThread'; +import { MatToolbarModule } from '@angular/material/toolbar'; + +@Component({ + selector: 'app-chat-view', + standalone: true, + imports: [ + CommonModule, + MatListModule, + MatButtonModule, + RouterModule, + ChatComponent, + MatToolbarModule + ], + templateUrl: './chat-view.component.html', + styleUrls: ['./chat-view.component.less'] +}) +export class ChatViewComponent implements OnInit, OnChanges { + @Input() compact: boolean = false; + @Input() initialChatId: string | null = null; + + public currentSelectedChatId: string = ''; + public validChatId: boolean = false; + public questionThreads: QuestionThread[] = []; + public showOtherChats = false; + public currentAssignmentId: string | null = null; + public VisibilityType = VisibilityType; + public userType: string = ''; + + readonly USER_CHATS = $localize`:@@userChats:Show my chats`; + readonly ASSIGNMENT_CHATS = $localize`:@@otherChats:Show other chats for this assignment`; + readonly GROUP_CHATS = $localize`:@@groupChats:Show group chats`; + public OTHER_CHATS: string = ''; + readonly UNNAMED_CHAT = $localize`:@@unnamedChatFallback:Unnamed Chat`; + readonly INVALID_CHAT_ID = $localize`:@@invalidChatId:Select a chat`; + + constructor( + private route: ActivatedRoute, + private router: Router, + private threadService: QuestionThreadService, + private authService: AuthenticationService + ) {} + + ngOnInit(): void { + const retrievedUserType = this.authService.retrieveUserType(); + this.userType = retrievedUserType === UserType.STUDENT ? 'student' : 'teacher'; + this.OTHER_CHATS = this.userType === 'teacher' ? this.ASSIGNMENT_CHATS : this.GROUP_CHATS; + + this.loadChats(); + + if (!this.compact) { + // Subscribe to route changes for chat ID. + // This will not reload the sidebar, only update the selection. + this.route.paramMap.subscribe(params => { + this.currentSelectedChatId = params.get('id') || ''; + this.updateChatSelectionState(); + }); + } else { + // In compact mode, if initialChatId is provided, set it. + // updateChatSelectionState will be called once loadChats completes. + if (this.initialChatId) { + this.currentSelectedChatId = this.initialChatId; + } + } + + this.threadService.threadUpdate$.subscribe(threadUpdate => { + this.questionThreads = this.questionThreads.map(thread => { + if (thread.id === threadUpdate.id) { + return { ...thread, visibility: threadUpdate.update.visibility }; + } + return thread; + }); + // Visibility change of a thread in the list doesn't require re-validating current selection + // unless the selected thread itself is modified in a way that affects its validity (e.g., deleted). + // For visibility, the badge will just update. + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (this.compact && changes['initialChatId'] && !changes['initialChatId'].firstChange) { + this.currentSelectedChatId = this.initialChatId || ''; + this.updateChatSelectionState(); + } + } + + public loadChats(): void { + this.threadService.loadSideBarQuestionThreads( + this.currentAssignmentId || '', + this.showOtherChats + ).subscribe({ + next: threads => { + this.questionThreads = threads; + this.updateChatSelectionState(); + }, + error: () => { + this.questionThreads = []; + this.updateChatSelectionState(); + } + }); + } + + private updateChatSelectionState(): void { + if (this.currentSelectedChatId && this.questionThreads.length > 0) { + const currentThread = this.questionThreads.find(t => t.id === this.currentSelectedChatId); + if (currentThread) { + this.currentAssignmentId = currentThread.assignmentId || null; + this.validChatId = true; + } else { + // currentSelectedChatId from route/input is not in the current list + this.currentAssignmentId = null; + this.validChatId = false; + } + } else { + // No chat selected, or no threads loaded + this.currentAssignmentId = null; + this.validChatId = false; + } + } + + toggleChatVisibility(): void { + this.showOtherChats = !this.showOtherChats; + this.loadChats(); + } + + handleChatItemClick(threadId: string): void { + if (this.compact) { + this.currentSelectedChatId = threadId; + this.updateChatSelectionState(); + } else { + this.router.navigate(['/', this.userType, 'chat', threadId]); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/services/learningObject.service.ts b/frontend/src/app/services/learningObject.service.ts index e2b80c3f..aedb87d7 100644 --- a/frontend/src/app/services/learningObject.service.ts +++ b/frontend/src/app/services/learningObject.service.ts @@ -3,7 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { AuthenticationService } from './authentication.service'; import { ErrorService } from './error.service'; import { environment } from '../../environments/environment'; -import { forkJoin, Observable, switchMap } from 'rxjs'; +import { catchError, forkJoin, map, Observable, of, switchMap } from 'rxjs'; import { SpecificLearningPathRequest } from '../interfaces/learning-path'; import { HtmlType, LearningObject, LearningObjectRequest } from '../interfaces/learning-object'; import { LearningPathService } from './learningPath.service'; @@ -51,6 +51,35 @@ export class LearningObjectService { return forkJoin(nodes.map(node => this.retrieveOneLearningObject(node))); } + /** + * Fetches the LO and emits its title, or falls back to the provided default. + */ + getTitleOrFallback( + hruid: string, + // htmlType: HtmlType, + fallback: string + ): Observable { + const headers = this.authenticationService.retrieveAuthenticationHeaders(); + + let params = new HttpParams(); + params = params.set('type', HtmlType.WRAPPED); + + return this.http.get( + `${this.API_URL}/learningObject/${hruid}`, + { ...headers, params } + ).pipe( + map((response: LearningObject) => { + if (response && response.metadata.title) { + return response.metadata.title; + } else { + return fallback; + } + }), + catchError(() => { + return of(fallback); + }) + ); + } /** * Give the basic information for a learning path (hruid, language) and receive the full learning objects diff --git a/frontend/src/app/services/questionThread.service.spec.ts b/frontend/src/app/services/questionThread.service.spec.ts index 7d5399f8..f17887ec 100644 --- a/frontend/src/app/services/questionThread.service.spec.ts +++ b/frontend/src/app/services/questionThread.service.spec.ts @@ -1,111 +1,166 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { of, Observable } from 'rxjs'; +import { + VisibilityType, + QuestionThread, + NewQuestionThread, + QuestionThreadUpdate +} from '../interfaces/questionThread'; +import { + QuestionThreadResponse, + QuestionThreadResponseSingle +} from '../interfaces/questionThread/questionThreadResponse'; import { QuestionThreadService } from './questionThread.service'; import { AuthenticationService } from './authentication.service'; import { ErrorService } from './error.service'; import { AssignmentService } from './assignment.service'; -import { of } from 'rxjs'; -import { QuestionThread, NewQuestionThread, VisibilityType } from '../interfaces/questionThread'; -import { QuestionThreadUpdate } from '../interfaces/questionThread/questionThreadUpdate'; -import { QuestionThreadResponse, QuestionThreadResponseSingle } from '../interfaces/questionThread/questionThreadResponse'; +import { MessageService } from './message.service'; +import { ClassesService } from './classes.service'; +import { LearningObjectService } from './learningObject.service'; +import { UserType } from '../interfaces/user'; +import { Assignment } from '../interfaces/assignment'; +import { Class } from '../interfaces/classes/class'; describe('QuestionThreadService', () => { + let service: QuestionThreadService; + let http: jasmine.SpyObj; + let auth: jasmine.SpyObj; + let error: jasmine.SpyObj; + let assignment: jasmine.SpyObj; + let message: jasmine.SpyObj; + let classes: jasmine.SpyObj; + let lo: jasmine.SpyObj; + const questionThread: QuestionThread = { id: 'question-id', creatorId: 'creator-id', assignmentId: 'assignment-id', - learningObjectId: undefined, + learningObjectId: 'object-id', isClosed: false, - visibility: VisibilityType.PUBLIC, + visibility: VisibilityType.GROUP, messageIds: [], }; - const newQuestionThread: NewQuestionThread = { + const newThreadPayload: NewQuestionThread = { creatorId: 'creator-id', assignmentId: 'assignment-id', learningObjectId: 'learning-object-id', isClosed: false, - visibility: VisibilityType.PUBLIC, + visibility: VisibilityType.GROUP, }; - const updatedQuestionThread: QuestionThreadUpdate = { + const updatePayload: QuestionThreadUpdate = { isClosed: true, visibility: VisibilityType.PRIVATE, }; - const questionThreadResponse: QuestionThreadResponse = { + const responseList: QuestionThreadResponse = { threads: [questionThread.id], }; - const questionThreadResponseSingle: QuestionThreadResponseSingle = { + const responseSingle: QuestionThreadResponseSingle = { id: questionThread.id, }; - let http: jasmine.SpyObj; - let authenticationService: jasmine.SpyObj; - let errorService: jasmine.SpyObj; - let assignmentService: jasmine.SpyObj; - let service: QuestionThreadService; - beforeEach(() => { - http = jasmine.createSpyObj('HttpClient', ['get', 'post', 'patch', 'delete']); - errorService = jasmine.createSpyObj('ErrorService', ['pipeHandler', 'retrieveError', 'createError', 'deleteError', 'updateError']); - errorService.pipeHandler.and.callFake(() => (source) => source); - authenticationService = jasmine.createSpyObj('AuthenticationService', ['retrieveUserId', 'retrieveAuthenticationHeaders']); - authenticationService.retrieveUserId.and.returnValue('creator-id'); - authenticationService.retrieveAuthenticationHeaders.and.returnValue({ - headers: new HttpHeaders().append('Authorization', `Bearer token`).append('Content-Type', 'application/json'), - }); - assignmentService = jasmine.createSpyObj('AssignmentService', ['retrieveAssignmentById']); - - service = new QuestionThreadService(http, authenticationService, errorService, assignmentService); - }); + http = jasmine.createSpyObj('HttpClient', ['get', 'post', 'patch', 'delete']); + error = jasmine.createSpyObj('ErrorService', ['pipeHandler', 'retrieveError', 'createError', 'updateError', 'deleteError']); + error.pipeHandler.and.callFake(() => (source: Observable) => source); + auth = jasmine.createSpyObj('AuthenticationService', ['retrieveUserId', 'retrieveAuthenticationHeaders', 'retrieveUserType']); + auth.retrieveUserId.and.returnValue('creator-id'); + auth.retrieveAuthenticationHeaders.and.returnValue({ headers: new HttpHeaders() }); + auth.retrieveUserType.and.returnValue(UserType.TEACHER); + assignment = jasmine.createSpyObj('AssignmentService', ['retrieveAssignments', 'retrieveAssignmentById']); + message = jasmine.createSpyObj('MessageService', ['retrieveMessageById']); + classes = jasmine.createSpyObj('ClassesService', ['classWithId']); + lo = jasmine.createSpyObj('LearningObjectService', ['getTitleOrFallback']); - it('should be created', () => { - expect(service).toBeTruthy(); + service = new QuestionThreadService(http, auth, error, assignment, message, classes, lo); }); - it('should retrieve question threads by assignment', () => { - http.get.and.returnValue(of(questionThreadResponse)); - spyOn(service, 'retrieveQuestionThreadById').and.returnValue(of(questionThread)); - - service.retrieveQuestionThreadsByAssignment('assignment-id').subscribe(result => { - expect(result).toEqual([questionThread]); + it('retrieveQuestionThreadById should GET thread', done => { + http.get.and.returnValue(of(questionThread)); + service.retrieveQuestionThreadById(questionThread.id).subscribe(res => { + expect(res).toEqual(questionThread); + done(); }); - expect(http.get).toHaveBeenCalledWith( - jasmine.stringMatching(/assignments\/assignment-id\/questions/), - jasmine.any(Object) + jasmine.stringMatching(/questions\/question-id$/), + jasmine.any(Object) ); }); - it('should retrieve a single question thread', () => { - http.get.and.returnValue(of(questionThread)); - service.retrieveQuestionThreadById(questionThread.id).subscribe(result => { - expect(result).toEqual(questionThread); + it('retrieveQuestionThreadsByAssignment should return threads list', done => { + http.get.and.returnValue(of(responseList)); + spyOn(service, 'retrieveQuestionThreadById').and.returnValue(of(questionThread)); + service.retrieveQuestionThreadsByAssignment('assignment-id').subscribe(res => { + expect(res).toEqual([questionThread]); + done(); }); }); - it('should create a question thread', () => { - http.post.and.returnValue(of(questionThreadResponseSingle)); - service.createQuestionThread(newQuestionThread).subscribe(result => { - expect(result).toEqual(jasmine.objectContaining({ - ...newQuestionThread, - id: questionThread.id, - })); + it('createQuestionThread should POST and map response', done => { + http.post.and.returnValue(of(responseSingle)); + service.createQuestionThread(newThreadPayload).subscribe(res => { + expect(res).toEqual({ ...newThreadPayload, id: responseSingle.id }); + done(); }); }); - it('should update a question thread', () => { + it('updateQuestionThread should PATCH, emit update, and return payload', done => { + const updateSpy = spyOn(service['threadUpdateSubject'], 'next').and.callThrough(); http.patch.and.returnValue(of({})); - service.updateQuestionThread(questionThread.id, updatedQuestionThread).subscribe(result => { - expect(result).toEqual(updatedQuestionThread); + service.updateQuestionThread('question-id', updatePayload).subscribe(res => { + expect(res).toEqual(updatePayload); + expect(updateSpy).toHaveBeenCalledWith({ id: 'question-id', update: updatePayload }); + done(); }); }); - it('should delete a question thread', () => { + it('deleteQuestionThread should DELETE and return true', done => { http.delete.and.returnValue(of({})); - service.deleteQuestionThread(questionThread.id).subscribe(result => { - expect(result).toBeTrue(); + service.deleteQuestionThread('question-id').subscribe(res => { + expect(res).toBeTrue(); + done(); + }); + }); + + it('retrieveQuestionThreadByStep returns null when no threads', done => { + spyOn(service, 'retrieveQuestionThreadsByAssignment').and.returnValue(of([])); + service.retrieveQuestionThreadByStep('a1', 'l1').subscribe(res => { + expect(res).toBeNull(); + done(); + }); + }); + + it('retrieveQuestionThreadByStep finds matching thread', done => { + const thread: QuestionThread = { ...questionThread, assignmentId: 'a1', learningObjectId: 'l1' }; + spyOn(service, 'retrieveQuestionThreadsByAssignment').and.returnValue(of([thread])); + service.retrieveQuestionThreadByStep('a1', 'l1').subscribe(res => { + expect(res).toEqual(thread); + done(); + }); + }); + + it('filterThreads filters and sorts for teacher without showOtherChats', () => { + const threads: QuestionThread[] = [ + { ...questionThread, id: '1', creatorId: 'u2', lastMessageDate: new Date(1) }, + { ...questionThread, id: '2', creatorId: 'u1', lastMessageDate: new Date(2) } + ]; + const res = service.filterThreads(threads, 'u1', 'a1', false); + expect(res.map(t => t.id)).toEqual(['2', '1']); + }); + + it('getThreadTitle combines class, assignment and LO names', done => { + const thread: QuestionThread = { ...questionThread, assignmentId: 'a1', learningObjectId: 'lo1' }; + const assignmentMock = { classId: 'c1', className: 'CN', name: 'AN' }; + const classMock = { name: 'ClassName' }; + assignment.retrieveAssignmentById.and.returnValue(of(assignmentMock as Assignment)); + classes.classWithId.and.returnValue(of(classMock as Class)); + lo.getTitleOrFallback.and.returnValue(of('LOTitle')); + service.getThreadTitle(thread).subscribe((title: string) => { + expect(title).toBe('ClassName > AN > LOTitle'); + done(); }); }); }); diff --git a/frontend/src/app/services/questionThread.service.ts b/frontend/src/app/services/questionThread.service.ts index 4794c92a..f184c0a5 100644 --- a/frontend/src/app/services/questionThread.service.ts +++ b/frontend/src/app/services/questionThread.service.ts @@ -3,10 +3,13 @@ import { HttpClient } from '@angular/common/http'; import { AuthenticationService } from './authentication.service'; import { ErrorService } from './error.service'; import { AssignmentService } from './assignment.service'; +import { MessageService } from './message.service'; import { environment } from '../../environments/environment'; -import { BehaviorSubject, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs'; +import { BehaviorSubject, catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs'; import { QuestionThread, NewQuestionThread, QuestionThreadUpdate, VisibilityType } from '../interfaces/questionThread'; import { QuestionThreadResponse, QuestionThreadResponseSingle } from '../interfaces/questionThread/questionThreadResponse'; +import { ClassesService } from './classes.service'; +import { LearningObjectService } from './learningObject.service'; @Injectable({ providedIn: 'root' @@ -29,7 +32,10 @@ export class QuestionThreadService { private http: HttpClient, private authService: AuthenticationService, private errorService: ErrorService, - private assignmentService: AssignmentService + private assignmentService: AssignmentService, + private messageService: MessageService, + private classesService: ClassesService, + private learningObjectService: LearningObjectService ) {} private questionThreadMessage = $localize `:@@questionThread:question thread`; @@ -54,6 +60,29 @@ export class QuestionThreadService { ); } + retrieveQuestionThreadByStep( + assignmentId: string, + learningObjectId: string + ): Observable { + const userId = this.authService.retrieveUserId() || ''; + return this.retrieveQuestionThreadsByAssignment(assignmentId).pipe( + switchMap(threads => { + if (!threads || threads.length === 0) { + return of(null); + } + const filteredThreads = threads.filter(thread => + thread.learningObjectId === learningObjectId && thread.creatorId === userId + ); + if (filteredThreads.length === 0) { + return of(null); // No specific thread found for this step/user + } + const foundThread = filteredThreads[0]; + // If a thread object is found but has no ID, it's problematic; treat as not found for ID-based retrieval. + return foundThread.id ? of(foundThread) : of(null); + }) + ); + } + /** * Retrieve all question threads associated with an assignment */ @@ -86,25 +115,34 @@ export class QuestionThreadService { * Load and filter question threads for the authenticated user or current learning object */ loadSideBarQuestionThreads( - userId: string, - currentLearningObjectId: string, - showPublicChats: boolean + currentAssignmentId: string, + showOtherChats: boolean ): Observable { return this.assignmentService.retrieveAssignments().pipe( switchMap(assignments => { - if (!assignments || !Array.isArray(assignments)) { + if (!assignments?.length) { return of([]); } - const threadRequests = assignments.map(a => - this.retrieveQuestionThreadsByAssignment(a.id) + const threadRequests = assignments.map(a => + this.retrieveQuestionThreadsByAssignment(a.id).pipe( + switchMap(threads => { + if (!threads.length) return of([]); + + const threadWithNames$ = threads.map(thread => + this.getThreadNameWithTimestamp(thread, a.name).pipe( + map(name => ({ ...thread, name })) + )); + return forkJoin(threadWithNames$); + }) + ) ); return forkJoin(threadRequests); }), map(threadArrays => threadArrays.flat()), map(allThreads => { const userId = this.authService.retrieveUserId() || ''; - return this.filterThreads(allThreads, userId, currentLearningObjectId, showPublicChats); + return this.filterThreads(allThreads, userId, currentAssignmentId, showOtherChats); }), this.errorService.pipeHandler( this.errorService.retrieveError($localize`loading user threads`) @@ -118,29 +156,88 @@ export class QuestionThreadService { filterThreads( allThreads: QuestionThread[], userId: string, - currentLearningObjectId: string, - showPublicChats: boolean + currentAssignmentId: string, + showOtherChats: boolean ): QuestionThread[] { - if (showPublicChats) { - return allThreads - .filter(t => - t.learningObjectId === currentLearningObjectId && - (t.visibility === VisibilityType.GROUP || t.visibility === VisibilityType.PUBLIC) - ) - .sort((a, b) => { - if (a.visibility === VisibilityType.GROUP && b.visibility !== VisibilityType.GROUP) return -1; - if (b.visibility === VisibilityType.GROUP && a.visibility !== VisibilityType.GROUP) return 1; - return 0; - }); + const sortByLatestMessage = (a: QuestionThread, b: QuestionThread) => { + const dateA = new Date(a.lastMessageDate || 0).getTime(); + const dateB = new Date(b.lastMessageDate || 0).getTime(); + return dateB - dateA; // descending order + }; + + if (showOtherChats) { + if (this.authService.retrieveUserType() === 'student') { + return allThreads + .filter(t => + // t.learningObjectId === currentLearningObjectId && + (t.visibility === VisibilityType.GROUP) + ) + .sort(sortByLatestMessage); + } else { + return allThreads + .filter(t => + t.assignmentId === currentAssignmentId + ) + .sort(sortByLatestMessage); + } } else { if (this.authService.retrieveUserType() === 'student') { - return allThreads.filter(t => t.creatorId === userId); + return allThreads + .filter(t => t.creatorId === userId) + .sort(sortByLatestMessage); } else { return allThreads + .sort(sortByLatestMessage); } } } + getThreadTitle(thread: QuestionThread): Observable { + return this.assignmentService.retrieveAssignmentById(thread.assignmentId).pipe( + switchMap(assignment => { + if (!assignment) return of('The spiders are back.'); // this should never happen + + return this.classesService.classWithId(assignment.classId).pipe( + map(ci => ci?.name ?? assignment.className ?? '???'), + catchError(() => of(assignment.className ?? '???')), + switchMap(className => + this.learningObjectService + .getTitleOrFallback(thread.learningObjectId, thread.learningObjectId) + .pipe( + map(lot => `${className} > ${assignment.name} > ${lot}`), + ) + ) + ); + }) + ); + } + + private getThreadNameWithTimestamp(thread: QuestionThread, assignmentName: string): Observable { + // If no messages, just return basic name + if (!thread.messageIds || thread.messageIds.length === 0) { + return of(`${assignmentName} - ${thread.id}`); + } + + // Retrieve all messages and find the latest one + return forkJoin(thread.messageIds.map(id => this.messageService.retrieveMessageById(id))).pipe( + map(messages => { + const latestMessage = messages.reduce((latest, current) => { + const latestDate = new Date(latest.createdAt || 0); + const currentDate = new Date(current.createdAt || 0); + return currentDate > latestDate ? current : latest; + }); + thread.lastMessageDate = latestMessage.createdAt; + const dateStr = latestMessage.createdAt ? + new Date(latestMessage.createdAt).toLocaleDateString() : + ''; + return `${dateStr} - ${assignmentName} - ${thread.id}`; + }), + this.errorService.pipeHandler( + this.errorService.retrieveError(this.questionMessage) + ) + ); +} + /** * Create a new question thread */