diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index 8383e482a..f81052186 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -96,6 +96,7 @@ import {LoginComponent} from "./auth/login.component" import {LogoutComponent} from "./auth/logout.component" import {NgIdleKeepaliveModule} from "@ng-idle/keepalive" import {LabelWithSelectComponent} from "./table/skills-library-table/label-with-select.component" +import {LibraryExportComponent} from "./navigation/libraryexport.component" export function initializeApp( appConfig: AppConfig, @@ -133,6 +134,7 @@ export function initializeApp( // Rich skills RichSkillsLibraryComponent, SkillCollectionsDisplayComponent, + LibraryExportComponent, // Rich skill detail RichSkillPublicComponent, diff --git a/ui/src/app/auth/auth-roles.ts b/ui/src/app/auth/auth-roles.ts index e3269d11c..b8f5e1aca 100644 --- a/ui/src/app/auth/auth-roles.ts +++ b/ui/src/app/auth/auth-roles.ts @@ -10,7 +10,8 @@ export enum ButtonAction { CollectionUpdate, CollectionCreate, CollectionPublish, - CollectionSkillsUpdate + CollectionSkillsUpdate, + LibraryExport } export const ActionByRoles = new Map([ @@ -21,6 +22,7 @@ export const ActionByRoles = new Map([ [ButtonAction.CollectionCreate, [OSMT_ADMIN, OSMT_CURATOR]], [ButtonAction.CollectionPublish, [OSMT_ADMIN]], [ButtonAction.CollectionSkillsUpdate, [OSMT_ADMIN]], + [ButtonAction.LibraryExport, [OSMT_ADMIN]] ]) //TODO migrate AuthServiceWgu & AuthService.hasRole & isEnabledByRoles into a singleton here. HDN Sept 15, 2022 diff --git a/ui/src/app/navigation/abstract-search.component.ts b/ui/src/app/navigation/abstract-search.component.ts index 24b335bda..0214d9842 100644 --- a/ui/src/app/navigation/abstract-search.component.ts +++ b/ui/src/app/navigation/abstract-search.component.ts @@ -16,6 +16,7 @@ export class AbstractSearchComponent { canCollectionCreate: boolean = false canCollectionPublish: boolean = false canCollectionSkillsUpdate: boolean = false + canExportLibrary: boolean = false constructor(protected searchService: SearchService, protected route: ActivatedRoute, protected authService: AuthService) { this.searchService.searchQuery$.subscribe(apiSearch => { @@ -41,6 +42,7 @@ export class AbstractSearchComponent { this.canCollectionCreate = this.authService.isEnabledByRoles(ButtonAction.CollectionCreate); this.canCollectionPublish = this.authService.isEnabledByRoles(ButtonAction.CollectionPublish); this.canCollectionSkillsUpdate = this.authService.isEnabledByRoles(ButtonAction.CollectionSkillsUpdate); + this.canExportLibrary = this.authService.isEnabledByRoles(ButtonAction.LibraryExport); } clearSearch(): boolean { diff --git a/ui/src/app/navigation/commoncontrols.component.html b/ui/src/app/navigation/commoncontrols.component.html index 12f5ab79c..a1a8d90ef 100644 --- a/ui/src/app/navigation/commoncontrols.component.html +++ b/ui/src/app/navigation/commoncontrols.component.html @@ -65,7 +65,7 @@ Batch Import RSDs - +
diff --git a/ui/src/app/navigation/libraryexport.component.html b/ui/src/app/navigation/libraryexport.component.html new file mode 100644 index 000000000..c840b94db --- /dev/null +++ b/ui/src/app/navigation/libraryexport.component.html @@ -0,0 +1,6 @@ + diff --git a/ui/src/app/navigation/libraryexport.component.spec.ts b/ui/src/app/navigation/libraryexport.component.spec.ts new file mode 100644 index 000000000..50eeb193e --- /dev/null +++ b/ui/src/app/navigation/libraryexport.component.spec.ts @@ -0,0 +1,70 @@ +/* tslint:disable:no-string-literal */ +import { Type } from "@angular/core" +import { async, ComponentFixture, TestBed } from "@angular/core/testing" +import { ActivatedRoute, Router } from "@angular/router" +import * as FileSaver from "file-saver" +import { ActivatedRouteStubSpec } from "test/util/activated-route-stub.spec" +import { AuthService } from "../auth/auth-service" +import { RichSkillService } from "../richskill/service/rich-skill.service" +import { AuthServiceStub, RichSkillServiceStub } from "../../../test/resource/mock-stubs" +import { LibraryExportComponent } from "./libraryexport.component" +import { of } from "rxjs" +import { apiTaskResultForCSV } from "../../../test/resource/mock-data" + + +let component: LibraryExportComponent +let fixture: ComponentFixture +let activatedRoute: ActivatedRouteStubSpec + +export function createComponent(T: Type): Promise { + fixture = TestBed.createComponent(T) + component = fixture.componentInstance + + // 1st change detection triggers ngOnInit which gets a hero + fixture.detectChanges() + + return fixture.whenStable().then(() => { + // 2nd change detection displays the async-fetched hero + fixture.detectChanges() + }) +} + +describe("LibraryExportComponent", () => { + beforeEach(async(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + activatedRoute = new ActivatedRouteStubSpec() + + TestBed.configureTestingModule({ + declarations: [ + LibraryExportComponent + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: RichSkillService, useClass: RichSkillServiceStub }, + { provide: Router, useValue: routerSpy } + ] + }) + .compileComponents() + createComponent(LibraryExportComponent) + spyOn(FileSaver, "saveAs").and.stub() + })) + + it("should be created", () => { + expect(LibraryExportComponent).toBeTruthy() + }) + + it("Should call export library with result", () => { + const service = TestBed.inject(RichSkillService) + const spy = spyOn(service, "libraryExport").and.returnValue(of(apiTaskResultForCSV)) + component.onDownloadLibrary() + expect(spy).toHaveBeenCalled() + // expect(FileSaver.saveAs).toHaveBeenCalled() + }) + + + it("download as csv file", () => { + component["downloadAsCsvFile"]("value1,value2,value3") + expect(FileSaver.saveAs).toHaveBeenCalled() + }) +}) diff --git a/ui/src/app/navigation/libraryexport.component.ts b/ui/src/app/navigation/libraryexport.component.ts new file mode 100644 index 000000000..29ac42841 --- /dev/null +++ b/ui/src/app/navigation/libraryexport.component.ts @@ -0,0 +1,55 @@ +import {Component, OnInit, LOCALE_ID, Inject} from "@angular/core" +import {formatDate} from "@angular/common" +import {SearchService} from "../search/search.service" +import {RichSkillService} from "../richskill/service/rich-skill.service" +import {ActivatedRoute} from "@angular/router" +import {AuthService} from "../auth/auth-service" +import * as FileSaver from "file-saver" +import {SvgHelper, SvgIcon} from "../core/SvgHelper" +import {AbstractSearchComponent} from "./abstract-search.component" +import {ApiTaskResult} from "../task/ApiTaskResult" +import {ToastService} from "../toast/toast.service" + +@Component({ + selector: "app-libraryexport", + templateUrl: "./libraryexport.component.html" +}) +export class LibraryExportComponent extends AbstractSearchComponent implements OnInit { + + searchIcon = SvgHelper.path(SvgIcon.SEARCH) + dismissIcon = SvgHelper.path(SvgIcon.DISMISS) + + constructor( + protected searchService: SearchService, + protected route: ActivatedRoute, + protected authService: AuthService, + protected richSkillService: RichSkillService, + @Inject(LOCALE_ID) protected locale: string, + private toastService: ToastService + ) { + super(searchService, route, authService) + } + + ngOnInit(): void { + } + + onDownloadLibrary(): void { + this.toastService.loaderSubject.next(true) + this.richSkillService.libraryExport() + .subscribe((apiTaskResult: ApiTaskResult) => { + this.richSkillService.getResultExportedLibrary(apiTaskResult.id.slice(1)).subscribe( + response => { + this.downloadAsCsvFile(response.body) + this.toastService.loaderSubject.next(false) + } + ) + }) + } + + private downloadAsCsvFile(csv: string): void { + const blob = new Blob([csv], {type: "text/csv;charset=utf-8;"}) + const date = formatDate(new Date(), "yyyy-MM-dd", this.locale) + FileSaver.saveAs(blob, `RSD Library - OSMT ${date}.csv`) + } + +} diff --git a/ui/src/app/richskill/service/rich-skill.service.spec.ts b/ui/src/app/richskill/service/rich-skill.service.spec.ts index 48fe51a6b..29df65ff5 100644 --- a/ui/src/app/richskill/service/rich-skill.service.spec.ts +++ b/ui/src/app/richskill/service/rich-skill.service.spec.ts @@ -6,6 +6,7 @@ import { HttpClientTestingModule, HttpTestingController } from "@angular/common/ import { fakeAsync, TestBed, tick } from "@angular/core/testing" import { Router } from "@angular/router" import { + apiTaskResultForCSV, createMockAuditLog, createMockBatchResult, createMockPaginatedSkills, @@ -25,6 +26,7 @@ import { ApiSkillSummary } from "../ApiSkillSummary" import { ApiSkillUpdate } from "../ApiSkillUpdate" import { ApiSearch, PaginatedSkills } from "./rich-skill-search.service" import { RichSkillService } from "./rich-skill.service" +import { ApiTaskResult } from "../../task/ApiTaskResult" // An example of how to test a service @@ -306,6 +308,38 @@ describe("RichSkillService", () => { }) }) + it("libraryExport should return", fakeAsync(() => { + RouterData.commands = [] + + // Act + const result$ = testService.libraryExport() + + tick(ASYNC_WAIT_PERIOD) + // Assert + result$.subscribe((data: ApiTaskResult) => { + expect(RouterData.commands).toEqual([]) // No Errors + }) + + const req = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + "/api/export/library") + expect(req.request.method).toEqual("GET") + expect(req.request.headers.get("Accept")).toEqual("application/json") + req.flush(result$) + })) + + it("getResultExportedLibrary", fakeAsync(() => { + { + const taskResult = apiTaskResultForCSV + const path = "api/results/text/" + apiTaskResultForCSV.uuid + const path2 = taskResult.id.slice(1) + testService.getResultExportedLibrary(path2).subscribe() + const req1 = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + "/" + path) + expect(req1.request.method).toEqual("GET") + req1.flush("csv") + + tick(ASYNC_WAIT_PERIOD) + } + })) + it("publishSkillsWithResult should return", fakeAsync(() => { // Arrange RouterData.commands = [] diff --git a/ui/src/app/richskill/service/rich-skill.service.ts b/ui/src/app/richskill/service/rich-skill.service.ts index 10996bd2e..925bd9655 100644 --- a/ui/src/app/richskill/service/rich-skill.service.ts +++ b/ui/src/app/richskill/service/rich-skill.service.ts @@ -1,8 +1,8 @@ import {Injectable} from "@angular/core" -import {HttpClient, HttpHeaders} from "@angular/common/http" -import {Observable} from "rxjs" +import {HttpClient, HttpHeaders, HttpResponse} from "@angular/common/http" +import {Observable, of, throwError} from "rxjs" import {ApiAuditLog, ApiSkill, ApiSortOrder, IAuditLog, ISkill} from "../ApiSkill" -import {map, share} from "rxjs/operators" +import {delay, map, retryWhen, share, switchMap} from "rxjs/operators" import {AbstractService} from "../../abstract.service" import {ApiSkillUpdate} from "../ApiSkillUpdate" import {AuthService} from "../../auth/auth-service" @@ -11,8 +11,8 @@ import {PublishStatus} from "../../PublishStatus" import {ApiBatchResult} from "../ApiBatchResult" import {ApiTaskResult, ITaskResult} from "../../task/ApiTaskResult" import {ApiSkillSummary, ISkillSummary} from "../ApiSkillSummary" -import {Router} from "@angular/router"; -import {Location} from "@angular/common"; +import {Router} from "@angular/router" +import {Location} from "@angular/common" @Injectable({ @@ -43,7 +43,7 @@ export class RichSkillService extends AbstractService { return new PaginatedSkills( body?.map(skill => skill) || [], Number(headers.get("X-Total-Count")) - ) + ) })) } @@ -149,6 +149,46 @@ export class RichSkillService extends AbstractService { })) } + libraryExport(): Observable { + return this.httpClient + .get(this.buildUrl("api/export/library"), { + headers: this.wrapHeaders(new HttpHeaders({ + Accept: "application/json" + } + )), + observe: "response" + }) + .pipe(share()) + .pipe(map(({body}) => new ApiTaskResult(this.safeUnwrapBody(body, "unwrap failure")))) + } + + /** + * Check if result exported with libraryExport() is ready if not check again every 1000 milliseconds. + * @param url Url to get RSD library exported as csv + * @param pollIntervalMs Milliseconds to retry request + */ + getResultExportedLibrary(url: string, pollIntervalMs: number = 1000): Observable { + return this.httpClient + .get(this.buildUrl(url), { + headers: this.wrapHeaders(new HttpHeaders({ + Accept: "text/csv" + } + )), + responseType: "text", + observe: "response" + }) + .pipe( + retryWhen(errors => errors.pipe( + switchMap((error) => { + if (error.status === 404) { + return of(error.status) + } + return throwError(error) + }), + delay(pollIntervalMs), + ))) + } + publishSkillsWithResult( apiSearch: ApiSearch, newStatus: PublishStatus = PublishStatus.Published, @@ -195,4 +235,5 @@ export class RichSkillService extends AbstractService { return body || [] })) } + } diff --git a/ui/test/resource/mock-data.ts b/ui/test/resource/mock-data.ts index 2761e0f00..949858e89 100644 --- a/ui/test/resource/mock-data.ts +++ b/ui/test/resource/mock-data.ts @@ -16,7 +16,7 @@ import { import { ApiCollectionSummary, ICollectionSummary, ISkillSummary } from "../../src/app/richskill/ApiSkillSummary" import { ApiReferenceListUpdate, IRichSkillUpdate, IStringListUpdate } from "../../src/app/richskill/ApiSkillUpdate" import { PaginatedCollections, PaginatedSkills } from "../../src/app/richskill/service/rich-skill-search.service" -import { ITaskResult } from "../../src/app/task/ApiTaskResult" +import { ApiTaskResult, ITaskResult } from "../../src/app/task/ApiTaskResult" // Add mock data here. // For more examples, see https://github.com/WGU-edu/ema-eval-ui/blob/develop/src/app/admin/pages/edit-user/edit-user.component.spec.ts @@ -277,3 +277,11 @@ export function createMockCollectionUpdate(creationDate: Date, updateDate: Date, skills: createMockStringListUpdate() } } + +export const apiTaskResultForCSV: ApiTaskResult = { + uuid: "c2624480-4935-4362-bc71-86e052dcb852", + status: "Processing", + contentType: "text/csv", + id: "/api/results/text/c2624480-4935-4362-bc71-86e052dcb852" +} + diff --git a/ui/test/resource/mock-stubs.ts b/ui/test/resource/mock-stubs.ts index 569614059..41708d34a 100644 --- a/ui/test/resource/mock-stubs.ts +++ b/ui/test/resource/mock-stubs.ts @@ -336,6 +336,15 @@ export class RichSkillServiceStub { ): Observable { return of(createMockPaginatedSkills()) } + + // noinspection JSUnusedGlobalSymbols,JSUnusedLocalSymbols + libraryExport(): Observable { + return of(`x, y, z`) + } + + getResultExportedLibrary(): Observable { + return of("") + } } export let KeywordSearchServiceData = {