diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt index 73b395ba9..b4bbdb7a5 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt @@ -156,6 +156,9 @@ class CollectionController @Autowired constructor( fun getSkillsForCollectionCsv( @PathVariable uuid: String ): HttpEntity { + if (collectionRepository.findByUUID(uuid)!!.publishStatus() == PublishStatus.Draft && !oAuthHelper.hasRole(appConfig.roleAdmin)) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + } val task = CsvTask(collectionUuid = uuid) taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionCsv, task) return Task.processingResponse(task) diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CsvTaskProcessor.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/CsvTaskProcessor.kt index b87ca3fa0..aed3830e0 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/collection/CsvTaskProcessor.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CsvTaskProcessor.kt @@ -2,6 +2,7 @@ package edu.wgu.osmt.collection import com.github.sonus21.rqueue.annotation.RqueueListener import edu.wgu.osmt.config.AppConfig +import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.richskill.RichSkillAndCollections import edu.wgu.osmt.richskill.RichSkillCsvExport import edu.wgu.osmt.richskill.RichSkillDescriptorDao @@ -46,6 +47,7 @@ class CsvTaskProcessor { val csv = collectionRepository.findByUUID(csvTask.collectionUuid) ?.skills + ?.filter { PublishStatus.Archived != it.publishStatus() } ?.with(RichSkillDescriptorDao::collections) ?.map { RichSkillAndCollections.fromDao(it) } ?.let { RichSkillCsvExport(appConfig).toCsv(it) } diff --git a/ui/src/app/collection/detail/manage-collection.component.spec.ts b/ui/src/app/collection/detail/manage-collection.component.spec.ts index d60d20fb2..97eea56ca 100644 --- a/ui/src/app/collection/detail/manage-collection.component.spec.ts +++ b/ui/src/app/collection/detail/manage-collection.component.spec.ts @@ -10,7 +10,8 @@ import { of } from "rxjs" import { createMockCollection, createMockPaginatedSkills, - createMockSkillSummary + createMockSkillSummary, + csvContent } from "../../../../test/resource/mock-data" import { AuthServiceStub, @@ -30,6 +31,7 @@ import { ApiCollection } from "../ApiCollection" import { CollectionService } from "../service/collection.service" import { ManageCollectionComponent } from "./manage-collection.component" import {AuthService} from "../../auth/auth-service"; +import * as FileSaver from "file-saver" @Component({ @@ -276,7 +278,7 @@ describe("ManageCollectionComponent", () => { // Assert expect(actions).toBeTruthy() - expect(actions.length).toEqual(4) + expect(actions.length).toEqual(5) let action = actions[0] expect(action.label).toEqual("Add RSDs to This Collection") @@ -490,4 +492,31 @@ describe("ManageCollectionComponent", () => { expect(component.showingMultipleConfirm).toBeFalsy() expect(component.apiSearch).toBeFalsy() }) + + it("generateCsv should call getCsv and loader", () => { + const spyCollectionService = spyOn(component["collectionService"], "requestCollectionSkillsCsv").and.callThrough() + const spyLoaderSubject = spyOn(component["toastService"].loaderSubject, "next") + component.generateCsv("My collection") + expect(spyCollectionService).toHaveBeenCalled() + expect(spyLoaderSubject).toHaveBeenCalledWith(true) + }) + + + it("getCsv should call getCsvTaskResultsIfComplete", () => { + const collection = { + uuid: "fc0a65a6-facd-4f9d-b590-cfecbfe706ad", + name: "My Collection" + } + const spyCollectionService = spyOn(component["collectionService"], "getCsvTaskResultsIfComplete").and.returnValue(of(csvContent)) + const spySaveCsv = spyOn(component, "saveCsv") + component.getCsv(collection.uuid, collection.name) + expect(spyCollectionService).toHaveBeenCalledWith(collection.uuid) + expect(spySaveCsv).toHaveBeenCalledWith(csvContent.body, collection.name) + }) + + it("saveCSV should call FileSaver", () => { + const spySaveAS = spyOn(FileSaver, "saveAs") + component.saveCsv(csvContent.body, "My Collection") + expect(spySaveAS).toHaveBeenCalled() + }) }) diff --git a/ui/src/app/collection/detail/manage-collection.component.ts b/ui/src/app/collection/detail/manage-collection.component.ts index e5abca46f..552cbe93c 100644 --- a/ui/src/app/collection/detail/manage-collection.component.ts +++ b/ui/src/app/collection/detail/manage-collection.component.ts @@ -1,4 +1,4 @@ -import {Component, OnInit, ViewChild} from "@angular/core" +import {Component, Inject, LOCALE_ID, OnInit, ViewChild} from "@angular/core" import {ApiCollection, ApiCollectionUpdate} from "../ApiCollection" import {ApiSearch, ApiSkillListUpdate} from "../../richskill/service/rich-skill-search.service" import {ActivatedRoute, Router} from "@angular/router" @@ -11,11 +11,15 @@ import {SvgHelper, SvgIcon} from "../../core/SvgHelper" import {TableActionDefinition} from "../../table/skills-library-table/has-action-definitions" import {determineFilters, PublishStatus} from "../../PublishStatus" import {ApiSkillSummary} from "../../richskill/ApiSkillSummary" -import {Observable, Subject} from "rxjs" +import {Observable, of, Subject, throwError} from "rxjs" import {TableActionBarComponent} from "../../table/skills-library-table/table-action-bar.component" import {Title} from "@angular/platform-browser"; import {AuthService} from "../../auth/auth-service"; import {ButtonAction} from "../../auth/auth-roles"; +import {formatDate} from "@angular/common" +import * as FileSaver from "file-saver" +import {ITaskResult} from "../../task/ApiTaskResult" +import {delay, retryWhen, switchMap} from "rxjs/operators" @Component({ selector: "app-manage-collection", @@ -30,6 +34,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On editIcon = SvgHelper.path(SvgIcon.EDIT) publishIcon = SvgHelper.path(SvgIcon.PUBLISH) + downloadIcon = SvgHelper.path(SvgIcon.DOWNLOAD) archiveIcon = SvgHelper.path(SvgIcon.ARCHIVE) unarchiveIcon = SvgHelper.path(SvgIcon.UNARCHIVE) addIcon = SvgHelper.path(SvgIcon.ADD) @@ -60,6 +65,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On protected route: ActivatedRoute, protected titleService: Title, protected authService: AuthService, + @Inject(LOCALE_ID) protected locale: string ) { super(router, richSkillService, toastService, authService) } @@ -136,6 +142,38 @@ export class ManageCollectionComponent extends SkillsListComponent implements On return false } + generateCsv(collectionName: string): void { + this.collectionService.requestCollectionSkillsCsv(this.uuidParam ?? "") + .subscribe((taskStarted: ITaskResult) => { + this.toastService.loaderSubject.next(true) + this.getCsv(taskStarted.uuid ?? "", collectionName) + }) + } + + getCsv(uuid: string, collectionName: string): void { + this.collectionService.getCsvTaskResultsIfComplete(uuid) + .pipe( + retryWhen(errors => errors.pipe( + switchMap((error) => { + if (error.status === 404) { + return of(error.status) + } + return throwError(error) + }), + delay(1000), + ))) + .subscribe(response => { + this.saveCsv(response.body, collectionName) + }) + } + + saveCsv(body: string, collectionName: string): void { + const blob = new Blob([body], {type: "text/csv;charset=utf-8;"}) + const date = formatDate(new Date(), "yyyy-MM-dd", this.locale) + FileSaver.saveAs(blob, `RSD Skills - ${collectionName} - ${date}.csv`) + this.toastService.loaderSubject.next(false) + } + actionDefinitions(): TableActionDefinition[] { const actions = [ new TableActionDefinition({ @@ -187,6 +225,15 @@ export class ManageCollectionComponent extends SkillsListComponent implements On }) ) } + + if ((this.collection?.status === PublishStatus.Draft || this.collection?.status === PublishStatus.Published) && this.authService.isEnabledByRoles(ButtonAction.LibraryExport)) { + actions.push(new TableActionDefinition({ + label: "Download CSV", + icon: this.downloadIcon, + callback: () => this.generateCsv(this.collection?.name ?? ""), + visible: () => true + })) + } return actions } diff --git a/ui/test/resource/mock-data.ts b/ui/test/resource/mock-data.ts index 769f22bde..7d9c29731 100644 --- a/ui/test/resource/mock-data.ts +++ b/ui/test/resource/mock-data.ts @@ -292,3 +292,5 @@ export const mockTaskResultForExportSearch: ApiTaskResult = { id: "/api/results/batch/77574cd6-933b-4ee0-a106-afadb7a3a292" } +export const csvContent = {body: "value1,value2,value3"} +