Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -133,6 +134,7 @@ export function initializeApp(
// Rich skills
RichSkillsLibraryComponent,
SkillCollectionsDisplayComponent,
LibraryExportComponent,

// Rich skill detail
RichSkillPublicComponent,
Expand Down
4 changes: 3 additions & 1 deletion ui/src/app/auth/auth-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export enum ButtonAction {
CollectionUpdate,
CollectionCreate,
CollectionPublish,
CollectionSkillsUpdate
CollectionSkillsUpdate,
LibraryExport
}

export const ActionByRoles = new Map<number, string[]>([
Expand All @@ -21,6 +22,7 @@ export const ActionByRoles = new Map<number, string[]>([
[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
2 changes: 2 additions & 0 deletions ui/src/app/navigation/abstract-search.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/app/navigation/commoncontrols.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
</svg>
<span class="m-button-x-text">Batch Import RSD<span class="t-type-lowercase">s</span></span>
</a>

<app-libraryexport></app-libraryexport>
</div>
<div>
<a class="m-buttonText m-buttonText-onBrand" routerLink="/search">
Expand Down
6 changes: 6 additions & 0 deletions ui/src/app/navigation/libraryexport.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<button class="m-button" type="button" (click)=onDownloadLibrary() *ngIf="canExportLibrary">
<svg class="m-button-x-icon t-icon">
<use xlink:href="/assets/images/svg-defs.svg#icon-download"></use>
</svg>
<span class="m-button-x-text">Export RSD Library</span>
</button>
70 changes: 70 additions & 0 deletions ui/src/app/navigation/libraryexport.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<LibraryExportComponent>
let activatedRoute: ActivatedRouteStubSpec

export function createComponent(T: Type<LibraryExportComponent>): Promise<void> {
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()
})
})
55 changes: 55 additions & 0 deletions ui/src/app/navigation/libraryexport.component.ts
Original file line number Diff line number Diff line change
@@ -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`)
}

}
34 changes: 34 additions & 0 deletions ui/src/app/richskill/service/rich-skill.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down
53 changes: 47 additions & 6 deletions ui/src/app/richskill/service/rich-skill.service.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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({
Expand Down Expand Up @@ -43,7 +43,7 @@ export class RichSkillService extends AbstractService {
return new PaginatedSkills(
body?.map(skill => skill) || [],
Number(headers.get("X-Total-Count"))
)
)
}))
}

Expand Down Expand Up @@ -149,6 +149,46 @@ export class RichSkillService extends AbstractService {
}))
}

libraryExport(): Observable<ApiTaskResult> {
return this.httpClient
.get<ApiTaskResult>(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<any> {
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,
Expand Down Expand Up @@ -195,4 +235,5 @@ export class RichSkillService extends AbstractService {
return body || []
}))
}

}
10 changes: 9 additions & 1 deletion ui/test/resource/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}

9 changes: 9 additions & 0 deletions ui/test/resource/mock-stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,15 @@ export class RichSkillServiceStub {
): Observable<PaginatedSkills> {
return of(createMockPaginatedSkills())
}

// noinspection JSUnusedGlobalSymbols,JSUnusedLocalSymbols
libraryExport(): Observable<string> {
return of(`x, y, z`)
}

getResultExportedLibrary(): Observable<any> {
return of("")
}
}

export let KeywordSearchServiceData = {
Expand Down