diff --git a/src/app/app.module.ts b/src/app/app.module.ts index de746534798..347fd654226 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -23,6 +23,7 @@ import { AnnouncementComponent } from './announcement/announcement.component'; import { AnnouncementDialogComponent } from './announcement/announcement.component'; import { TrackScrollDirective } from './track-scroll.directive'; import { RecaptchaV3Module, RECAPTCHA_V3_SITE_KEY, RECAPTCHA_BASE_URL } from 'ng-recaptcha'; +import { ArchiveProjectService } from './services/archive-project.service'; export function initialize( configService: ConfigService, @@ -59,11 +60,12 @@ export function initialize( MatDialogModule, RecaptchaV3Module, RouterModule.forRoot([], { - scrollPositionRestoration: 'enabled', - anchorScrolling: 'enabled' -}) + scrollPositionRestoration: 'enabled', + anchorScrolling: 'enabled' + }) ], providers: [ + ArchiveProjectService, ConfigService, StudentService, TeacherService, diff --git a/src/app/common/harness-helper.ts b/src/app/common/harness-helper.ts new file mode 100644 index 00000000000..59a97460be1 --- /dev/null +++ b/src/app/common/harness-helper.ts @@ -0,0 +1,8 @@ +import { MatMenuHarness } from '@angular/material/menu/testing'; + +export async function clickMenuButton(thisContext: any, menuButtonText: string): Promise { + const getMenu = thisContext.locatorFor(MatMenuHarness); + const menu = await getMenu(); + await menu.open(); + return menu.clickItem({ text: menuButtonText }); +} diff --git a/src/app/domain/archiveProjectResponse.ts b/src/app/domain/archiveProjectResponse.ts new file mode 100644 index 00000000000..fb33f0a6ce7 --- /dev/null +++ b/src/app/domain/archiveProjectResponse.ts @@ -0,0 +1,4 @@ +export class ArchiveProjectResponse { + archived: boolean; + id: number; +} diff --git a/src/app/domain/project.ts b/src/app/domain/project.ts index 6558c9ad945..d25e4cc063c 100644 --- a/src/app/domain/project.ts +++ b/src/app/domain/project.ts @@ -2,22 +2,24 @@ import { Run } from './run'; import { User } from '../domain/user'; export class Project { - id: number; - name: string; - metadata: any; - dateCreated: string; + archived: boolean; dateArchived: string; - lastEdited: string; - projectThumb: string; - thumbStyle: any; + dateCreated: string; + id: number; isHighlighted: boolean; + lastEdited: string; + license: String; + metadata: any; + name: string; owner: User; - sharedOwners: User[] = []; - run: Run; parentId: number; - wiseVersion: number; + projectThumb: string; + run: Run; + sharedOwners: User[] = []; + tags: string[]; + thumbStyle: any; uri: String; - license: String; + wiseVersion: number; static readonly VIEW_PERMISSION: number = 1; static readonly EDIT_PERMISSION: number = 2; diff --git a/src/app/services/archive-project.service.ts b/src/app/services/archive-project.service.ts new file mode 100644 index 00000000000..fc559f1b5e3 --- /dev/null +++ b/src/app/services/archive-project.service.ts @@ -0,0 +1,40 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { Project } from '../domain/project'; +import { ArchiveProjectResponse } from '../domain/archiveProjectResponse'; + +@Injectable() +export class ArchiveProjectService { + private refreshProjectsEventSource: Subject = new Subject(); + public refreshProjectsEvent$ = this.refreshProjectsEventSource.asObservable(); + + constructor(private http: HttpClient) {} + + archiveProject(project: Project): Observable { + return this.http.put(`/api/project/${project.id}/archived`, null); + } + + archiveProjects(projects: Project[]): Observable { + const projectIds = projects.map((project) => project.id); + return this.http.put(`/api/projects/archived`, projectIds); + } + + unarchiveProject(project: Project): Observable { + return this.http.delete(`/api/project/${project.id}/archived`); + } + + unarchiveProjects(projects: Project[]): Observable { + let params = new HttpParams(); + for (const project of projects) { + params = params.append('projectIds', project.id); + } + return this.http.delete(`/api/projects/archived`, { + params: params + }); + } + + refreshProjects(): void { + this.refreshProjectsEventSource.next(); + } +} diff --git a/src/app/services/mock-archive-project.service.ts b/src/app/services/mock-archive-project.service.ts new file mode 100644 index 00000000000..fbe64286a64 --- /dev/null +++ b/src/app/services/mock-archive-project.service.ts @@ -0,0 +1,44 @@ +import { Observable, Subject, of } from 'rxjs'; +import { Project } from '../domain/project'; +import { ArchiveProjectResponse } from '../domain/archiveProjectResponse'; + +export class MockArchiveProjectService { + private refreshProjectsEventSource: Subject = new Subject(); + public refreshProjectsEvent$ = this.refreshProjectsEventSource.asObservable(); + + archiveProject(project: Project): Observable { + return this.archiveProjectHelper(project, true); + } + + unarchiveProject(project: Project): Observable { + return this.archiveProjectHelper(project, false); + } + + private archiveProjectHelper( + project: Project, + archived: boolean + ): Observable { + project.archived = archived; + return of(project); + } + + archiveProjects(projects: Project[]): Observable { + return this.archiveProjectsHelper(projects, true); + } + + unarchiveProjects(projects: Project[]): Observable { + return this.archiveProjectsHelper(projects, false); + } + + private archiveProjectsHelper( + projects: Project[], + archived: boolean + ): Observable { + projects.forEach((project) => (project.archived = archived)); + return of(projects); + } + + refreshProjects(): void { + this.refreshProjectsEventSource.next(); + } +} diff --git a/src/app/student/student-run-list/student-run-list.component.ts b/src/app/student/student-run-list/student-run-list.component.ts index c251c2f4d19..421db1a3062 100644 --- a/src/app/student/student-run-list/student-run-list.component.ts +++ b/src/app/student/student-run-list/student-run-list.component.ts @@ -5,7 +5,7 @@ import { ConfigService } from '../../services/config.service'; import { ActivatedRoute } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { AddProjectDialogComponent } from '../add-project-dialog/add-project-dialog.component'; -import { formatDate } from '@angular/common'; +import { runSpansDays } from '../../../assets/wise5/common/datetime/datetime'; @Component({ selector: 'app-student-run-list', @@ -83,9 +83,7 @@ export class StudentRunListComponent implements OnInit { } runSpansDays(run: StudentRun) { - const startDay = formatDate(run.startTime, 'shortDate', this.localeID); - const endDay = formatDate(run.endTime, 'shortDate', this.localeID); - return startDay != endDay; + return runSpansDays(run, this.localeID); } activeTotal(): number { diff --git a/src/app/teacher/run-menu/run-menu.component.html b/src/app/teacher/run-menu/run-menu.component.html index 95c17ba96e4..4ae5f012639 100644 --- a/src/app/teacher/run-menu/run-menu.component.html +++ b/src/app/teacher/run-menu/run-menu.component.html @@ -29,5 +29,13 @@ report_problem Report Problem + + archive + Archive + + + unarchive + Restore + diff --git a/src/app/teacher/run-menu/run-menu.component.spec.ts b/src/app/teacher/run-menu/run-menu.component.spec.ts index 388c47d57c2..90e4a728c26 100644 --- a/src/app/teacher/run-menu/run-menu.component.spec.ts +++ b/src/app/teacher/run-menu/run-menu.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { RunMenuComponent } from './run-menu.component'; import { TeacherService } from '../teacher.service'; -import { Project } from '../../domain/project'; import { BehaviorSubject, Observable } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; @@ -12,6 +11,17 @@ import { TeacherRun } from '../teacher-run'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { Course } from '../../domain/course'; import { RouterTestingModule } from '@angular/router/testing'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ArchiveProjectService } from '../../services/archive-project.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RunMenuHarness } from './run-menu.harness'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MockArchiveProjectService } from '../../services/mock-archive-project.service'; +import { MatSnackBarHarness } from '@angular/material/snack-bar/testing'; +import { HarnessLoader } from '@angular/cdk/testing'; export class MockTeacherService { checkClassroomAuthorization(): Observable { @@ -56,44 +66,117 @@ export class MockConfigService { } } -describe('RunMenuComponent', () => { - let component: RunMenuComponent; - let fixture: ComponentFixture; +let archiveProjectService: ArchiveProjectService; +let component: RunMenuComponent; +let fixture: ComponentFixture; +const owner = new User(); +let rootLoader: HarnessLoader; +let runMenuHarness: RunMenuHarness; +let teacherService: TeacherService; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [MatMenuModule, RouterTestingModule], - declarations: [RunMenuComponent], - providers: [ - { provide: TeacherService, useClass: MockTeacherService }, - { provide: UserService, useClass: MockUserService }, - { provide: ConfigService, useClass: MockConfigService }, - { provide: MatDialog, useValue: {} } - ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); - })); +describe('RunMenuComponent', () => { + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + HttpClientTestingModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatSnackBarModule, + RouterTestingModule + ], + declarations: [RunMenuComponent], + providers: [ + { provide: ArchiveProjectService, useClass: MockArchiveProjectService }, + { provide: TeacherService, useClass: MockTeacherService }, + { provide: UserService, useClass: MockUserService }, + { provide: ConfigService, useClass: MockConfigService }, + { provide: MatDialog, useValue: {} } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }) + ); - beforeEach(() => { + beforeEach(async () => { fixture = TestBed.createComponent(RunMenuComponent); component = fixture.componentInstance; - const run: TeacherRun = new TeacherRun(); - run.id = 1; - run.name = 'Photosynthesis'; - const owner = new User(); - owner.id = 1; - run.owner = owner; - const project = new Project(); - project.id = 1; - project.owner = owner; - project.sharedOwners = []; - run.project = project; - run.sharedOwners = []; - component.run = run; + setRun(false); + archiveProjectService = TestBed.inject(ArchiveProjectService); + teacherService = TestBed.inject(TeacherService); fixture.detectChanges(); + runMenuHarness = await TestbedHarnessEnvironment.harnessForFixture(fixture, RunMenuHarness); + rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + archive(); + unarchive(); }); + +function setRun(archived: boolean): void { + component.run = new TeacherRun({ + id: 1, + name: 'Photosynthesis', + owner: owner, + project: { + id: 1, + owner: owner, + sharedOwners: [] + }, + archived: archived + }); +} + +function archive() { + describe('archive()', () => { + it('should archive a run', async () => { + await runMenuHarness.clickArchiveMenuButton(); + expect(component.run.archived).toEqual(true); + const snackBar = await getSnackBar(); + expect(await snackBar.getMessage()).toEqual('Successfully archived unit.'); + }); + it('should archive a run and then undo', async () => { + await runMenuHarness.clickArchiveMenuButton(); + expect(component.run.archived).toEqual(true); + let snackBar = await getSnackBar(); + expect(await snackBar.getMessage()).toEqual('Successfully archived unit.'); + expect(await snackBar.getActionDescription()).toEqual('Undo'); + await snackBar.dismissWithAction(); + expect(component.run.archived).toEqual(false); + snackBar = await getSnackBar(); + expect(await snackBar.getMessage()).toEqual('Action undone.'); + }); + }); +} + +function unarchive() { + describe('unarchive()', () => { + it('should unarchive a run', async () => { + setRun(true); + component.ngOnInit(); + await runMenuHarness.clickUnarchiveMenuButton(); + expect(component.run.archived).toEqual(false); + const snackBar = await getSnackBar(); + expect(await snackBar.getMessage()).toEqual('Successfully restored unit.'); + }); + it('should unarchive a run and then undo', async () => { + setRun(true); + component.ngOnInit(); + await runMenuHarness.clickUnarchiveMenuButton(); + expect(component.run.archived).toEqual(false); + let snackBar = await getSnackBar(); + expect(await snackBar.getMessage()).toEqual('Successfully restored unit.'); + expect(await snackBar.getActionDescription()).toEqual('Undo'); + await snackBar.dismissWithAction(); + expect(component.run.archived).toEqual(true); + snackBar = await getSnackBar(); + expect(await snackBar.getMessage()).toEqual('Action undone.'); + }); + }); +} + +async function getSnackBar() { + return await rootLoader.getHarness(MatSnackBarHarness); +} diff --git a/src/app/teacher/run-menu/run-menu.component.ts b/src/app/teacher/run-menu/run-menu.component.ts index be4698a3928..1ac69fe7886 100644 --- a/src/app/teacher/run-menu/run-menu.component.ts +++ b/src/app/teacher/run-menu/run-menu.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ShareRunDialogComponent } from '../share-run-dialog/share-run-dialog.component'; import { LibraryProjectDetailsComponent } from '../../modules/library/library-project-details/library-project-details.component'; @@ -8,6 +8,9 @@ import { ConfigService } from '../../services/config.service'; import { RunSettingsDialogComponent } from '../run-settings-dialog/run-settings-dialog.component'; import { EditRunWarningDialogComponent } from '../edit-run-warning-dialog/edit-run-warning-dialog.component'; import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ArchiveProjectService } from '../../services/archive-project.service'; +import { ArchiveProjectResponse } from '../../domain/archiveProjectResponse'; @Component({ selector: 'app-run-menu', @@ -15,16 +18,18 @@ import { Router } from '@angular/router'; styleUrls: ['./run-menu.component.scss'] }) export class RunMenuComponent implements OnInit { + private editLink: string = ''; + protected reportProblemLink: string = ''; @Input() run: TeacherRun; - - editLink: string = ''; - reportProblemLink: string = ''; + @Output() runArchiveStatusChangedEvent: EventEmitter = new EventEmitter(); constructor( + private archiveProjectService: ArchiveProjectService, private dialog: MatDialog, private userService: UserService, private configService: ConfigService, - private router: Router + private router: Router, + private snackBar: MatSnackBar ) {} ngOnInit() { @@ -34,14 +39,14 @@ export class RunMenuComponent implements OnInit { this.reportProblemLink = `${this.configService.getContextPath()}/contact?runId=${this.run.id}`; } - shareRun() { + protected shareRun() { this.dialog.open(ShareRunDialogComponent, { data: { run: this.run }, panelClass: 'dialog-md' }); } - showUnitDetails() { + protected showUnitDetails() { const project = this.run.project; this.dialog.open(LibraryProjectDetailsComponent, { data: { project: project, isRunProject: true }, @@ -49,23 +54,15 @@ export class RunMenuComponent implements OnInit { }); } - canEdit() { + protected canEdit() { return this.run.project.canEdit(this.userService.getUserId()); } - canShare() { + protected canShare() { return this.run.canGradeAndManage(this.userService.getUserId()); } - isOwner() { - return this.run.isOwner(this.userService.getUserId()); - } - - isRunCompleted() { - return this.run.isCompleted(this.configService.getCurrentServerTime()); - } - - showEditRunDetails() { + protected showEditRunDetails() { const run = this.run; this.dialog.open(RunSettingsDialogComponent, { ariaLabel: $localize`Run Settings`, @@ -75,7 +72,7 @@ export class RunMenuComponent implements OnInit { }); } - editContent() { + protected editContent() { if (this.run.lastRun) { this.dialog.open(EditRunWarningDialogComponent, { ariaLabel: $localize`Edit Classroom Unit Warning`, @@ -86,4 +83,57 @@ export class RunMenuComponent implements OnInit { this.router.navigateByUrl(this.editLink); } } + + protected archive(archive: boolean): void { + this.archiveProjectService[archive ? 'archiveProject' : 'unarchiveProject']( + this.run.project + ).subscribe({ + next: (response: ArchiveProjectResponse) => { + this.updateArchivedStatus(this.run, response.archived); + this.showSuccessMessage(this.run, archive); + }, + error: () => { + this.showErrorMessage(archive); + } + }); + } + + private showSuccessMessage(run: TeacherRun, archive: boolean): void { + this.openSnackBar( + run, + $localize`Successfully ${archive ? 'archived' : 'restored'} unit.`, + archive ? 'unarchiveProject' : 'archiveProject' + ); + } + + private showErrorMessage(archive: boolean): void { + this.snackBar.open($localize`Error ${archive ? 'archiving' : 'unarchiving'} unit.`); + } + + private updateArchivedStatus(run: TeacherRun, archived: boolean): void { + run.archived = archived; + this.runArchiveStatusChangedEvent.emit(); + } + + private openSnackBar(run: TeacherRun, message: string, undoFunctionName: string): void { + this.snackBar + .open(message, $localize`Undo`) + .onAction() + .subscribe(() => { + this.undoArchiveAction(run, undoFunctionName); + }); + } + + private undoArchiveAction(run: TeacherRun, archiveFunctionName: string): void { + this.archiveProjectService[archiveFunctionName](run.project).subscribe({ + next: (response: ArchiveProjectResponse) => { + run.archived = response.archived; + this.archiveProjectService.refreshProjects(); + this.snackBar.open($localize`Action undone.`); + }, + error: () => { + this.snackBar.open($localize`Error undoing action.`); + } + }); + } } diff --git a/src/app/teacher/run-menu/run-menu.harness.ts b/src/app/teacher/run-menu/run-menu.harness.ts new file mode 100644 index 00000000000..dd30bb97bda --- /dev/null +++ b/src/app/teacher/run-menu/run-menu.harness.ts @@ -0,0 +1,30 @@ +import { ComponentHarness } from '@angular/cdk/testing'; +import { clickMenuButton } from '../../common/harness-helper'; +import { MatMenuHarness } from '@angular/material/menu/testing'; + +export class RunMenuHarness extends ComponentHarness { + static hostSelector = 'app-run-menu'; + private ARCHIVE_MENU_BUTTON_TEXT = 'archiveArchive'; + private UNARCHIVE_MENU_BUTTON_TEXT = 'unarchiveRestore'; + + async clickArchiveMenuButton(): Promise { + return await clickMenuButton(this, this.ARCHIVE_MENU_BUTTON_TEXT); + } + + async clickUnarchiveMenuButton(): Promise { + return await clickMenuButton(this, this.UNARCHIVE_MENU_BUTTON_TEXT); + } + + async hasRestoreMenuButton(): Promise { + const getMenu = this.locatorFor(MatMenuHarness); + const menu = await getMenu(); + await menu.open(); + let foundRestoreMenuButton = false; + for (const item of await menu.getItems()) { + if ((await item.getText()) === this.UNARCHIVE_MENU_BUTTON_TEXT) { + foundRestoreMenuButton = true; + } + } + return foundRestoreMenuButton; + } +} diff --git a/src/app/teacher/select-runs-controls/select-runs-controls.component.html b/src/app/teacher/select-runs-controls/select-runs-controls.component.html new file mode 100644 index 00000000000..7409531d769 --- /dev/null +++ b/src/app/teacher/select-runs-controls/select-runs-controls.component.html @@ -0,0 +1,53 @@ +
+ +
+ + + + + + + + +
+
{{ numSelectedRuns }} selected
+ + +
diff --git a/src/app/teacher/select-runs-controls/select-runs-controls.component.scss b/src/app/teacher/select-runs-controls/select-runs-controls.component.scss new file mode 100644 index 00000000000..91b72c5ce44 --- /dev/null +++ b/src/app/teacher/select-runs-controls/select-runs-controls.component.scss @@ -0,0 +1,3 @@ +.select-all-drop-down { + margin: 0 -8px; +} diff --git a/src/app/teacher/select-runs-controls/select-runs-controls.component.spec.ts b/src/app/teacher/select-runs-controls/select-runs-controls.component.spec.ts new file mode 100644 index 00000000000..3c6e1f91411 --- /dev/null +++ b/src/app/teacher/select-runs-controls/select-runs-controls.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectRunsControlsComponent } from './select-runs-controls.component'; +import { SelectRunsControlsModule } from './select-runs-controls.module'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { ArchiveProjectService } from '../../services/archive-project.service'; +import { MockArchiveProjectService } from '../../services/mock-archive-project.service'; + +describe('SelectRunsControlsComponent', () => { + let component: SelectRunsControlsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatSnackBarModule, SelectRunsControlsModule], + providers: [{ provide: ArchiveProjectService, useClass: MockArchiveProjectService }] + }).compileComponents(); + + fixture = TestBed.createComponent(SelectRunsControlsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/teacher/select-runs-controls/select-runs-controls.component.ts b/src/app/teacher/select-runs-controls/select-runs-controls.component.ts new file mode 100644 index 00000000000..5c722a29b54 --- /dev/null +++ b/src/app/teacher/select-runs-controls/select-runs-controls.component.ts @@ -0,0 +1,127 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox'; +import { TeacherRun } from '../teacher-run'; +import { ArchiveProjectResponse } from '../../domain/archiveProjectResponse'; +import { Subscription } from 'rxjs'; +import { Project } from '../../domain/project'; +import { ArchiveProjectService } from '../../services/archive-project.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SelectRunsOption } from './select-runs-option'; + +@Component({ + selector: 'select-runs-controls', + templateUrl: './select-runs-controls.component.html', + styleUrls: ['./select-runs-controls.component.scss'], + providers: [{ provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: 'noop' } }] +}) +export class SelectRunsControlsComponent { + @Output() archiveActionEvent = new EventEmitter(); + protected numSelectedRuns: number = 0; + @Input() runChangedEventEmitter: EventEmitter = new EventEmitter(); + @Input() runs: TeacherRun[] = []; + protected selectedAllRuns: boolean = false; + protected selectedSomeRuns: boolean = false; + @Output() selectRunsOptionChosenEvent = new EventEmitter(); + @Input() showArchived: boolean = false; + + constructor( + private archiveProjectService: ArchiveProjectService, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + this.runChangedEventEmitter.subscribe(() => { + this.ngOnChanges(); + }); + } + + ngOnChanges(): void { + this.numSelectedRuns = this.runs.filter((run: TeacherRun) => run.selected).length; + this.selectedAllRuns = this.numSelectedRuns === this.runs.length; + this.selectedSomeRuns = this.numSelectedRuns !== 0 && !this.selectedAllRuns; + } + + protected selectAllRunsCheckboxClicked(): void { + this.selectRunsOptionChosenEvent.emit( + this.selectedAllRuns || this.selectedSomeRuns ? SelectRunsOption.None : SelectRunsOption.All + ); + } + + protected selectRunsOptionChosen(value: string): void { + this.selectRunsOptionChosenEvent.emit(value as SelectRunsOption); + } + + protected archiveSelectedRuns(archive: boolean): Subscription { + const runs = this.getSelectedRuns(); + return this.archiveProjectService[archive ? 'archiveProjects' : 'unarchiveProjects']( + this.getProjects(runs) + ).subscribe({ + next: (archiveProjectsResponse: ArchiveProjectResponse[]) => { + this.updateRunsArchivedStatus(runs, archiveProjectsResponse); + this.openSuccessSnackBar(runs, archiveProjectsResponse, archive); + }, + error: () => { + this.showErrorSnackBar(archive); + } + }); + } + + private updateRunsArchivedStatus( + runs: TeacherRun[], + archiveProjectsResponse: ArchiveProjectResponse[] + ): void { + for (const archiveProjectResponse of archiveProjectsResponse) { + const run = runs.find((run: TeacherRun) => run.project.id === archiveProjectResponse.id); + run.archived = archiveProjectResponse.archived; + } + this.archiveActionEvent.emit(); + } + + private openSuccessSnackBar( + runs: TeacherRun[], + archiveProjectsResponse: ArchiveProjectResponse[], + archived: boolean + ): void { + const count = archiveProjectsResponse.filter( + (response: ArchiveProjectResponse) => response.archived === archived + ).length; + this.snackBar + .open( + archived + ? $localize`Successfully archived ${count} unit(s).` + : $localize`Successfully restored ${count} unit(s).`, + $localize`Undo` + ) + .onAction() + .subscribe(() => { + this.undoArchiveAction(runs, archived ? 'unarchiveProjects' : 'archiveProjects'); + }); + } + + private showErrorSnackBar(archive: boolean): void { + this.snackBar.open( + archive ? $localize`Error archiving unit(s).` : $localize`Error restoring unit(s).` + ); + } + + private undoArchiveAction(runs: TeacherRun[], archiveFunctionName: string): void { + this.archiveProjectService[archiveFunctionName](this.getProjects(runs)).subscribe({ + next: (archiveProjectsResponse: ArchiveProjectResponse[]) => { + this.updateRunsArchivedStatus(runs, archiveProjectsResponse); + this.archiveProjectService.refreshProjects(); + this.snackBar.open($localize`Action undone.`); + }, + error: () => { + this.snackBar.open($localize`Error undoing action.`); + } + }); + } + + private getSelectedRuns(): TeacherRun[] { + return this.runs.filter((run: TeacherRun) => run.selected); + } + + private getProjects(runs: TeacherRun[]): Project[] { + return runs.map((run: TeacherRun) => run.project); + } +} diff --git a/src/app/teacher/select-runs-controls/select-runs-controls.harness.ts b/src/app/teacher/select-runs-controls/select-runs-controls.harness.ts new file mode 100644 index 00000000000..27b829e83b3 --- /dev/null +++ b/src/app/teacher/select-runs-controls/select-runs-controls.harness.ts @@ -0,0 +1,42 @@ +import { ComponentHarness } from '@angular/cdk/testing'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; + +export class SelectRunsControlsHarness extends ComponentHarness { + static hostSelector = 'select-runs-controls'; + protected getArchiveButton = this.locatorFor( + MatButtonHarness.with({ selector: '[aria-label="Archive selected units"]' }) + ); + protected getSelectAllCheckbox = this.locatorFor(MatCheckboxHarness); + protected getUnarchiveButton = this.locatorFor( + MatButtonHarness.with({ selector: '[aria-label="Restore selected units"]' }) + ); + + async checkCheckbox(): Promise { + return (await this.getSelectAllCheckbox()).check(); + } + + async uncheckCheckbox(): Promise { + return (await this.getSelectAllCheckbox()).uncheck(); + } + + async toggleCheckbox(): Promise { + return (await this.getSelectAllCheckbox()).toggle(); + } + + async isChecked(): Promise { + return (await this.getSelectAllCheckbox()).isChecked(); + } + + async isIndeterminate(): Promise { + return (await this.getSelectAllCheckbox()).isIndeterminate(); + } + + async clickArchiveButton(): Promise { + return (await this.getArchiveButton()).click(); + } + + async clickUnarchiveButton(): Promise { + return (await this.getUnarchiveButton()).click(); + } +} diff --git a/src/app/teacher/select-runs-controls/select-runs-controls.module.ts b/src/app/teacher/select-runs-controls/select-runs-controls.module.ts new file mode 100644 index 00000000000..d5e28e41dd2 --- /dev/null +++ b/src/app/teacher/select-runs-controls/select-runs-controls.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { SelectRunsControlsComponent } from './select-runs-controls.component'; +import { FlexLayoutModule } from '@angular/flex-layout'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + declarations: [SelectRunsControlsComponent], + exports: [SelectRunsControlsComponent], + imports: [ + CommonModule, + FlexLayoutModule, + MatButtonModule, + MatCheckboxModule, + MatIconModule, + MatMenuModule, + MatTooltipModule + ] +}) +export class SelectRunsControlsModule {} diff --git a/src/app/teacher/select-runs-controls/select-runs-option.ts b/src/app/teacher/select-runs-controls/select-runs-option.ts new file mode 100644 index 00000000000..a64017ff589 --- /dev/null +++ b/src/app/teacher/select-runs-controls/select-runs-option.ts @@ -0,0 +1,7 @@ +export enum SelectRunsOption { + All = 'ALL', + None = 'NONE', + Completed = 'COMPLETED', + Running = 'RUNNING', + Scheduled = 'SCHEDULED' +} diff --git a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html index 6b69d0d09ad..268dfbf3c2a 100644 --- a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html +++ b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html @@ -51,10 +51,21 @@ +
+ +
- + {{ run.project.name }}
- +
+ +
diff --git a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.scss b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.scss index 33e27dadcb6..ac613c736a2 100644 --- a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.scss +++ b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.scss @@ -27,6 +27,7 @@ margin-bottom: 2px; } -app-run-menu { - margin: -8px -8px 0 0; +.run-action { + margin: -8px; + display: block; } diff --git a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.spec.ts b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.spec.ts index fe9a5dee30b..d45f1881adc 100644 --- a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.spec.ts +++ b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.spec.ts @@ -1,6 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TeacherRunListItemComponent } from './teacher-run-list-item.component'; -import { Project } from '../../domain/project'; import { TeacherService } from '../teacher.service'; import { TeacherRun } from '../teacher-run'; import { ConfigService } from '../../services/config.service'; @@ -8,6 +7,18 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { MatDialogModule } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; +import { TeacherRunListItemHarness } from './teacher-run-list-item.harness'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatMenuModule } from '@angular/material/menu'; +import { RunMenuComponent } from '../run-menu/run-menu.component'; +import { ArchiveProjectService } from '../../services/archive-project.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { UserService } from '../../services/user.service'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { User } from '../../domain/user'; +import { MockArchiveProjectService } from '../../services/mock-archive-project.service'; export class MockTeacherService {} @@ -23,48 +34,93 @@ export class MockConfigService { } } -describe('TeacherRunListItemComponent', () => { - let component: TeacherRunListItemComponent; - let fixture: ComponentFixture; +let component: TeacherRunListItemComponent; +let fixture: ComponentFixture; +const periods = ['1', '2']; +const projectName: string = 'Photosynthesis'; +const numStudents: number = 30; +const runCode: string = 'Dog123'; +let runListItemHarness: TeacherRunListItemHarness; +const userId: number = 1; +let userService: UserService; - beforeEach(() => { +describe('TeacherRunListItemComponent', () => { + beforeEach(async () => { TestBed.configureTestingModule({ - declarations: [TeacherRunListItemComponent], - imports: [MatDialogModule, BrowserAnimationsModule, RouterTestingModule], + declarations: [RunMenuComponent, TeacherRunListItemComponent], + imports: [ + BrowserAnimationsModule, + HttpClientTestingModule, + MatCardModule, + MatDialogModule, + MatIconModule, + MatMenuModule, + MatSnackBarModule, + RouterTestingModule + ], providers: [ + { provide: ArchiveProjectService, useClass: MockArchiveProjectService }, + { provide: ConfigService, useClass: MockConfigService }, { provide: TeacherService, useClass: MockTeacherService }, - { provide: ConfigService, useClass: MockConfigService } + UserService ], schemas: [NO_ERRORS_SCHEMA] }); + userService = TestBed.inject(UserService); + spyOn(userService, 'getUserId').and.returnValue(userId); fixture = TestBed.createComponent(TeacherRunListItemComponent); component = fixture.componentInstance; - const run = new TeacherRun(); - run.id = 1; - run.name = 'Photosynthesis'; - run.startTime = new Date('2018-10-17T00:00:00.0').getTime(); - run.endTime = new Date('2018-10-18T23:59:59.0').getTime(); - run.numStudents = 30; - run.periods = ['1', '2']; - run.runCode = 'Dog123'; - const project = new Project(); - project.id = 1; - project.name = 'Photosynthesis'; - project.projectThumb = ''; - run.project = project; - component.run = run; + component.run = new TeacherRun({ + id: 1, + archived: false, + selected: false, + name: projectName, + numStudents: numStudents, + owner: new User({ id: userId }), + periods: periods, + project: { + id: 1, + tags: [], + name: projectName, + owner: new User({ id: userId }) + }, + runCode: runCode, + startTime: new Date('2018-10-17').getTime(), + endTime: new Date('2018-10-18').getTime() + }); fixture.detectChanges(); + runListItemHarness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + TeacherRunListItemHarness + ); }); - it('should create', () => { - expect(component).toBeTruthy(); + render(); + runArchiveStatusChanged(); +}); + +function render() { + describe('render', () => { + it('should show run info', () => { + const compiled = fixture.debugElement.nativeElement; + expect(compiled.textContent).toContain(projectName); + expect(compiled.textContent).toContain(`${periods.length} periods`); + expect(compiled.textContent).toContain(`${numStudents} students`); + expect(compiled.textContent).toContain(`Access Code: ${runCode}`); + }); }); +} - it('should show run info', () => { - const compiled = fixture.debugElement.nativeElement; - expect(compiled.textContent).toContain('Photosynthesis'); - expect(compiled.textContent).toContain('2 periods'); - expect(compiled.textContent).toContain('30 students'); - expect(compiled.textContent).toContain('Access Code: Dog123'); +function runArchiveStatusChanged() { + describe('run is not archived and archive menu button is clicked', () => { + it('should archive run and emit events', async () => { + const runSelectedSpy = spyOn(component.runSelectedStatusChangedEvent, 'emit'); + const runArchiveSpy = spyOn(component.runArchiveStatusChangedEvent, 'emit'); + expect(await runListItemHarness.isArchived()).toBeFalse(); + await runListItemHarness.clickArchiveMenuButton(); + expect(await runListItemHarness.isArchived()).toBeTrue(); + expect(runSelectedSpy).toHaveBeenCalled(); + expect(runArchiveSpy).toHaveBeenCalled(); + }); }); -}); +} diff --git a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.ts b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.ts index 19df5e0c161..9a3cd8488a3 100644 --- a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.ts +++ b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, Input, ElementRef } from '@angular/core'; +import { Component, OnInit, Input, ElementRef, Output, EventEmitter } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { SafeStyle } from '@angular/platform-browser'; import { TeacherRun } from '../teacher-run'; @@ -15,13 +15,14 @@ import { ShareRunCodeDialogComponent } from '../share-run-code-dialog/share-run- animations: [flash] }) export class TeacherRunListItemComponent implements OnInit { + protected animateDelay: string = '0s'; + protected animateDuration: string = '0s'; + protected manageStudentsLink: string = ''; + protected periodsTooltipText: string; @Input() run: TeacherRun = new TeacherRun(); - - manageStudentsLink: string = ''; - periodsTooltipText: string; - thumbStyle: SafeStyle; - animateDuration: string = '0s'; - animateDelay: string = '0s'; + @Output() runArchiveStatusChangedEvent: EventEmitter = new EventEmitter(); + @Output() runSelectedStatusChangedEvent: EventEmitter = new EventEmitter(); + protected thumbStyle: SafeStyle; constructor( private sanitizer: DomSanitizer, @@ -36,17 +37,17 @@ export class TeacherRunListItemComponent implements OnInit { this.manageStudentsLink = `${this.configService.getContextPath()}/teacher/manage/unit/${ this.run.id }/manage-students`; - if (this.run.isHighlighted) { + if (this.run.highlighted) { this.animateDuration = '2s'; this.animateDelay = '1s'; setTimeout(() => { - this.run.isHighlighted = false; + this.run.highlighted = false; }, 7000); } } ngAfterViewInit() { - if (this.run.isHighlighted) { + if (this.run.highlighted) { this.elRef.nativeElement.querySelector('mat-card').scrollIntoView(); } } @@ -61,7 +62,7 @@ export class TeacherRunListItemComponent implements OnInit { return this.sanitizer.bypassSecurityTrustStyle(STYLE); } - launchGradeAndManageTool(): void { + protected launchGradeAndManageTool(): void { if (this.run.project.wiseVersion === 4) { window.location.href = `${this.configService.getWISE4Hostname()}` + @@ -73,7 +74,7 @@ export class TeacherRunListItemComponent implements OnInit { } } - getPeriodsTooltipText(): string { + private getPeriodsTooltipText(): string { let string = ''; const length = this.run.periods.length; for (let p = 0; p < length; p++) { @@ -88,11 +89,11 @@ export class TeacherRunListItemComponent implements OnInit { return string; } - isRunActive(run) { + protected isRunActive(run: TeacherRun): boolean { return run.isActive(this.configService.getCurrentServerTime()); } - isRunCompleted(run) { + protected isRunCompleted(run: TeacherRun): boolean { return run.isCompleted(this.configService.getCurrentServerTime()); } @@ -103,4 +104,10 @@ export class TeacherRunListItemComponent implements OnInit { panelClass: 'dialog-sm' }); } + + protected runArchiveStatusChanged(): void { + this.run.selected = false; + this.runSelectedStatusChangedEvent.emit(); + this.runArchiveStatusChangedEvent.emit(); + } } diff --git a/src/app/teacher/teacher-run-list-item/teacher-run-list-item.harness.ts b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.harness.ts new file mode 100644 index 00000000000..a45d77aa65d --- /dev/null +++ b/src/app/teacher/teacher-run-list-item/teacher-run-list-item.harness.ts @@ -0,0 +1,35 @@ +import { ComponentHarness } from '@angular/cdk/testing'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; +import { MatCardHarness } from '@angular/material/card/testing'; +import { RunMenuHarness } from '../run-menu/run-menu.harness'; + +export class TeacherRunListItemHarness extends ComponentHarness { + static hostSelector = 'app-teacher-run-list-item'; + protected getCard = this.locatorFor(MatCardHarness); + protected getCheckbox = this.locatorFor(MatCheckboxHarness); + protected getMenu = this.locatorFor(RunMenuHarness); + + async checkCheckbox(): Promise { + return (await this.getCheckbox()).check(); + } + + async isChecked(): Promise { + return (await this.getCheckbox()).isChecked(); + } + + async clickArchiveMenuButton(): Promise { + return (await this.getMenu()).clickArchiveMenuButton(); + } + + async clickUnarchiveMenuButton(): Promise { + return (await this.getMenu()).clickUnarchiveMenuButton(); + } + + async getRunTitle(): Promise { + return (await this.getCard()).getTitleText(); + } + + async isArchived(): Promise { + return (await this.getMenu()).hasRestoreMenuButton(); + } +} diff --git a/src/app/teacher/teacher-run-list/teacher-run-list.component.html b/src/app/teacher/teacher-run-list/teacher-run-list.component.html index 21fb12545ed..6036c1273e9 100644 --- a/src/app/teacher/teacher-run-list/teacher-run-list.component.html +++ b/src/app/teacher/teacher-run-list/teacher-run-list.component.html @@ -1,13 +1,10 @@ -
-

Hey there! Looks like you haven't run any WISE units in your classes yet.

-

Select "Browse Units" to find titles to use with your students.

-
-
+
- - + + View + + Active + Archived + +
-
-

+

+

Units found: {{ filteredRuns.length }} - Total classroom units: {{ filteredRuns.length }} - - ({{ scheduledTotal() }} scheduled, + + Archived classroom units: + Active classroom units: + {{ filteredRuns.length }} + + + ({{ completedTotal() }} completed, {{ activeTotal() }} active{{ activeTotal() }} running + , {{ scheduledTotal() }} scheduled) | - Clear filters

- - - - - - {{ run.startTime | date : 'mediumDate' }} - - - {{ run.endTime | date : 'mediumDate' }} - - - - - - + + +
+

Hey there! Looks like you don't have any active classroom units.

+

Browse the "Unit Library" to find titles to use with your students.

+
+
+

Looks like you don't have any archived classroom units.

+
-
- -
- -
+ + + + + + {{ run.startTime | date: 'mediumDate' }} + + - {{ run.endTime | date: 'mediumDate' }} + + + + + + + + +
+ +
+
diff --git a/src/app/teacher/teacher-run-list/teacher-run-list.component.spec.ts b/src/app/teacher/teacher-run-list/teacher-run-list.component.spec.ts index 0c24871e9fb..c5a8dd0e23a 100644 --- a/src/app/teacher/teacher-run-list/teacher-run-list.component.spec.ts +++ b/src/app/teacher/teacher-run-list/teacher-run-list.component.spec.ts @@ -1,130 +1,374 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { defer, Observable } from 'rxjs'; +import { of } from 'rxjs'; import { TeacherRunListComponent } from './teacher-run-list.component'; import { TeacherService } from '../teacher.service'; -import { Project } from '../../domain/project'; import { TeacherRun } from '../teacher-run'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ConfigService } from '../../services/config.service'; import { RouterTestingModule } from '@angular/router/testing'; import { UserService } from '../../services/user.service'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { User } from '../../domain/user'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SelectRunsControlsModule } from '../select-runs-controls/select-runs-controls.module'; +import { BrowserModule } from '@angular/platform-browser'; +import { TeacherRunListItemComponent } from '../teacher-run-list-item/teacher-run-list-item.component'; +import { MatDialogModule } from '@angular/material/dialog'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { TeacherRunListHarness } from './teacher-run-list.harness'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatInputModule } from '@angular/material/input'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { RunMenuComponent } from '../run-menu/run-menu.component'; +import { MatMenuModule } from '@angular/material/menu'; +import { ArchiveProjectService } from '../../services/archive-project.service'; +import { MatCardModule } from '@angular/material/card'; +import { MockArchiveProjectService } from '../../services/mock-archive-project.service'; +import { MatSelectModule } from '@angular/material/select'; class TeacherScheduleStubComponent {} -export function fakeAsyncResponse(data: T) { - return defer(() => Promise.resolve(data)); -} - -export class MockTeacherService { - getRuns(): Observable { - const runs: TeacherRun[] = []; - const run1 = new TeacherRun(); - run1.id = 1; - run1.name = 'Photosynthesis'; - run1.numStudents = 30; - run1.periods = ['1', '2']; - run1.startTime = new Date('2018-01-01T00:00:00.0').getTime(); - const project1 = new Project(); - project1.id = 1; - project1.name = 'Photosynthesis'; - project1.projectThumb = ''; - run1.project = project1; - const run2 = new TeacherRun(); - run2.id = 2; - run2.name = 'Plate Tectonics'; - run2.numStudents = 15; - run2.periods = ['3', '4']; - run2.startTime = new Date('2018-03-03T00:00:00.0').getTime(); - const project2 = new Project(); - project2.id = 1; - project2.name = 'Plate Tectonics'; - project2.projectThumb = ''; - run2.project = project2; - runs.push(run1); - runs.push(run2); - return Observable.create((observer) => { - observer.next(runs); - observer.complete(); - }); - } - runs$ = fakeAsyncResponse({ - id: 3, - name: 'Global Climate Change', - periods: ['1', '2'] - }); -} - -export class MockConfigService { - getCurrentServerTime(): number { - return new Date('2018-08-24T00:00:00.0').getTime(); - } -} +let component: TeacherRunListComponent; +let configService: ConfigService; +const currentTime = new Date().getTime(); +let fixture: ComponentFixture; +let getRunsSpy: jasmine.Spy; +const run1StartTime = new Date('2020-01-01').getTime(); +const run1Title = 'First Run'; +const run2StartTime = new Date('2020-01-02').getTime(); +const run2Title = 'Second Run'; +const run3StartTime = new Date('2020-01-03').getTime(); +const run3Title = 'Third Run'; +let runListHarness: TeacherRunListHarness; +let teacherService: TeacherService; +const userId: number = 1; +let userService: UserService; -export class MockUserService { - getUserId(): number { - return 1; +class TeacherRunStub extends TeacherRun { + constructor(id: number, startTime: number, endTime: number, name: string, tags: string[] = []) { + super({ + id: id, + archived: false, + selected: false, + name: name, + numStudents: 10, + owner: new User({ id: userId }), + periods: [], + project: { + id: id, + tags: tags, + name: name, + owner: new User({ id: userId }) + }, + startTime: startTime, + endTime: endTime + }); } } describe('TeacherRunListComponent', () => { - let component: TeacherRunListComponent; - let fixture: ComponentFixture; - beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [TeacherRunListComponent], + declarations: [RunMenuComponent, TeacherRunListComponent, TeacherRunListItemComponent], imports: [ + BrowserAnimationsModule, + BrowserModule, + FormsModule, + HttpClientTestingModule, + MatCardModule, + MatCheckboxModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatMenuModule, + MatSelectModule, + MatSnackBarModule, RouterTestingModule.withRoutes([ { path: 'teacher/home/schedule', component: TeacherScheduleStubComponent } - ]) + ]), + SelectRunsControlsModule ], providers: [ - { provide: TeacherService, useClass: MockTeacherService }, - { provide: ConfigService, useClass: MockConfigService }, - { provide: UserService, useClass: MockUserService } + { provide: ArchiveProjectService, useClass: MockArchiveProjectService }, + ConfigService, + TeacherService, + UserService ], schemas: [NO_ERRORS_SCHEMA] }); }) ); - beforeEach(() => { + beforeEach(async () => { + configService = TestBed.inject(ConfigService); + teacherService = TestBed.inject(TeacherService); + userService = TestBed.inject(UserService); + getRunsSpy = spyOn(teacherService, 'getRuns'); + getRunsSpy.and.returnValue( + of([ + new TeacherRunStub(1, run1StartTime, currentTime - 1000, run1Title), + new TeacherRunStub(2, run2StartTime, null, run2Title), + new TeacherRunStub(3, currentTime + 86400000, null, run3Title) + ]) + ); + spyOn(configService, 'getCurrentServerTime').and.returnValue(currentTime); + spyOn(configService, 'getContextPath').and.returnValue(''); + spyOn(userService, 'getUserId').and.returnValue(userId); fixture = TestBed.createComponent(TeacherRunListComponent); component = fixture.componentInstance; fixture.detectChanges(); + runListHarness = await TestbedHarnessEnvironment.harnessForFixture( + fixture, + TeacherRunListHarness + ); + }); + + archiveSelectedRuns(); + showArchivedChanged(); + runArchiveStatusChanged(); + runSelectedStatusChanged(); + selectAllRunsCheckboxClicked(); + selectRunsOptionChosen(); + sortByStartTimeDesc(); + unarchiveSelectedRuns(); + noRuns(); +}); + +function sortByStartTimeDesc() { + describe('sortByStartTimeDesc()', () => { + it('should sort runs by start date', async () => { + await expectRunTitles([run3Title, run2Title, run1Title]); + }); + }); +} + +function archiveSelectedRuns(): void { + describe('archiveSelectedRuns()', () => { + it('should archive selected runs', async () => { + await runListHarness.clickRunListItemCheckbox(0); + await runListHarness.clickRunListItemCheckbox(1); + await runListHarness.clickArchiveButton(); + expect(await runListHarness.getNumRunListItems()).toEqual(1); + await expectRunTitles([run1Title]); + }); + }); +} + +function unarchiveSelectedRuns(): void { + describe('unarchiveSelectedRuns()', () => { + it('should unarchive selected runs', async () => { + getRunsSpy.and.returnValue( + of([ + new TeacherRunStub(1, run1StartTime, null, run1Title), + new TeacherRunStub(2, run2StartTime, null, run2Title, ['archived']), + new TeacherRunStub(3, run3StartTime, null, run3Title, ['archived']) + ]) + ); + component.ngOnInit(); + await runListHarness.showArchived(); + expect(await runListHarness.getNumRunListItems()).toEqual(2); + await runListHarness.clickRunListItemCheckbox(0); + await runListHarness.clickRunListItemCheckbox(1); + await runListHarness.clickUnarchiveButton(); + expect(await runListHarness.getNumRunListItems()).toEqual(0); + }); + }); +} + +function showArchivedChanged(): void { + describe('showArchivedChanged()', () => { + describe('active runs are shown and some runs are selected', () => { + it('should unselect the runs', async () => { + expect(await runListHarness.isShowingArchived()).toBeFalse(); + await runListHarness.clickRunListItemCheckbox(0); + await runListHarness.clickRunListItemCheckbox(2); + await runListHarness.showArchived(); + expect(await runListHarness.isShowingArchived()).toBeTrue(); + await expectRunsIsSelected([false, false, false]); + }); + }); + }); +} + +function runSelectedStatusChanged(): void { + describe('runSelectedStatusChanged()', () => { + describe('one run is selected', () => { + it('should show indeterminate for the select all checkbox', async () => { + await clickRunListeItemCheckboxes([0]); + expect(await runListHarness.isSelectRunsCheckboxIndeterminate()).toBeTrue(); + }); + }); + describe('two runs are selected', () => { + it('should show indeterminate for the select all checkbox', async () => { + await clickRunListeItemCheckboxes([0, 1]); + expect(await runListHarness.isSelectRunsCheckboxIndeterminate()).toBeTrue(); + }); + }); + describe('all runs are selected', () => { + it('should show checked for the select all checkbox', async () => { + await clickRunListeItemCheckboxes([0, 1, 2]); + expect(await runListHarness.isSelectRunsCheckboxChecked()).toBeTrue(); + }); + }); }); +} - function isRunsSortedByStartTimeDesc(runs: TeacherRun[]): boolean { - let previous: number = null; - for (let run of runs) { - let current = run.startTime; - if (previous && previous < current) { - return false; - } - previous = current; - } - return true; +async function clickRunListeItemCheckboxes(indexes: number[]): Promise { + for (const index of indexes) { + await runListHarness.clickRunListItemCheckbox(index); } +} + +function selectAllRunsCheckboxClicked(): void { + describe('selectAllRunsCheckboxClicked()', () => { + selectAllRuns(); + unselectAllRuns(); + someSelectedUnselectAllRuns(); + }); +} - it('should create', () => { - expect(component).toBeTruthy(); +function selectAllRuns() { + describe('select all runs checkbox is not checked and it is clicked', () => { + it('should select all runs', async () => { + await expectRunsIsSelected([false, false, false]); + expect(await runListHarness.isSelectRunsCheckboxChecked()).toBeFalse(); + await runListHarness.checkSelectRunsCheckbox(); + await expectRunsIsSelected([true, true, true]); + expect(await runListHarness.isSelectRunsCheckboxChecked()).toBeTrue(); + }); }); +} - it('should sort runs by start date', () => { - const run3 = new TeacherRun(); - run3.id = 3; - run3.name = 'Planet Earth'; - run3.numStudents = 10; - run3.periods = ['6', '7']; - run3.startTime = new Date('2018-02-02T00:00:00.0').getTime(); - const project3 = new Project(); - project3.id = 1; - project3.name = 'Planet Earth'; - project3.projectThumb = ''; - run3.project = project3; - component.runs.push(run3); - component.runs.sort(component.sortByStartTimeDesc); - expect(isRunsSortedByStartTimeDesc(component.runs)).toBeTruthy(); +function unselectAllRuns() { + describe('select all runs checkbox is checked and it is clicked', () => { + it('should unselect all runs', async () => { + await runListHarness.checkSelectRunsCheckbox(); + await expectRunsIsSelected([true, true, true]); + expect(await runListHarness.isSelectRunsCheckboxChecked()).toBeTrue(); + await runListHarness.uncheckSelectRunsCheckbox(); + await expectRunsIsSelected([false, false, false]); + expect(await runListHarness.isSelectRunsCheckboxChecked()).toBeFalse(); + }); }); -}); +} + +function someSelectedUnselectAllRuns() { + describe('select all runs checkbox is indeterminate checked and it is clicked', () => { + it('should unselect all runs', async () => { + await runListHarness.clickRunListItemCheckbox(0); + await expectRunsIsSelected([true, false, false]); + expect(await runListHarness.isSelectRunsCheckboxIndeterminate()).toBeTrue(); + await runListHarness.toggleSelectRunsCheckbox(); + await expectRunsIsSelected([false, false, false]); + expect(await runListHarness.isSelectRunsCheckboxChecked()).toBeFalse(); + }); + }); +} + +function selectRunsOptionChosen(): void { + describe('selectRunsOptionChosen()', () => { + const testCases = [ + { menuButtonText: 'All', selectedRuns: [true, true, true] }, + { menuButtonText: 'None', selectedRuns: [false, false, false] }, + { menuButtonText: 'Completed', selectedRuns: [false, false, true] }, + { menuButtonText: 'Running', selectedRuns: [false, true, false] }, + { menuButtonText: 'Scheduled', selectedRuns: [true, false, false] } + ]; + testCases.forEach(({ menuButtonText, selectedRuns }) => { + describe(`when ${menuButtonText} option is chosen`, () => { + beforeEach(async () => { + await runListHarness.clickSelectRunsMenuButton(menuButtonText); + }); + it(`should select ${menuButtonText} runs`, async () => { + await expectRunsIsSelected(selectedRuns); + }); + }); + }); + }); +} + +function runArchiveStatusChanged(): void { + describe('runArchiveStatusChanged()', () => { + archiveRunNoLongerInActiveView(); + unarchiveRunNoLongerInArchivedView(); + }); +} + +function archiveRunNoLongerInActiveView() { + describe('when a run is archived', () => { + it('it should no longer be displayed in the active view', async () => { + expect(await runListHarness.isShowingArchived()).toBeFalse(); + expect(await runListHarness.getNumRunListItems()).toEqual(3); + await runListHarness.clickRunListItemMenuArchiveButton(1); + expect(await runListHarness.isShowingArchived()).toBeFalse(); + expect(await runListHarness.getNumRunListItems()).toEqual(2); + await expectRunTitles([run3Title, run1Title]); + }); + }); +} + +function unarchiveRunNoLongerInArchivedView() { + describe('when a run is unarchived', () => { + it('it should no longer be displayed in the archived view', async () => { + getRunsSpy.and.returnValue( + of([ + new TeacherRunStub(1, run1StartTime, null, run1Title), + new TeacherRunStub(2, run2StartTime, null, run2Title, ['archived']), + new TeacherRunStub(3, run3StartTime, null, run3Title) + ]) + ); + component.ngOnInit(); + await runListHarness.showArchived(); + expect(await runListHarness.isShowingArchived()).toBeTrue(); + expect(await runListHarness.getNumRunListItems()).toEqual(1); + await expectRunTitles([run2Title]); + await runListHarness.clickRunListItemMenuUnarchiveButton(0); + expect(await runListHarness.isShowingArchived()).toBeTrue(); + expect(await runListHarness.getNumRunListItems()).toEqual(0); + }); + }); +} + +function noRuns(): void { + describe('when there are no runs', () => { + beforeEach(() => { + getRunsSpy.and.returnValue(of([])); + component.ngOnInit(); + }); + describe('in the active view', () => { + it('should display a message', async () => { + expect(await runListHarness.isShowingArchived()).toBeFalse(); + expect(await runListHarness.getNoRunsMessage()).toContain( + "Hey there! Looks like you don't have any active classroom units." + ); + }); + }); + describe('in the archived view', () => { + it('should display a message', async () => { + await runListHarness.showArchived(); + expect(await runListHarness.isShowingArchived()).toBeTrue(); + expect(await runListHarness.getNoRunsMessage()).toContain( + "Looks like you don't have any archived classroom units." + ); + }); + }); + }); +} + +async function expectRunTitles(expectedRunTitles: string[]): Promise { + const numRunListItems = await runListHarness.getNumRunListItems(); + for (let i = 0; i < numRunListItems; i++) { + const runListItem = await runListHarness.getRunListItem(i); + expect(await runListItem.getRunTitle()).toEqual(expectedRunTitles[i]); + } +} + +async function expectRunsIsSelected(expectRunsIsSelected: boolean[]): Promise { + const numRunListItems = await runListHarness.getNumRunListItems(); + for (let i = 0; i < numRunListItems; i++) { + expect(await runListHarness.isRunListItemCheckboxChecked(i)).toEqual(expectRunsIsSelected[i]); + } +} diff --git a/src/app/teacher/teacher-run-list/teacher-run-list.component.ts b/src/app/teacher/teacher-run-list/teacher-run-list.component.ts index 30ffae67d3d..184a83936d6 100644 --- a/src/app/teacher/teacher-run-list/teacher-run-list.component.ts +++ b/src/app/teacher/teacher-run-list/teacher-run-list.component.ts @@ -1,12 +1,14 @@ -import { Component, Inject, LOCALE_ID, OnInit } from '@angular/core'; +import { Component, EventEmitter, Inject, LOCALE_ID, OnInit } from '@angular/core'; import { TeacherService } from '../teacher.service'; import { TeacherRun } from '../teacher-run'; import { ConfigService } from '../../services/config.service'; import { ActivatedRoute, Params, Router } from '@angular/router'; -import { formatDate } from '@angular/common'; import { Observable, of, Subscription } from 'rxjs'; import { UserService } from '../../services/user.service'; import { mergeMap } from 'rxjs/operators'; +import { ArchiveProjectService } from '../../services/archive-project.service'; +import { runSpansDays } from '../../../assets/wise5/common/datetime/datetime'; +import { SelectRunsOption } from '../select-runs-controls/select-runs-option'; @Component({ selector: 'app-teacher-run-list', @@ -14,19 +16,22 @@ import { mergeMap } from 'rxjs/operators'; styleUrls: ['./teacher-run-list.component.scss'] }) export class TeacherRunListComponent implements OnInit { - MAX_RECENT_RUNS = 10; - - runs: TeacherRun[] = []; - filteredRuns: TeacherRun[] = []; - loaded: boolean = false; - searchValue: string = ''; - periods: string[] = []; - filterOptions: any[]; - filterValue: string = ''; - showAll: boolean = false; - subscriptions: Subscription = new Subscription(); + private MAX_RECENT_RUNS = 10; + + protected allRunsLoaded: boolean = false; + protected filteredRuns: TeacherRun[] = []; + protected filterValue: string = ''; + protected numSelectedRuns: number = 0; + protected recentRunsLoaded: boolean = false; + protected runChangedEventEmitter: EventEmitter = new EventEmitter(); + protected runs: TeacherRun[] = []; + protected searchValue: string = ''; + protected showAll: boolean = false; + protected showArchived: boolean = false; + private subscriptions: Subscription = new Subscription(); constructor( + private archiveProjectService: ArchiveProjectService, private configService: ConfigService, @Inject(LOCALE_ID) private localeID: string, private route: ActivatedRoute, @@ -38,6 +43,7 @@ export class TeacherRunListComponent implements OnInit { ngOnInit() { this.getRuns(); this.subscribeToRuns(); + this.subscribeToRefreshProjects(); } ngOnDestroy() { @@ -52,7 +58,7 @@ export class TeacherRunListComponent implements OnInit { this.setRuns(runs); this.processRuns(); this.highlightNewRunIfNecessary(); - this.loaded = true; + this.allRunsLoaded = true; }); } @@ -70,9 +76,11 @@ export class TeacherRunListComponent implements OnInit { this.runs = runs.map((run) => { const teacherRun = new TeacherRun(run); teacherRun.shared = !teacherRun.isOwner(userId); + teacherRun.archived = teacherRun.project.tags.includes('archived'); return teacherRun; }); this.filteredRuns = this.runs; + this.recentRunsLoaded = true; } private subscribeToRuns(): void { @@ -83,6 +91,14 @@ export class TeacherRunListComponent implements OnInit { ); } + private subscribeToRefreshProjects(): void { + this.subscriptions.add( + this.archiveProjectService.refreshProjectsEvent$.subscribe(() => { + this.runArchiveStatusChanged(); + }) + ); + } + private updateExistingRun(updatedRun: TeacherRun): void { const runIndex = this.runs.findIndex((run) => run.id === updatedRun.id); this.runs.splice(runIndex, 1, updatedRun); @@ -92,86 +108,56 @@ export class TeacherRunListComponent implements OnInit { private processRuns(): void { this.filteredRuns = this.runs; - this.populatePeriods(); - this.periods.sort(); - this.populateFilterOptions(); this.performSearchAndFilter(); } - sortByStartTimeDesc(a: TeacherRun, b: TeacherRun): number { + protected sortByStartTimeDesc(a: TeacherRun, b: TeacherRun): number { return b.startTime - a.startTime; } - private populatePeriods(): void { - this.periods = []; - for (const run of this.runs) { - for (const period of run.periods) { - if (!this.periods.includes(period)) { - this.periods.push(period); - } - } - } + protected runSpansDays(run: TeacherRun): boolean { + return runSpansDays(run, this.localeID); } - private populateFilterOptions(): void { - this.filterOptions = [{ value: '', label: $localize`All Periods` }]; - for (const period of this.periods) { - this.filterOptions.push({ value: period, label: period }); - } + protected activeTotal(): number { + return this.getTotal('isActive'); } - runSpansDays(run: TeacherRun) { - const startDay = formatDate(run.startTime, 'shortDate', this.localeID); - const endDay = formatDate(run.endTime, 'shortDate', this.localeID); - return startDay != endDay; + protected completedTotal(): number { + const now = this.configService.getCurrentServerTime(); + return this.filteredRuns.filter( + (run: TeacherRun) => !run.isActive(now) && !run.isScheduled(now) + ).length; } - activeTotal(): number { - let total = 0; - const now = this.configService.getCurrentServerTime(); - for (const run of this.filteredRuns) { - if (run.isActive(now)) { - total++; - } - } - return total; + protected scheduledTotal(): number { + return this.getTotal('isScheduled'); } - scheduledTotal(): number { - let total = 0; + private getTotal(filterFunctionName: string): number { const now = this.configService.getCurrentServerTime(); - for (const run of this.filteredRuns) { - if (run.isScheduled(now)) { - total++; - } - } - return total; + return this.filteredRuns.filter((run: TeacherRun) => run[filterFunctionName](now)).length; } private performSearchAndFilter(): void { this.filteredRuns = this.searchValue ? this.performSearch(this.searchValue) : this.runs; - this.performFilter(this.filterValue); + this.performFilter(); + this.runSelectedStatusChanged(); } - searchChanged(searchValue: string): void { + protected searchChanged(searchValue: string): void { this.searchValue = searchValue; this.performSearchAndFilter(); } - filterChanged(value: string): void { - this.filterValue = value; - this.performSearchAndFilter(); - } - - private performFilter(value: string): void { + private performFilter(): void { this.filteredRuns = this.filteredRuns.filter((run: TeacherRun) => { - return value === '' || run.periods.includes(value); + return (!this.showArchived && !run.archived) || (this.showArchived && run.archived); }); } - private performSearch(searchValue: string) { + private performSearch(searchValue: string): TeacherRun[] { searchValue = searchValue.toLocaleLowerCase(); - // TODO: extract this for global use? return this.runs.filter((run: TeacherRun) => Object.keys(run).some((prop) => { const value = run[prop]; @@ -186,13 +172,18 @@ export class TeacherRunListComponent implements OnInit { ); } - reset(): void { + protected clearFilters(event: Event): void { + event.preventDefault(); + this.reset(); + } + + protected reset(): void { this.searchValue = ''; this.filterValue = ''; this.performSearchAndFilter(); } - isRunActive(run) { + protected isRunActive(run: TeacherRun): boolean { return run.isActive(this.configService.getCurrentServerTime()); } @@ -212,8 +203,34 @@ export class TeacherRunListComponent implements OnInit { private highlightNewRun(runId: number): void { for (const run of this.runs) { if (run.id === runId) { - run.isHighlighted = true; + run.highlighted = true; } } } + + private unselectAllRuns(): void { + for (const run of this.runs) { + run.selected = false; + } + } + + protected selectRunsOptionChosen(option: SelectRunsOption): void { + const now = this.configService.getCurrentServerTime(); + this.filteredRuns.forEach((run: TeacherRun) => run.updateSelected(option, now)); + this.runSelectedStatusChanged(); + } + + protected updateRunsInformation(): void { + this.unselectAllRuns(); + this.runSelectedStatusChanged(); + this.performSearchAndFilter(); + } + + protected runArchiveStatusChanged(): void { + this.performSearchAndFilter(); + } + + private runSelectedStatusChanged(): void { + this.runChangedEventEmitter.emit(); + } } diff --git a/src/app/teacher/teacher-run-list/teacher-run-list.harness.ts b/src/app/teacher/teacher-run-list/teacher-run-list.harness.ts new file mode 100644 index 00000000000..10e93607344 --- /dev/null +++ b/src/app/teacher/teacher-run-list/teacher-run-list.harness.ts @@ -0,0 +1,92 @@ +import { ComponentHarness } from '@angular/cdk/testing'; +import { TeacherRunListItemHarness } from '../teacher-run-list-item/teacher-run-list-item.harness'; +import { SelectRunsControlsHarness } from '../select-runs-controls/select-runs-controls.harness'; +import { MatSelectHarness } from '@angular/material/select/testing'; +import { clickMenuButton } from '../../common/harness-helper'; + +export class TeacherRunListHarness extends ComponentHarness { + static hostSelector = 'app-teacher-run-list'; + private ARCHIVED_TEXT = 'Archived'; + protected getNoRunsMessageDiv = this.locatorFor('.no-runs-message'); + protected getRunListItems = this.locatorForAll(TeacherRunListItemHarness); + protected getSelectRunsControls = this.locatorFor(SelectRunsControlsHarness); + protected getViewSelect = this.locatorFor(MatSelectHarness); + + async isShowingArchived(): Promise { + return (await (await this.getViewSelect()).getValueText()) === this.ARCHIVED_TEXT; + } + + async showArchived(): Promise { + return (await this.getViewSelect()).clickOptions({ text: this.ARCHIVED_TEXT }); + } + + async checkSelectRunsCheckbox(): Promise { + return (await this.getSelectRunsControls()).checkCheckbox(); + } + + async uncheckSelectRunsCheckbox(): Promise { + return (await this.getSelectRunsControls()).uncheckCheckbox(); + } + + async toggleSelectRunsCheckbox(): Promise { + return (await this.getSelectRunsControls()).toggleCheckbox(); + } + + async isSelectRunsCheckboxChecked(): Promise { + return (await this.getSelectRunsControls()).isChecked(); + } + + async isSelectRunsCheckboxIndeterminate(): Promise { + return (await this.getSelectRunsControls()).isIndeterminate(); + } + + async clickSelectRunsMenuButton(menuButtonText: string): Promise { + return clickMenuButton(await this.getSelectRunsControls(), menuButtonText); + } + + async clickRunListItemCheckbox(index: number): Promise { + return (await this.getRunListItem(index)).checkCheckbox(); + } + + async isRunListItemCheckboxChecked(index: number): Promise { + return (await this.getRunListItem(index)).isChecked(); + } + + async getRunListItem(index: number): Promise { + return (await this.getRunListItems())[index]; + } + + async getNumRunListItems(): Promise { + return (await this.getRunListItems()).length; + } + + async getNumSelectedRunListItems(): Promise { + let numSelectedRunListItems = 0; + for (let i = 0; i < (await this.getRunListItems()).length; i++) { + if (await this.isRunListItemCheckboxChecked(i)) { + numSelectedRunListItems++; + } + } + return numSelectedRunListItems; + } + + async clickRunListItemMenuArchiveButton(index: number): Promise { + return (await this.getRunListItem(index)).clickArchiveMenuButton(); + } + + async clickRunListItemMenuUnarchiveButton(index: number): Promise { + return (await this.getRunListItem(index)).clickUnarchiveMenuButton(); + } + + async clickArchiveButton(): Promise { + return (await this.getSelectRunsControls()).clickArchiveButton(); + } + + async clickUnarchiveButton(): Promise { + return (await this.getSelectRunsControls()).clickUnarchiveButton(); + } + + async getNoRunsMessage(): Promise { + return (await this.getNoRunsMessageDiv()).text(); + } +} diff --git a/src/app/teacher/teacher-run.ts b/src/app/teacher/teacher-run.ts index 6eb4480b1a5..7678ba8c4c7 100644 --- a/src/app/teacher/teacher-run.ts +++ b/src/app/teacher/teacher-run.ts @@ -1,10 +1,33 @@ import { Run } from '../domain/run'; +import { SelectRunsOption } from './select-runs-controls/select-runs-option'; export class TeacherRun extends Run { - isHighlighted: boolean; + archived: boolean; + selected: boolean; shared: boolean; + highlighted: boolean; constructor(jsonObject: any = {}) { super(jsonObject); } + + updateSelected(selectRunsOption: SelectRunsOption, currentTime: number): void { + switch (selectRunsOption) { + case SelectRunsOption.All: + this.selected = true; + break; + case SelectRunsOption.None: + this.selected = false; + break; + case SelectRunsOption.Completed: + this.selected = this.isCompleted(currentTime); + break; + case SelectRunsOption.Running: + this.selected = this.isActive(currentTime); + break; + case SelectRunsOption.Scheduled: + this.selected = this.isScheduled(currentTime); + break; + } + } } diff --git a/src/app/teacher/teacher.module.ts b/src/app/teacher/teacher.module.ts index 770085ade95..e9903dbe281 100644 --- a/src/app/teacher/teacher.module.ts +++ b/src/app/teacher/teacher.module.ts @@ -40,6 +40,8 @@ import { DiscourseRecentActivityComponent } from './discourse-recent-activity/di import { ShareRunCodeDialogComponent } from './share-run-code-dialog/share-run-code-dialog.component'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatListModule } from '@angular/material/list'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { SelectRunsControlsModule } from './select-runs-controls/select-runs-controls.module'; const materialModules = [ MatAutocompleteModule, @@ -55,6 +57,7 @@ const materialModules = [ MatNativeDateModule, MatProgressBarModule, MatRadioModule, + MatSlideToggleModule, MatSnackBarModule, MatTabsModule, MatTableModule, @@ -69,6 +72,7 @@ const materialModules = [ LibraryModule, materialModules, SharedModule, + SelectRunsControlsModule, TeacherRoutingModule, TimelineModule, ClipboardModule diff --git a/src/assets/wise5/authoringTool/peer-grouping/create-new-peer-grouping-dialog/create-new-peer-grouping-dialog.component.ts b/src/assets/wise5/authoringTool/peer-grouping/create-new-peer-grouping-dialog/create-new-peer-grouping-dialog.component.ts index e84a2b14506..5941ce34f45 100644 --- a/src/assets/wise5/authoringTool/peer-grouping/create-new-peer-grouping-dialog/create-new-peer-grouping-dialog.component.ts +++ b/src/assets/wise5/authoringTool/peer-grouping/create-new-peer-grouping-dialog/create-new-peer-grouping-dialog.component.ts @@ -34,7 +34,7 @@ export class CreateNewPeerGroupingDialogComponent extends AuthorPeerGroupingDial this.dialogRef.close(); }, () => { - this.snackBar.open($localize`Please Try Again (Error: Duplicate Tag)`); + this.snackBar.open($localize`Please try again (Error: duplicate tag).`); } ); } diff --git a/src/assets/wise5/common/datetime/datetime.ts b/src/assets/wise5/common/datetime/datetime.ts index 7b99a35ae8d..07f1d22025c 100644 --- a/src/assets/wise5/common/datetime/datetime.ts +++ b/src/assets/wise5/common/datetime/datetime.ts @@ -1,3 +1,6 @@ +import { formatDate } from '@angular/common'; +import { Run } from '../../../../app/domain/run'; + /** * Convert milliseconds since the epoch to a pretty printed date time * @param milliseconds the milliseconds since the epoch @@ -9,3 +12,9 @@ export function millisecondsToDateTime(milliseconds: number): string { const date = new Date(milliseconds); return `${date.toDateString()} ${date.toLocaleTimeString()}`; } + +export function runSpansDays(run: Run, localeID: string): boolean { + const startDay = formatDate(run.startTime, 'shortDate', localeID); + const endDay = formatDate(run.endTime, 'shortDate', localeID); + return startDay != endDay; +} diff --git a/src/messages.xlf b/src/messages.xlf index af64c3b8633..70fa851692f 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -2893,7 +2893,7 @@ Click "Cancel" to keep the invalid JSON open so you can fix it. src/app/teacher/teacher-run-list/teacher-run-list.component.html - 17 + 14 src/assets/wise5/authoringTool/addNode/choose-simulation/choose-simulation.component.html @@ -5405,7 +5405,7 @@ Click "Cancel" to keep the invalid JSON open so you can fix it. src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html - 72 + 83 @@ -6107,7 +6107,7 @@ Click "Cancel" to keep the invalid JSON open so you can fix it. src/app/teacher/teacher-run-list/teacher-run-list.component.html - 79 + 104 @@ -6225,6 +6225,14 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.src/app/notebook/notebook-report/notebook-report.component.html 42 + + src/app/teacher/run-menu/run-menu.component.html + 38 + + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 47 + Save @@ -7468,7 +7476,7 @@ Click "Cancel" to keep the invalid JSON open so you can fix it. src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html - 102 + 119 @@ -7507,7 +7515,7 @@ Click "Cancel" to keep the invalid JSON open so you can fix it. src/app/teacher/teacher-run-list/teacher-run-list.component.html - 40 + 31 @@ -7525,7 +7533,7 @@ Click "Cancel" to keep the invalid JSON open so you can fix it. src/app/teacher/teacher-run-list/teacher-run-list.component.html - 47 + 46 @@ -7534,10 +7542,6 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.src/app/student/student-run-list/student-run-list.component.html 33 - - src/app/teacher/teacher-run-list/teacher-run-list.component.html - 50 - Clear search @@ -8149,18 +8153,76 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.26 + + Archive + + src/app/teacher/run-menu/run-menu.component.html + 34 + + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 35 + + Run Settings src/app/teacher/run-menu/run-menu.component.ts - 71 + 68 Edit Classroom Unit Warning src/app/teacher/run-menu/run-menu.component.ts - 81 + 78 + + + + Successfully unit. + + src/app/teacher/run-menu/run-menu.component.ts + 104 + + + + Error unit. + + src/app/teacher/run-menu/run-menu.component.ts + 110 + + + + Undo + + src/app/teacher/run-menu/run-menu.component.ts + 120 + + + src/app/teacher/select-runs-controls/select-runs-controls.component.ts + 93 + + + + Action undone. + + src/app/teacher/run-menu/run-menu.component.ts + 132 + + + src/app/teacher/select-runs-controls/select-runs-controls.component.ts + 112 + + + + Error undoing action. + + src/app/teacher/run-menu/run-menu.component.ts + 135 + + + src/app/teacher/select-runs-controls/select-runs-controls.component.ts + 115 @@ -8366,6 +8428,132 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.305 + + Select + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 7 + + + src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html + 62 + + + + Select units + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 15 + + + + All + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 21 + + + src/assets/wise5/authoringTool/milestones-authoring/milestones-authoring.component.html + 114 + + + src/assets/wise5/authoringTool/milestones-authoring/milestones-authoring.component.html + 367 + + + src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.html + 458 + + + + None + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 22 + + + src/assets/wise5/authoringTool/peer-grouping/select-peer-grouping-option/select-peer-grouping-option.component.html + 18 + + + + Completed + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 23 + + + src/assets/wise5/classroomMonitor/classroomMonitorComponents/milestones/milestone-details/milestone-details.component.html + 80 + + + src/assets/wise5/themes/default/themeComponents/nodeStatusIcon/node-status-icon.component.html + 20 + + + + Running + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 24 + + + + Scheduled + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 25 + + + + selected + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 28 + + + + Archive selected units + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 33 + + + + Restore selected units + + src/app/teacher/select-runs-controls/select-runs-controls.component.html + 45 + + + + Successfully archived unit(s). + + src/app/teacher/select-runs-controls/select-runs-controls.component.ts + 91 + + + + Successfully restored unit(s). + + src/app/teacher/select-runs-controls/select-runs-controls.component.ts + 92 + + + + Error archiving unit(s). + + src/app/teacher/select-runs-controls/select-runs-controls.component.ts + 103 + + + + Error restoring unit(s). + + src/app/teacher/select-runs-controls/select-runs-controls.component.ts + 103 + + Share with Students @@ -8652,53 +8840,67 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.(Legacy Unit) src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html - 75 + 86 Last student login: src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html - 99 + 116 Teacher Tools src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.html - 110 + 127 Class Periods: src/app/teacher/teacher-run-list-item/teacher-run-list-item.component.ts - 81 + 82 - - Hey there! Looks like you haven't run any WISE units in your classes yet. + + Active src/app/teacher/teacher-run-list/teacher-run-list.component.html - 2 + 23 - - Select "Browse Units" to find titles to use with your students. + + Archived src/app/teacher/teacher-run-list/teacher-run-list.component.html - 3 + 24 - - Filter By Period + + Archived classroom units: src/app/teacher/teacher-run-list/teacher-run-list.component.html - 28 + 34 - - Total classroom units: + + Active classroom units: + + src/app/teacher/teacher-run-list/teacher-run-list.component.html + 35 + + + + completed + + src/app/teacher/teacher-run-list/teacher-run-list.component.html + 40 + + + + running src/app/teacher/teacher-run-list/teacher-run-list.component.html 43 @@ -8708,26 +8910,28 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.Clear filters src/app/teacher/teacher-run-list/teacher-run-list.component.html - 56 + 57 - - All Periods - - src/app/teacher/teacher-run-list/teacher-run-list.component.ts - 117 - + + Hey there! Looks like you don't have any active classroom units. - src/assets/wise5/classroomMonitor/classroomMonitorComponents/nodeProgress/nav-item/nav-item.component.ts - 350 + src/app/teacher/teacher-run-list/teacher-run-list.component.html + 65 + + + Browse the "Unit Library" to find titles to use with your students. - src/assets/wise5/classroomMonitor/classroomMonitorComponents/select-period/select-period.component.ts - 96 + src/app/teacher/teacher-run-list/teacher-run-list.component.html + 66 + + + Looks like you don't have any archived classroom units. - src/assets/wise5/services/teacherDataService.ts - 635 + src/app/teacher/teacher-run-list/teacher-run-list.component.html + 69 @@ -10301,21 +10505,6 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.96 - - All - - src/assets/wise5/authoringTool/milestones-authoring/milestones-authoring.component.html - 114 - - - src/assets/wise5/authoringTool/milestones-authoring/milestones-authoring.component.html - 367 - - - src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.html - 458 - - Add Milestone Satisfy Criteria @@ -11647,8 +11836,8 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.63 - - Please Try Again (Error: Duplicate Tag) + + Please try again (Error: duplicate tag). src/assets/wise5/authoringTool/peer-grouping/create-new-peer-grouping-dialog/create-new-peer-grouping-dialog.component.ts 37 @@ -11759,13 +11948,6 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.14 - - None - - src/assets/wise5/authoringTool/peer-grouping/select-peer-grouping-option/select-peer-grouping-option.component.html - 18 - - Logic: @@ -13035,17 +13217,6 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.25 - - Completed - - src/assets/wise5/classroomMonitor/classroomMonitorComponents/milestones/milestone-details/milestone-details.component.html - 80 - - - src/assets/wise5/themes/default/themeComponents/nodeStatusIcon/node-status-icon.component.html - 20 - - Not Completed @@ -13421,6 +13592,21 @@ Click "Cancel" to keep the invalid JSON open so you can fix it.240 + + All Periods + + src/assets/wise5/classroomMonitor/classroomMonitorComponents/nodeProgress/nav-item/nav-item.component.ts + 350 + + + src/assets/wise5/classroomMonitor/classroomMonitorComponents/select-period/select-period.component.ts + 96 + + + src/assets/wise5/services/teacherDataService.ts + 635 + + Period: