diff --git a/src/app/classroom-monitor/teams-on-node/teams-on-node.component.html b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.html new file mode 100644 index 00000000000..75a187a7991 --- /dev/null +++ b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.html @@ -0,0 +1,13 @@ +@if (workgroupsOnNode.length > 0) { + + + person + {{ workgroupsOnNode.length }} + + +} diff --git a/src/app/classroom-monitor/teams-on-node/teams-on-node.component.scss b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.scss new file mode 100644 index 00000000000..320cc8e058f --- /dev/null +++ b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.scss @@ -0,0 +1,3 @@ +.mdc-tooltip--multiline .mat-mdc-tooltip-surface { + white-space: break-spaces; +} \ No newline at end of file diff --git a/src/app/classroom-monitor/teams-on-node/teams-on-node.component.spec.ts b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.spec.ts new file mode 100644 index 00000000000..0bb0e7d8ace --- /dev/null +++ b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.spec.ts @@ -0,0 +1,308 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TeamsOnNodeComponent } from './teams-on-node.component'; +import { ClassroomStatusService } from '../../../assets/wise5/services/classroomStatusService'; +import { ConfigService } from '../../../assets/wise5/services/configService'; +import { ProjectService } from '../../../assets/wise5/services/projectService'; +import { Subject } from 'rxjs'; + +describe('TeamsOnNodeComponent', () => { + let component: TeamsOnNodeComponent; + let fixture: ComponentFixture; + let classroomStatusService: jasmine.SpyObj; + let configService: jasmine.SpyObj; + let projectService: jasmine.SpyObj; + let studentStatusReceivedSubject: Subject; + + const nodeId = 'node1'; + const stepNodeId = 'node2'; + const lessonNodeId = 'group1'; + const periodId = 1; + const periodName = 'Period 1'; + + const createMockWorkgroup = (workgroupId: number) => ({ + workgroupId, + periodId + }); + + beforeEach(async () => { + studentStatusReceivedSubject = new Subject(); + const classroomStatusServiceSpy = jasmine.createSpyObj('ClassroomStatusService', [ + 'getWorkgroupsOnNode' + ]); + classroomStatusServiceSpy.studentStatusReceived$ = studentStatusReceivedSubject.asObservable(); + const configServiceSpy = jasmine.createSpyObj('ConfigService', [ + 'getPermissions', + 'getDisplayUsernamesByWorkgroupId' + ]); + const projectServiceSpy = jasmine.createSpyObj('ProjectService', ['isApplicationNode']); + + await TestBed.configureTestingModule({ + imports: [TeamsOnNodeComponent], + providers: [ + { provide: ClassroomStatusService, useValue: classroomStatusServiceSpy }, + { provide: ConfigService, useValue: configServiceSpy }, + { provide: ProjectService, useValue: projectServiceSpy } + ] + }).compileComponents(); + + classroomStatusService = TestBed.inject( + ClassroomStatusService + ) as jasmine.SpyObj; + configService = TestBed.inject(ConfigService) as jasmine.SpyObj; + projectService = TestBed.inject(ProjectService) as jasmine.SpyObj; + + fixture = TestBed.createComponent(TeamsOnNodeComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should subscribe to studentStatusReceived$ and call ngOnChanges', () => { + component.nodeId = nodeId; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue([]); + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + fixture.detectChanges(); + + spyOn(component, 'ngOnChanges'); + studentStatusReceivedSubject.next(); + + expect(component.ngOnChanges).toHaveBeenCalled(); + }); + }); + + describe('ngOnDestroy', () => { + it('should unsubscribe from subscriptions', () => { + component.nodeId = nodeId; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue([]); + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + fixture.detectChanges(); + + spyOn(component['subscriptions'], 'unsubscribe'); + + component.ngOnDestroy(); + + expect(component['subscriptions'].unsubscribe).toHaveBeenCalled(); + }); + }); + + describe('ngOnChanges', () => { + describe('workgroups on node', () => { + it('should get workgroups on node for the specified period', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2)]; + component.nodeId = nodeId; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + component.ngOnChanges(); + + expect(classroomStatusService.getWorkgroupsOnNode).toHaveBeenCalledWith(nodeId, periodId); + expect(component['workgroupsOnNode']).toEqual(workgroups); + }); + }); + + describe('tooltip text for step', () => { + beforeEach(() => { + component.nodeId = stepNodeId; + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + }); + + it('should create tooltip text for single team on step in specific period', () => { + const workgroups = [createMockWorkgroup(1)]; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('1 team on this step (Period: Period 1)'); + }); + + it('should create tooltip text for multiple teams on step in specific period', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2), createMockWorkgroup(3)]; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('3 teams on this step (Period: Period 1)'); + }); + + it('should create tooltip text for single team on step in all periods', () => { + const workgroups = [createMockWorkgroup(1)]; + component.period = { periodId: -1, periodName: 'All Periods' }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('1 team on this step (All periods)'); + }); + + it('should create tooltip text for multiple teams on step in all periods', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2)]; + component.period = { periodId: -1, periodName: 'All Periods' }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('2 teams on this step (All periods)'); + }); + }); + + describe('tooltip text for lesson', () => { + beforeEach(() => { + component.nodeId = lessonNodeId; + projectService.isApplicationNode.and.returnValue(false); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + }); + + it('should create tooltip text for single team on lesson in specific period', () => { + const workgroups = [createMockWorkgroup(1)]; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('1 team on this lesson (Period: Period 1)'); + }); + + it('should create tooltip text for multiple teams on lesson in specific period', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2)]; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('2 teams on this lesson (Period: Period 1)'); + }); + + it('should create tooltip text for single team on lesson in all periods', () => { + const workgroups = [createMockWorkgroup(1)]; + component.period = { periodId: -1, periodName: 'All Periods' }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('1 team on this lesson (All periods)'); + }); + + it('should create tooltip text for multiple teams on lesson in all periods', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2), createMockWorkgroup(3)]; + component.period = { periodId: -1, periodName: 'All Periods' }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('3 teams on this lesson (All periods)'); + }); + }); + + describe('tooltip text with student names', () => { + beforeEach(() => { + component.nodeId = stepNodeId; + component.period = { periodId, periodName }; + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: true } as any); + }); + + it('should append student names to tooltip when permission is granted', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2)]; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + configService.getDisplayUsernamesByWorkgroupId.and.callFake((workgroupId: number) => { + if (workgroupId === 1) return 'Alice, Bob'; + if (workgroupId === 2) return 'Charlie, David'; + return ''; + }); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe( + '2 teams on this step (Period: Period 1)\nAlice, Bob\nCharlie, David\n' + ); + }); + + it('should not append student names when permission is not granted', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2)]; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + component.ngOnChanges(); + + expect(component['tooltipText']).toBe('2 teams on this step (Period: Period 1)'); + expect(configService.getDisplayUsernamesByWorkgroupId).not.toHaveBeenCalled(); + }); + }); + }); + + describe('template rendering', () => { + it('should display workgroup count when there are workgroups on node', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2), createMockWorkgroup(3)]; + component.nodeId = nodeId; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + component.ngOnChanges(); + fixture.detectChanges(); + + const countElement = fixture.nativeElement.querySelector('.progress-wrapper'); + expect(countElement).toBeTruthy(); + expect(countElement.textContent.trim()).toContain('3'); + }); + + it('should not display anything when there are no workgroups on node', () => { + component.nodeId = nodeId; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue([]); + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + component.ngOnChanges(); + fixture.detectChanges(); + + const countElement = fixture.nativeElement.querySelector('.progress-wrapper'); + expect(countElement).toBeFalsy(); + }); + + it('should display mat-icon with person icon', () => { + const workgroups = [createMockWorkgroup(1)]; + component.nodeId = nodeId; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + component.ngOnChanges(); + fixture.detectChanges(); + + const iconElement = fixture.nativeElement.querySelector('mat-icon'); + expect(iconElement).toBeTruthy(); + expect(iconElement.textContent.trim()).toBe('person'); + }); + + it('should set tooltip text for mat-icon', () => { + const workgroups = [createMockWorkgroup(1), createMockWorkgroup(2)]; + component.nodeId = stepNodeId; + component.period = { periodId, periodName }; + classroomStatusService.getWorkgroupsOnNode.and.returnValue(workgroups); + projectService.isApplicationNode.and.returnValue(true); + configService.getPermissions.and.returnValue({ canViewStudentNames: false } as any); + + component.ngOnChanges(); + fixture.detectChanges(); + + expect(component['tooltipText']).toBe('2 teams on this step (Period: Period 1)'); + }); + }); +}); diff --git a/src/app/classroom-monitor/teams-on-node/teams-on-node.component.ts b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.ts new file mode 100644 index 00000000000..1cc9e9b3376 --- /dev/null +++ b/src/app/classroom-monitor/teams-on-node/teams-on-node.component.ts @@ -0,0 +1,64 @@ +import { Component, inject, Input, ViewEncapsulation } from '@angular/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ClassroomStatusService } from '../../../assets/wise5/services/classroomStatusService'; +import { MatIconModule } from '@angular/material/icon'; +import { ProjectService } from '../../../assets/wise5/services/projectService'; +import { ConfigService } from '../../../assets/wise5/services/configService'; +import { Subscription } from 'rxjs'; + +@Component({ + encapsulation: ViewEncapsulation.None, + imports: [MatIconModule, MatTooltipModule], + selector: 'teams-on-node', + styleUrl: './teams-on-node.component.scss', + templateUrl: './teams-on-node.component.html' +}) +export class TeamsOnNodeComponent { + private classroomStatusService = inject(ClassroomStatusService); + private configService = inject(ConfigService); + private projectService = inject(ProjectService); + + @Input() nodeId: string; + @Input() period: any; + private subscriptions: Subscription = new Subscription(); + protected tooltipText: string; + protected workgroupsOnNode: any[] = []; + + ngOnInit(): void { + this.subscriptions.add( + this.classroomStatusService.studentStatusReceived$.subscribe(() => { + this.ngOnChanges(); + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + ngOnChanges(): void { + this.workgroupsOnNode = this.classroomStatusService.getWorkgroupsOnNode( + this.nodeId, + this.period.periodId + ); + const teams = this.workgroupsOnNode.length === 1 ? $localize`team` : $localize`teams`; + const stepOrLesson = this.projectService.isApplicationNode(this.nodeId) + ? $localize`step` + : $localize`lesson`; + if (this.period.periodId === -1) { + this.tooltipText = $localize`${this.workgroupsOnNode.length} ${teams} on this ${stepOrLesson} (All periods)`; + } else { + this.tooltipText = $localize`${this.workgroupsOnNode.length} ${teams} on this ${stepOrLesson} (Period: ${this.period.periodName})`; + } + if (this.configService.getPermissions().canViewStudentNames) { + this.tooltipText += + `\n` + + this.workgroupsOnNode + .map( + (workgroup) => + `${this.configService.getDisplayUsernamesByWorkgroupId(workgroup.workgroupId)}\n` + ) + .join(''); + } + } +} diff --git a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/nodeProgress/nav-item/nav-item.component.html b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/nodeProgress/nav-item/nav-item.component.html index 0852c1b87e6..8c7ffcfbf18 100644 --- a/src/assets/wise5/classroomMonitor/classroomMonitorComponents/nodeProgress/nav-item/nav-item.component.html +++ b/src/assets/wise5/classroomMonitor/classroomMonitorComponents/nodeProgress/nav-item/nav-item.component.html @@ -46,6 +46,7 @@ +