diff --git a/api/pom.xml b/api/pom.xml index 880252993..b1d60eb56 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -250,6 +250,16 @@ opencsv 5.7.1 + + org.apache.poi + poi + 5.2.3 + + + org.apache.poi + poi-ooxml + 5.2.3 + org.springframework.boot spring-boot-starter-actuator diff --git a/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt b/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt index a09095086..ccec455bb 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/ImportCommandRunner.kt @@ -1,8 +1,8 @@ package edu.wgu.osmt -import edu.wgu.osmt.csv.BatchImportRichSkill -import edu.wgu.osmt.csv.BlsImport -import edu.wgu.osmt.csv.OnetImport +import edu.wgu.osmt.io.csv.BatchImportRichSkill +import edu.wgu.osmt.io.csv.BlsImport +import edu.wgu.osmt.io.csv.OnetImport import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired diff --git a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt index ae0088a78..72cac367a 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt @@ -6,6 +6,8 @@ object RoutePaths { const val EXPORT = "$API/export" const val SEARCH_SKILLS = "$SEARCH_PATH/skills" const val EXPORT_LIBRARY = "$EXPORT/library" + const val EXPORT_LIBRARY_CSV = "$EXPORT_LIBRARY/csv" + const val EXPORT_LIBRARY_XLSX = "$EXPORT_LIBRARY/xlsx" const val SEARCH_SIMILAR_SKILLS = "$SEARCH_SKILLS/similarity" const val SEARCH_SIMILARITIES = "$SEARCH_SKILLS/similarities" const val SEARCH_COLLECTIONS = "$SEARCH_PATH/collections" @@ -16,9 +18,12 @@ object RoutePaths { const val SKILLS_FILTER = "$SKILLS_PATH/filter" const val SKILL_PUBLISH = "$SKILLS_PATH/publish" const val SKILL_DETAIL = "$SKILLS_PATH/{uuid}" + const val SKILL_DETAIL_XLSX = "$SKILLS_PATH/{uuid}/xlsx" const val SKILL_UPDATE = "$SKILL_DETAIL/update" const val SKILL_AUDIT_LOG = "$SKILL_DETAIL/log" const val EXPORT_SKILLS = "$EXPORT/skills" + const val EXPORT_SKILLS_CSV = "$EXPORT_SKILLS/csv" + const val EXPORT_SKILLS_XLSX = "$EXPORT_SKILLS/xlsx" const val COLLECTIONS_PATH = "$API/collections" @@ -31,12 +36,14 @@ object RoutePaths { const val COLLECTION_SKILLS = "$COLLECTION_DETAIL/skills" const val COLLECTION_AUDIT_LOG = "$COLLECTION_DETAIL/log" const val COLLECTION_CSV = "$COLLECTION_DETAIL/csv" + const val COLLECTION_XLSX = "$COLLECTION_DETAIL/xlsx" const val COLLECTION_REMOVE = "$COLLECTION_DETAIL/remove" const val WORKSPACE_PATH = "$API/workspace" const val TASKS_PATH = "$API/results" const val TASK_DETAIL_TEXT = "$TASKS_PATH/text/{uuid}" + const val TASK_DETAIL_MEDIA = "$TASKS_PATH/media/{uuid}" const val TASK_DETAIL_BATCH = "$TASKS_PATH/batch/{uuid}" const val TASK_DETAIL_SKILLS = "$TASKS_PATH/skills/{uuid}" diff --git a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt index 06e1549ff..c62568f79 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/auditlog/AuditLogUtils.kt @@ -4,7 +4,7 @@ import edu.wgu.osmt.collection.CollectionRepository import edu.wgu.osmt.collection.CollectionTable import edu.wgu.osmt.collection.diff import edu.wgu.osmt.config.AppConfig -import edu.wgu.osmt.csv.BatchImportRichSkill +import edu.wgu.osmt.io.csv.BatchImportRichSkill import edu.wgu.osmt.richskill.RichSkillDescriptorTable import edu.wgu.osmt.richskill.RichSkillRepository import edu.wgu.osmt.richskill.diff 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 60986a98a..512e906c4 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionController.kt @@ -26,6 +26,7 @@ import edu.wgu.osmt.task.Task import edu.wgu.osmt.task.TaskMessageService import edu.wgu.osmt.task.TaskResult import edu.wgu.osmt.task.UpdateCollectionSkillsTask +import edu.wgu.osmt.task.XlsxTask import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpEntity import org.springframework.http.HttpStatus @@ -185,6 +186,18 @@ class CollectionController @Autowired constructor( return Task.processingResponse(task) } + @GetMapping(RoutePaths.COLLECTION_XLSX, produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + fun getSkillsForCollectionXlsx( + @PathVariable uuid: String + ): HttpEntity { + if (collectionRepository.findByUUID(uuid)!!.status == PublishStatus.Draft && !oAuthHelper.hasRole(appConfig.roleAdmin)) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + } + val task = XlsxTask(collectionUuid = uuid) + taskMessageService.enqueueJob(TaskMessageService.skillsForCollectionXlsx, task) + return Task.processingResponse(task) + } + @DeleteMapping(RoutePaths.COLLECTION_REMOVE, produces = [MediaType.APPLICATION_JSON_VALUE]) fun removeCollection( @PathVariable uuid: String diff --git a/api/src/main/kotlin/edu/wgu/osmt/io/common/TabularResource.kt b/api/src/main/kotlin/edu/wgu/osmt/io/common/TabularResource.kt new file mode 100644 index 000000000..edbef5ae8 --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/io/common/TabularResource.kt @@ -0,0 +1,12 @@ +package edu.wgu.osmt.io.common + +interface TabularResource, T> { + + /** + * Defines the columns of this table in their desired order. + */ + fun columnTranslations(data: List): Array + +} + +interface TabColumn diff --git a/api/src/main/kotlin/edu/wgu/osmt/io/common/TabularTask.kt b/api/src/main/kotlin/edu/wgu/osmt/io/common/TabularTask.kt new file mode 100644 index 000000000..0e4f33a85 --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/io/common/TabularTask.kt @@ -0,0 +1,26 @@ +package edu.wgu.osmt.io.common + +import edu.wgu.osmt.collection.CollectionRepository +import edu.wgu.osmt.config.AppConfig +import edu.wgu.osmt.richskill.RichSkillRepository +import edu.wgu.osmt.task.Task +import edu.wgu.osmt.task.TaskMessageService +import org.springframework.beans.factory.annotation.Autowired + +abstract class TabularTask { + @Autowired + lateinit var taskMessageService: TaskMessageService + + @Autowired + lateinit var collectionRepository: CollectionRepository + + @Autowired + lateinit var richSkillRepository: RichSkillRepository + + @Autowired + lateinit var appConfig: AppConfig + + abstract fun tabularSkillsInCollectionProcessor(task: T) + + abstract fun tabularSkillsInFullLibraryProcessor(task: T) +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/csv/BatchImportRichSkill.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/BatchImportRichSkill.kt similarity index 99% rename from api/src/main/kotlin/edu/wgu/osmt/csv/BatchImportRichSkill.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/BatchImportRichSkill.kt index d11828605..8ed3ea5c7 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/csv/BatchImportRichSkill.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/BatchImportRichSkill.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import com.opencsv.bean.CsvBindByName import edu.wgu.osmt.collection.CollectionDao diff --git a/api/src/main/kotlin/edu/wgu/osmt/csv/BlsImport.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/BlsImport.kt similarity index 98% rename from api/src/main/kotlin/edu/wgu/osmt/csv/BlsImport.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/BlsImport.kt index 96d432f0f..4492a1a28 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/csv/BlsImport.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/BlsImport.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import com.opencsv.bean.CsvBindByName import edu.wgu.osmt.config.AppConfig @@ -10,7 +10,6 @@ import edu.wgu.osmt.richskill.RichSkillRepository import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.Logger import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component /** diff --git a/api/src/main/kotlin/edu/wgu/osmt/csv/CsvBuilder.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvBuilder.kt similarity index 93% rename from api/src/main/kotlin/edu/wgu/osmt/csv/CsvBuilder.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvBuilder.kt index 31ab4b323..30639fd62 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/csv/CsvBuilder.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvBuilder.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv /** * DSL style entry-point to build a CsvResource. This approach should be used if a csv file is simple in structure @@ -48,13 +48,13 @@ class CsvColumnBuilder { } class CsvConfigBuilder { - var delimeter: Char = CsvConfig.delimeter + var delimiter: Char = CsvConfig.delimiter var quoteChar: Char = CsvConfig.quoteChar var escapeChar: Char = CsvConfig.escapeChar var lineEnd: String = CsvConfig.lineEnd var includeHeader: Boolean = CsvConfig.includeHeader fun build(): CsvConfig { - return CsvConfig(delimeter, quoteChar, escapeChar, lineEnd, includeHeader) + return CsvConfig(delimiter, quoteChar, escapeChar, lineEnd, includeHeader) } } \ No newline at end of file diff --git a/api/src/main/kotlin/edu/wgu/osmt/csv/CsvImport.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvImport.kt similarity index 98% rename from api/src/main/kotlin/edu/wgu/osmt/csv/CsvImport.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvImport.kt index ae54b92ce..9b6deee34 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/csv/CsvImport.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvImport.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import com.opencsv.bean.CsvToBeanBuilder import edu.wgu.osmt.jobcode.JobCodeBreakout diff --git a/api/src/main/kotlin/edu/wgu/osmt/csv/CsvResource.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvResource.kt similarity index 79% rename from api/src/main/kotlin/edu/wgu/osmt/csv/CsvResource.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvResource.kt index 3fb5c94b3..99317b246 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/csv/CsvResource.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvResource.kt @@ -1,15 +1,17 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import com.opencsv.CSVWriter +import edu.wgu.osmt.io.common.TabularResource +import edu.wgu.osmt.io.common.TabColumn import java.io.StringWriter import java.io.Writer -abstract class CsvResource(val debugName: String) { +abstract class CsvResource(val debugName: String) : TabularResource, T> { /** * Defines the columns of this csv in their desired order. */ - abstract fun columnTranslations(data: List): Array> + abstract override fun columnTranslations(data: List): Array> /** * Override if opencsv defaults are not desired @@ -40,7 +42,7 @@ abstract class CsvResource(val debugName: String) { val config = configureCsv() return CSVWriter(writer, - config.delimeter, + config.delimiter, config.quoteChar, config.escapeChar, config.lineEnd @@ -79,20 +81,20 @@ abstract class CsvResource(val debugName: String) { data class CsvColumn( val name: String = "", val translate: (T) -> String -) +): TabColumn /** * Configure the global attributes of a csv export */ data class CsvConfig( - val delimeter: Char = CsvConfig.delimeter, - val quoteChar: Char = CsvConfig.quoteChar, - val escapeChar: Char = CsvConfig.escapeChar, - val lineEnd: String = CsvConfig.lineEnd, - val includeHeader: Boolean = CsvConfig.includeHeader + val delimiter: Char = Defaults.delimiter, + val quoteChar: Char = Defaults.quoteChar, + val escapeChar: Char = Defaults.escapeChar, + val lineEnd: String = Defaults.lineEnd, + val includeHeader: Boolean = Defaults.includeHeader ) { - companion object Defaults { // Allows the default values to be shared with it's builder - val delimeter: Char = CSVWriter.DEFAULT_SEPARATOR + companion object Defaults { // Allows the default values to be shared with its builder + val delimiter: Char = CSVWriter.DEFAULT_SEPARATOR val quoteChar: Char = CSVWriter.DEFAULT_QUOTE_CHARACTER val escapeChar: Char = CSVWriter.DEFAULT_ESCAPE_CHARACTER val lineEnd: String = CSVWriter.DEFAULT_LINE_END diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CsvTaskProcessor.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvTaskProcessor.kt similarity index 57% rename from api/src/main/kotlin/edu/wgu/osmt/collection/CsvTaskProcessor.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvTaskProcessor.kt index aed3830e0..b8f8847a3 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/collection/CsvTaskProcessor.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/CsvTaskProcessor.kt @@ -1,19 +1,16 @@ -package edu.wgu.osmt.collection +package edu.wgu.osmt.io.csv import com.github.sonus21.rqueue.annotation.RqueueListener -import edu.wgu.osmt.config.AppConfig import edu.wgu.osmt.db.PublishStatus +import edu.wgu.osmt.io.common.TabularTask import edu.wgu.osmt.richskill.RichSkillAndCollections -import edu.wgu.osmt.richskill.RichSkillCsvExport import edu.wgu.osmt.richskill.RichSkillDescriptorDao -import edu.wgu.osmt.richskill.RichSkillRepository import edu.wgu.osmt.task.CsvTask import edu.wgu.osmt.task.TaskMessageService import edu.wgu.osmt.task.TaskStatus import org.jetbrains.exposed.dao.with import org.slf4j.Logger import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Profile import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional @@ -21,41 +18,28 @@ import org.springframework.transaction.annotation.Transactional @Component @Profile("apiserver") @Transactional -class CsvTaskProcessor { +class CsvTaskProcessor : TabularTask() { val logger: Logger = LoggerFactory.getLogger(CsvTaskProcessor::class.java) - @Autowired - lateinit var taskMessageService: TaskMessageService - - @Autowired - lateinit var collectionRepository: CollectionRepository - - @Autowired - lateinit var richSkillRepository: RichSkillRepository - - @Autowired - lateinit var appConfig: AppConfig - @RqueueListener( value = [TaskMessageService.skillsForCollectionCsv], deadLetterQueueListenerEnabled = "true", deadLetterQueue = TaskMessageService.deadLetters, concurrency = "1" ) - fun csvSkillsInCollectionProcessor(csvTask: CsvTask) { - logger.info("Started processing task id: ${csvTask.uuid}") + override fun tabularSkillsInCollectionProcessor(task: CsvTask) { + logger.info("Started processing task id: ${task.uuid}") - val csv = collectionRepository.findByUUID(csvTask.collectionUuid) + val csv = collectionRepository.findByUUID(task.collectionUuid) ?.skills ?.filter { PublishStatus.Archived != it.publishStatus() } - ?.with(RichSkillDescriptorDao::collections) ?.map { RichSkillAndCollections.fromDao(it) } ?.let { RichSkillCsvExport(appConfig).toCsv(it) } taskMessageService.publishResult( - csvTask.copy(result = csv, status = TaskStatus.Ready) + task.copy(result = csv, status = TaskStatus.Ready) ) - logger.info("Task ${csvTask.uuid} completed") + logger.info("Task ${task.uuid} completed") } @RqueueListener( @@ -64,18 +48,17 @@ class CsvTaskProcessor { deadLetterQueue = TaskMessageService.deadLetters, concurrency = "1" ) - fun csvSkillsInFullLibraryProcessor(csvTask: CsvTask) { - logger.info("Started processing task for Full Library export") + override fun tabularSkillsInFullLibraryProcessor(task: CsvTask) { + logger.info("Started processing task for Full Library .csv export") val csv = richSkillRepository.findAll() - ?.with(RichSkillDescriptorDao::collections) ?.map { RichSkillAndCollections.fromDao(it) } ?.let { RichSkillCsvExport(appConfig).toCsv(it) } taskMessageService.publishResult( - csvTask.copy(result = csv, status = TaskStatus.Ready) + task.copy(result = csv, status = TaskStatus.Ready) ) - logger.info("Full Library export task completed") + logger.info("Full Library export task .csv completed") } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/ExportSkillToCsvTaskProcessor.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/ExportSkillToCsvTaskProcessor.kt similarity index 92% rename from api/src/main/kotlin/edu/wgu/osmt/richskill/ExportSkillToCsvTaskProcessor.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/ExportSkillToCsvTaskProcessor.kt index f971f7f59..9eaea2970 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/ExportSkillToCsvTaskProcessor.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/ExportSkillToCsvTaskProcessor.kt @@ -1,7 +1,9 @@ -package edu.wgu.osmt.richskill +package edu.wgu.osmt.io.csv import com.github.sonus21.rqueue.annotation.RqueueListener import edu.wgu.osmt.config.AppConfig +import edu.wgu.osmt.richskill.RichSkillAndCollections +import edu.wgu.osmt.richskill.RichSkillRepository import edu.wgu.osmt.task.ExportSkillsToCsvTask import edu.wgu.osmt.task.TaskMessageService import edu.wgu.osmt.task.TaskStatus diff --git a/api/src/main/kotlin/edu/wgu/osmt/csv/OnetImport.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/OnetImport.kt similarity index 99% rename from api/src/main/kotlin/edu/wgu/osmt/csv/OnetImport.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/OnetImport.kt index 9ed9260d4..cd532d2be 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/csv/OnetImport.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/OnetImport.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import com.opencsv.bean.CsvBindByName import edu.wgu.osmt.config.AppConfig diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillCsvExport.kt b/api/src/main/kotlin/edu/wgu/osmt/io/csv/RichSkillCsvExport.kt similarity index 72% rename from api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillCsvExport.kt rename to api/src/main/kotlin/edu/wgu/osmt/io/csv/RichSkillCsvExport.kt index 2fb294a45..ac901c0c0 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillCsvExport.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/io/csv/RichSkillCsvExport.kt @@ -1,10 +1,9 @@ -package edu.wgu.osmt.richskill +package edu.wgu.osmt.io.csv import edu.wgu.osmt.config.AppConfig -import edu.wgu.osmt.csv.CsvColumn -import edu.wgu.osmt.csv.CsvResource import edu.wgu.osmt.jobcode.JobCode import edu.wgu.osmt.jobcode.JobCodeBreakout +import edu.wgu.osmt.richskill.RichSkillAndCollections class RichSkillCsvExport( private val appConfig: AppConfig @@ -15,18 +14,18 @@ class RichSkillCsvExport( val columns = arrayOf( CsvColumn("Canonical URL") { it.rs.canonicalUrl(appConfig.baseUrl) }, CsvColumn("RSD Name") { it.rs.name }, - CsvColumn("Authors") { it.rs.authors.map { author -> author.value ?: "" }.joinToString(listDelimiter) }, + CsvColumn("Authors") { it.rs.authors.joinToString(listDelimiter) { author -> author.value ?: "" } }, CsvColumn("Skill Statement") { it.rs.statement }, - CsvColumn("Categories") { it.rs.categories.map{ category -> category.value ?: "" }.joinToString(listDelimiter) }, - CsvColumn("Keywords") { it.rs.searchingKeywords.map { keyword -> keyword.value ?: "" }.joinToString(listDelimiter) }, - CsvColumn("Standards") { it.rs.standards.map { keyword -> keyword.value ?: "" }.joinToString(listDelimiter) }, - CsvColumn("Certifications") { it.rs.certifications.map { keyword -> keyword.value ?: "" }.joinToString(listDelimiter) }, + CsvColumn("Categories") { it.rs.categories.joinToString(listDelimiter) { category -> category.value ?: "" } }, + CsvColumn("Keywords") { it.rs.searchingKeywords.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } }, + CsvColumn("Standards") { it.rs.standards.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } }, + CsvColumn("Certifications") { it.rs.certifications.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } }, CsvColumn("Occupation Major Groups") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::majorCode) }, CsvColumn("Occupation Minor Groups") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::minorCode) }, CsvColumn("Broad Occupations") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::broadCode) }, CsvColumn("Detailed Occupations") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::detailedCode) }, CsvColumn("O*Net Job Codes") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::jobRoleCode) }, - CsvColumn("Employers") { it.rs.employers.map { keyword -> keyword.value ?: "" }.joinToString(listDelimiter) } + CsvColumn("Employers") { it.rs.employers.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } } ) val alignmentCount = data.map { s -> s.rs.alignments.size }.maxOrNull() ?: 0 val alignmentColumns = (0 until alignmentCount).flatMap { i -> diff --git a/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/ExportSkillToXlsxTaskProcessor.kt b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/ExportSkillToXlsxTaskProcessor.kt new file mode 100644 index 000000000..7a2b6c3f8 --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/ExportSkillToXlsxTaskProcessor.kt @@ -0,0 +1,51 @@ +package edu.wgu.osmt.io.xlsx + +import com.github.sonus21.rqueue.annotation.RqueueListener +import edu.wgu.osmt.config.AppConfig +import edu.wgu.osmt.richskill.RichSkillAndCollections +import edu.wgu.osmt.richskill.RichSkillRepository +import edu.wgu.osmt.task.ExportSkillsToXlsxTask +import edu.wgu.osmt.task.TaskMessageService +import edu.wgu.osmt.task.TaskStatus +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Profile("apiserver") +@Transactional +class ExportSkillToXlsxTaskProcessor { + val logger: Logger = LoggerFactory.getLogger(ExportSkillToXlsxTaskProcessor::class.java) + + @Autowired + lateinit var taskMessageService: TaskMessageService + + @Autowired + lateinit var richSkillRepository: RichSkillRepository + + @Autowired + lateinit var appConfig: AppConfig + + @RqueueListener( + value = [TaskMessageService.skillsForCustomListExportXlsx], + deadLetterQueueListenerEnabled = "true", + deadLetterQueue = TaskMessageService.deadLetters, + concurrency = "1" + ) + fun xlsxSkillsInCustomRsdListProcessor(task: ExportSkillsToXlsxTask) { + logger.info("Started processing task for Custom RSD List .xlsx export") + + val xlsx = task.uuids?.map { richSkillRepository.findByUUID(it) } + ?.map { RichSkillAndCollections.fromDao(it!!) } + ?.let { RichSkillXlsxExport(appConfig).toXlsx(it) } + + taskMessageService.publishResult( + task.copy(result = xlsx, status = TaskStatus.Ready) + ) + logger.info("Custom RSD List .xlsx export task completed") + } + +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/RichSkillXlsxExport.kt b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/RichSkillXlsxExport.kt new file mode 100644 index 000000000..186cd884e --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/RichSkillXlsxExport.kt @@ -0,0 +1,52 @@ +package edu.wgu.osmt.io.xlsx + +import edu.wgu.osmt.config.AppConfig +import edu.wgu.osmt.jobcode.JobCode +import edu.wgu.osmt.jobcode.JobCodeBreakout +import edu.wgu.osmt.richskill.RichSkillAndCollections + +class RichSkillXlsxExport( + private val appConfig: AppConfig +) : XlsxResource("RichSkillXlsxExport") { + private val listDelimiter = "; " + + override fun columnTranslations(data: List): Array> { + val columns = arrayOf( + XlsxColumn("Canonical URL") { it.rs.canonicalUrl(appConfig.baseUrl) }, + XlsxColumn("RSD Name") { it.rs.name }, + XlsxColumn("Authors") { it.rs.authors.joinToString(listDelimiter) { author -> author.value ?: "" } }, + XlsxColumn("Skill Statement") { it.rs.statement }, + XlsxColumn("Categories") { it.rs.categories.joinToString(listDelimiter) { category -> category.value ?: "" } }, + XlsxColumn("Keywords") { it.rs.searchingKeywords.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } }, + XlsxColumn("Standards") { it.rs.standards.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } }, + XlsxColumn("Certifications") { it.rs.certifications.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } }, + XlsxColumn("Occupation Major Groups") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::majorCode) }, + XlsxColumn("Occupation Minor Groups") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::minorCode) }, + XlsxColumn("Broad Occupations") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::broadCode) }, + XlsxColumn("Detailed Occupations") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::detailedCode) }, + XlsxColumn("O*Net Job Codes") { prepareJobCodePart(it.rs.jobCodes, JobCodeBreakout::jobRoleCode) }, + XlsxColumn("Employers") { it.rs.employers.joinToString(listDelimiter) { keyword -> keyword.value ?: "" } } + ) + val alignmentCount = data.map { s -> s.rs.alignments.size }.maxOrNull() ?: 0 + val alignmentColumns = (0 until alignmentCount).flatMap { i -> + val label = if (i > 0) " ${i + 1}" else "" + listOf( + XlsxColumn("Alignment${label} Name") { it.rs.alignments.getOrNull(i)?.value ?: "" }, + XlsxColumn("Alignment${label} URL") { it.rs.alignments.getOrNull(i)?.uri ?: "" }, + XlsxColumn("Alignment${label} Framework") { it.rs.alignments.getOrNull(i)?.framework ?: "" } + ) + } + return columns + alignmentColumns + } + + private fun prepareJobCodePart( + codes: List, + partTransformation: (String) -> String? + ): String = codes + .asSequence() + .map { it.code } + .map(partTransformation) + .filterNotNull() + .distinct() + .joinToString(listDelimiter) +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/XlsxResource.kt b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/XlsxResource.kt new file mode 100644 index 000000000..e5d8272b2 --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/XlsxResource.kt @@ -0,0 +1,72 @@ +package edu.wgu.osmt.io.xlsx + +import edu.wgu.osmt.io.common.TabColumn +import edu.wgu.osmt.io.common.TabularResource +import org.apache.poi.ss.usermodel.BuiltinFormats +import org.apache.poi.ss.usermodel.Cell +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.xssf.usermodel.XSSFSheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import java.io.ByteArrayOutputStream + +abstract class XlsxResource(val debugName: String) : TabularResource, T> { + private val workbook: XSSFWorkbook = XSSFWorkbook() + private var rowCount: Int = 0 + + /** + * Defines the columns of this xlsx in their desired order. + */ + abstract override fun columnTranslations(data: List): Array> + + /** + * Produce a xlsx export from the list of data using this XlsxResource's configuration and translations + */ + fun toXlsx(data: List): ByteArray { + val sheet: XSSFSheet = this.workbook.createSheet() + + writeHeaderRow(data, sheet) + writeRows(data, sheet) + + val output: ByteArrayOutputStream = ByteArrayOutputStream() + workbook.write(output) + + return output.toByteArray() + } + + private fun writeHeaderRow(data: List, sheet: XSSFSheet) { + if (true) { + val headerRow = columnTranslations(data).map { column -> column.name }.toTypedArray() + writeRow(headerRow, sheet, true) + } + } + + private fun writeRows(data: List, sheet: XSSFSheet) { + val rowsList: List> = data.map { datum -> + columnTranslations(data).map { it.translate(datum) }.toTypedArray() + } + rowsList.forEach { writeRow(it, sheet) } + } + + private fun writeRow(rowData: Array, sheet: XSSFSheet, isHeader: Boolean=false) { + val row: Row = sheet.createRow(this.rowCount) + + rowData.forEachIndexed { colIndex, element -> + if (isHeader) { + val format = BuiltinFormats.getBuiltinFormat("@") + val textStyle = this.workbook.createCellStyle() + textStyle.setDataFormat(format) + sheet.setDefaultColumnStyle(colIndex, textStyle) + } + + val cell: Cell = row.createCell(colIndex) + cell.setCellValue(element) + } + + this.rowCount++ + } +} + +data class XlsxColumn( + val name: String = "", + val translate: (T) -> String +): TabColumn diff --git a/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/XlsxTaskProcessor.kt b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/XlsxTaskProcessor.kt new file mode 100644 index 000000000..c9117fc75 --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/io/xlsx/XlsxTaskProcessor.kt @@ -0,0 +1,63 @@ +package edu.wgu.osmt.io.xlsx + +import com.github.sonus21.rqueue.annotation.RqueueListener +import edu.wgu.osmt.db.PublishStatus +import edu.wgu.osmt.io.common.TabularTask +import edu.wgu.osmt.richskill.RichSkillAndCollections +import edu.wgu.osmt.richskill.RichSkillDescriptorDao +import edu.wgu.osmt.task.XlsxTask +import edu.wgu.osmt.task.TaskMessageService +import edu.wgu.osmt.task.TaskStatus +import org.jetbrains.exposed.dao.with +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Profile("apiserver") +@Transactional +class XlsxTaskProcessor : TabularTask() { + val logger: Logger = LoggerFactory.getLogger(XlsxTaskProcessor::class.java) + + @RqueueListener( + value = [TaskMessageService.skillsForCollectionXlsx], + deadLetterQueueListenerEnabled = "true", + deadLetterQueue = TaskMessageService.deadLetters, + concurrency = "1" + ) + override fun tabularSkillsInCollectionProcessor(task: XlsxTask) { + logger.info("Started processing task id: ${task.uuid}") + + val xlsx = collectionRepository.findByUUID(task.collectionUuid) + ?.skills + ?.filter { PublishStatus.Archived != it.publishStatus() } + ?.map { RichSkillAndCollections.fromDao(it) } + ?.let { RichSkillXlsxExport(appConfig).toXlsx(it) } + + taskMessageService.publishResult( + task.copy(result = xlsx, status = TaskStatus.Ready) + ) + logger.info("Task ${task.uuid} completed") + } + + @RqueueListener( + value = [TaskMessageService.skillsForFullLibraryXlsx], + deadLetterQueueListenerEnabled = "true", + deadLetterQueue = TaskMessageService.deadLetters, + concurrency = "1" + ) + override fun tabularSkillsInFullLibraryProcessor(task: XlsxTask) { + logger.info("Started processing task for Full Library .xlsx export") + + val xlsx = richSkillRepository.findAll() + ?.map { RichSkillAndCollections.fromDao(it) } + ?.let { RichSkillXlsxExport(appConfig).toXlsx(it) } + + taskMessageService.publishResult( + task.copy(result = xlsx, status = TaskStatus.Ready) + ) + logger.info("Full Library .xlsx export task completed") + } +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeBreakout.kt b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeBreakout.kt index 52ed509ee..1ae530471 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeBreakout.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeBreakout.kt @@ -4,28 +4,28 @@ package edu.wgu.osmt.jobcode // formats: x-xxxx.xx and xx.xxxx.xx which means that a little extra processing needs to be done. object JobCodeBreakout { - private val codePartDelimeter = "[-.]".toRegex() // used to split code on either hyphen (-) or period (.) + private val codePartDelimiter = "[-.]".toRegex() // used to split code on either hyphen (-) or period (.) - private fun majorPart(code: String): String? = code.split(codePartDelimeter) + private fun majorPart(code: String): String? = code.split(codePartDelimiter) .takeIf { it.isNotEmpty() } ?.let { it[0] } ?.takeIf { it.toIntOrNull() != null } - private fun minorPart(code: String): String? = code.split(codePartDelimeter) + private fun minorPart(code: String): String? = code.split(codePartDelimiter) .takeIf { it.size > 1 } ?.let { it[1] } ?.takeIf { it.length >= 2 } ?.substring(0, 2) ?.takeIf { it.toIntOrNull() != null } - private fun minorLeadingPart(code: String): String? = code.split(codePartDelimeter) + private fun minorLeadingPart(code: String): String? = code.split(codePartDelimiter) .takeIf { it.size > 1 } ?.let { it[1] } ?.takeIf { it.length >= 2 } ?.substring(0, 1) ?.takeIf { it.toIntOrNull() != null } - private fun broadPart(code: String): String? = code.split(codePartDelimeter) + private fun broadPart(code: String): String? = code.split(codePartDelimiter) .takeIf { it.size > 1 } ?.let { it[1] } ?.takeIf { it.length >= 3 } @@ -33,14 +33,14 @@ object JobCodeBreakout { ?.takeIf { it.toIntOrNull() != null } - private fun detailedPart(code: String): String? = code.split(codePartDelimeter) + private fun detailedPart(code: String): String? = code.split(codePartDelimiter) .takeIf { it.size > 1 } ?.let { it[1] } ?.takeIf { it.length >= 4 } ?.substring(3, 4) ?.takeIf { it.toIntOrNull() != null } - private fun jobRolePart(code: String): String? = code.split(codePartDelimeter) + private fun jobRolePart(code: String): String? = code.split(codePartDelimiter) .takeIf { it.size > 2 } ?.let { it[2] } ?.takeIf { it.toIntOrNull() != null } diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt index fd032c847..660539ce7 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt @@ -16,16 +16,20 @@ import edu.wgu.osmt.config.AppConfig import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.elasticsearch.OffsetPageable import edu.wgu.osmt.elasticsearch.PaginatedLinks +import edu.wgu.osmt.io.csv.RichSkillCsvExport +import edu.wgu.osmt.io.xlsx.RichSkillXlsxExport import edu.wgu.osmt.keyword.KeywordDao import edu.wgu.osmt.security.OAuthHelper import edu.wgu.osmt.task.AppliesToType import edu.wgu.osmt.task.CreateSkillsTask import edu.wgu.osmt.task.CsvTask import edu.wgu.osmt.task.ExportSkillsToCsvTask +import edu.wgu.osmt.task.ExportSkillsToXlsxTask import edu.wgu.osmt.task.PublishTask import edu.wgu.osmt.task.Task import edu.wgu.osmt.task.TaskMessageService import edu.wgu.osmt.task.TaskResult +import edu.wgu.osmt.task.XlsxTask import org.apache.commons.lang3.StringUtils import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpEntity @@ -251,9 +255,9 @@ class RichSkillController @Autowired constructor( } @Transactional(readOnly = true) - @GetMapping(RoutePaths.EXPORT_LIBRARY, produces = [MediaType.APPLICATION_JSON_VALUE]) + @GetMapping(RoutePaths.EXPORT_LIBRARY_CSV, produces = [MediaType.APPLICATION_JSON_VALUE]) @ResponseBody - fun exportLibrary( + fun exportLibraryCsv( @AuthenticationPrincipal user: Jwt? ): HttpEntity { if (!appConfig.allowPublicSearching && user === null) { @@ -270,9 +274,28 @@ class RichSkillController @Autowired constructor( } @Transactional(readOnly = true) - @PostMapping(RoutePaths.EXPORT_SKILLS, produces = [MediaType.APPLICATION_JSON_VALUE]) + @GetMapping(RoutePaths.EXPORT_LIBRARY_XLSX, produces = [MediaType.APPLICATION_JSON_VALUE]) @ResponseBody - fun exportCustomList( + fun exportLibraryXlsx( + @AuthenticationPrincipal user: Jwt? + ): HttpEntity { + if (!appConfig.allowPublicSearching && user === null) { + throw GeneralApiException("Unauthorized", HttpStatus.UNAUTHORIZED) + } + if (!oAuthHelper.hasRole(appConfig.roleAdmin)) { + throw GeneralApiException("OSMT user must have an Admin role.", HttpStatus.UNAUTHORIZED) + } + + val task = XlsxTask(collectionUuid = "FullLibrary") + taskMessageService.enqueueJob(TaskMessageService.skillsForFullLibraryXlsx, task) + + return Task.processingResponse(task) + } + + @Transactional(readOnly = true) + @PostMapping(RoutePaths.EXPORT_SKILLS_CSV, produces = [MediaType.APPLICATION_JSON_VALUE]) + @ResponseBody + fun exportCustomListCsv( @RequestBody uuids: List?, @AuthenticationPrincipal user: Jwt? ): HttpEntity { @@ -285,4 +308,21 @@ class RichSkillController @Autowired constructor( return Task.processingResponse(task) } + + @Transactional(readOnly = true) + @PostMapping(RoutePaths.EXPORT_SKILLS_XLSX, produces = [MediaType.APPLICATION_JSON_VALUE]) + @ResponseBody + fun exportCustomListXlsx( + @RequestBody uuids: List?, + @AuthenticationPrincipal user: Jwt? + ): HttpEntity { + if (!appConfig.allowPublicSearching && user === null) { + throw GeneralApiException("Unauthorized", HttpStatus.UNAUTHORIZED) + } + + val task = ExportSkillsToXlsxTask(collectionUuid = "CustomList", uuids) + taskMessageService.enqueueJob(TaskMessageService.skillsForCustomListExportXlsx, task) + + return Task.processingResponse(task) + } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/security/SecurityConfig.kt b/api/src/main/kotlin/edu/wgu/osmt/security/SecurityConfig.kt index 3e8cdc7c0..95b106eaf 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/security/SecurityConfig.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/security/SecurityConfig.kt @@ -11,6 +11,7 @@ import edu.wgu.osmt.RoutePaths.COLLECTION_REMOVE import edu.wgu.osmt.RoutePaths.COLLECTION_SKILLS import edu.wgu.osmt.RoutePaths.COLLECTION_SKILLS_UPDATE import edu.wgu.osmt.RoutePaths.COLLECTION_UPDATE +import edu.wgu.osmt.RoutePaths.COLLECTION_XLSX import edu.wgu.osmt.RoutePaths.SEARCH_COLLECTIONS import edu.wgu.osmt.RoutePaths.SEARCH_JOBCODES_PATH import edu.wgu.osmt.RoutePaths.SEARCH_KEYWORDS_PATH @@ -22,6 +23,7 @@ import edu.wgu.osmt.RoutePaths.SKILL_DETAIL import edu.wgu.osmt.RoutePaths.SKILL_PUBLISH import edu.wgu.osmt.RoutePaths.SKILL_UPDATE import edu.wgu.osmt.RoutePaths.TASK_DETAIL_BATCH +import edu.wgu.osmt.RoutePaths.TASK_DETAIL_MEDIA import edu.wgu.osmt.RoutePaths.TASK_DETAIL_SKILLS import edu.wgu.osmt.RoutePaths.TASK_DETAIL_TEXT import edu.wgu.osmt.RoutePaths.WORKSPACE_PATH @@ -93,6 +95,8 @@ class SecurityConfig : WebSecurityConfigurerAdapter() { .mvcMatchers(POST, COLLECTION_SKILLS).permitAll() .mvcMatchers(GET, COLLECTION_CSV).permitAll() .mvcMatchers(GET, TASK_DETAIL_TEXT).permitAll() // public csv results + .mvcMatchers(GET, COLLECTION_XLSX).permitAll() + .mvcMatchers(GET, TASK_DETAIL_MEDIA).permitAll() // public excel results .and().exceptionHandling().authenticationEntryPoint(returnUnauthorized) .and().oauth2Login().successHandler(redirectToFrontend) diff --git a/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt b/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt index d615b07ad..39573e7af 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt @@ -21,6 +21,7 @@ import java.util.* ) @JsonSubTypes( JsonSubTypes.Type(value = CsvTask::class, name = "CsvTask"), + JsonSubTypes.Type(value = XlsxTask::class, name = "XlsxTask"), JsonSubTypes.Type(value = ApiSearch::class, name = "ApiSearch"), JsonSubTypes.Type(value = ApiBatchResult::class, name = "ApiBatchResult"), JsonSubTypes.Type(value = PublishTask::class, name = "PublishTask"), @@ -28,6 +29,7 @@ import java.util.* JsonSubTypes.Type(value = UpdateCollectionSkillsTask::class, name = "UpdateCollectionSkillsTask"), JsonSubTypes.Type(value = CreateSkillsTask::class, name = "CreateSkillsTask"), JsonSubTypes.Type(value = ExportSkillsToCsvTask::class, name = "ExportSkillsToCsvTask"), + JsonSubTypes.Type(value = ExportSkillsToXlsxTask::class, name = "ExportSkillsToXlsxTask"), JsonSubTypes.Type(value = RemoveCollectionSkillsTask::class, name = "RemoveCollectionSkillsTask") ) @@ -67,7 +69,17 @@ data class CsvTask( ) : Task { override val contentType = "text/csv" override val apiResultPath = RoutePaths.TASK_DETAIL_TEXT +} +data class XlsxTask( + val collectionUuid: String = "", + override val uuid: String = UUID.randomUUID().toString(), + override val start: Date = Date(), + override val result: ByteArray? = null, + override val status: TaskStatus = TaskStatus.Processing +) : Task { + override val contentType = "application/vnd.ms-excel" + override val apiResultPath = RoutePaths.TASK_DETAIL_MEDIA } data class ExportSkillsToCsvTask( @@ -82,6 +94,18 @@ data class ExportSkillsToCsvTask( override val apiResultPath = RoutePaths.TASK_DETAIL_BATCH } +data class ExportSkillsToXlsxTask( + val collectionUuid: String = "", + val uuids: List? = null, + override val uuid: String = UUID.randomUUID().toString(), + override val start: Date = Date(), + override val result: ByteArray? = null, + override val status: TaskStatus = TaskStatus.Processing +) : Task { + override val contentType = "application/vnd.ms-excel" + override val apiResultPath = RoutePaths.TASK_DETAIL_BATCH +} + data class CreateSkillsTask( val apiSkillUpdates: List = listOf(), val userString: String = "", diff --git a/api/src/main/kotlin/edu/wgu/osmt/task/TaskController.kt b/api/src/main/kotlin/edu/wgu/osmt/task/TaskController.kt index c394e1d6e..2ee66da68 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/task/TaskController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/task/TaskController.kt @@ -35,6 +35,12 @@ class TaskController @Autowired constructor( return taskResult(uuid) } + @GetMapping(RoutePaths.TASK_DETAIL_MEDIA) + @ResponseBody + fun mediaResult(@PathVariable uuid: String): HttpEntity<*> { + return taskResult(uuid) + } + @GetMapping(RoutePaths.TASK_DETAIL_BATCH) @ResponseBody fun batchResult(@PathVariable uuid: String): HttpEntity<*> { diff --git a/api/src/main/kotlin/edu/wgu/osmt/task/TaskMessageService.kt b/api/src/main/kotlin/edu/wgu/osmt/task/TaskMessageService.kt index 9174368ae..f8ac08e5d 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/task/TaskMessageService.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/task/TaskMessageService.kt @@ -43,8 +43,11 @@ class TaskMessageService { const val publishSkills = "batch-publish-skills" const val updateCollectionSkills = "update-collection-skills" const val skillsForCollectionCsv = "collection-skills-csv-process" + const val skillsForCollectionXlsx = "collection-skills-xlsx-process" const val removeCollectionSkills = "remove-collection" const val skillsForFullLibraryCsv = "full-library-skills-csv-process" - const val skillsForCustomListExportCsv = "custom-rsd-list-export" + const val skillsForFullLibraryXlsx = "full-library-skills-xlsx-process" + const val skillsForCustomListExportCsv = "custom-rsd-list-csv-export" + const val skillsForCustomListExportXlsx = "custom-rsd-list-xlsx-export" } } diff --git a/api/src/test/kotlin/edu/wgu/osmt/csv/BatchImportRichSkillTest.kt b/api/src/test/kotlin/edu/wgu/osmt/io/csv/BatchImportRichSkillTest.kt similarity index 98% rename from api/src/test/kotlin/edu/wgu/osmt/csv/BatchImportRichSkillTest.kt rename to api/src/test/kotlin/edu/wgu/osmt/io/csv/BatchImportRichSkillTest.kt index 759301948..317cefc2f 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/csv/BatchImportRichSkillTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/io/csv/BatchImportRichSkillTest.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import edu.wgu.osmt.BaseDockerizedTest import edu.wgu.osmt.HasDatabaseReset diff --git a/api/src/test/kotlin/edu/wgu/osmt/csv/BlsImportTest.kt b/api/src/test/kotlin/edu/wgu/osmt/io/csv/BlsImportTest.kt similarity index 98% rename from api/src/test/kotlin/edu/wgu/osmt/csv/BlsImportTest.kt rename to api/src/test/kotlin/edu/wgu/osmt/io/csv/BlsImportTest.kt index e654cd6d1..a5159017e 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/csv/BlsImportTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/io/csv/BlsImportTest.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import edu.wgu.osmt.BaseDockerizedTest import edu.wgu.osmt.HasDatabaseReset diff --git a/api/src/test/kotlin/edu/wgu/osmt/csv/CsvResourceTest.kt b/api/src/test/kotlin/edu/wgu/osmt/io/csv/CsvResourceTest.kt similarity index 96% rename from api/src/test/kotlin/edu/wgu/osmt/csv/CsvResourceTest.kt rename to api/src/test/kotlin/edu/wgu/osmt/io/csv/CsvResourceTest.kt index 7ddb650ba..f1ffa0e1a 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/csv/CsvResourceTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/io/csv/CsvResourceTest.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -165,9 +165,9 @@ internal class CsvResourceTest { } fun getCsvTestResource( - debugName: String = "unit-test", - columnDefinitions: Array>, - configuration: CsvConfig? = null + debugName: String = "unit-test", + columnDefinitions: Array>, + configuration: CsvConfig? = null ): CsvResource { return object : CsvResource(debugName) { override fun columnTranslations(d: List): Array> = columnDefinitions diff --git a/api/src/test/kotlin/edu/wgu/osmt/csv/OnetImportTest.kt b/api/src/test/kotlin/edu/wgu/osmt/io/csv/OnetImportTest.kt similarity index 98% rename from api/src/test/kotlin/edu/wgu/osmt/csv/OnetImportTest.kt rename to api/src/test/kotlin/edu/wgu/osmt/io/csv/OnetImportTest.kt index 90c13d2c6..322b9a7f9 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/csv/OnetImportTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/io/csv/OnetImportTest.kt @@ -1,4 +1,4 @@ -package edu.wgu.osmt.csv +package edu.wgu.osmt.io.csv import edu.wgu.osmt.BaseDockerizedTest import edu.wgu.osmt.HasDatabaseReset diff --git a/api/src/test/kotlin/edu/wgu/osmt/io/xlsx/RichSkillXlsxExportTest.kt b/api/src/test/kotlin/edu/wgu/osmt/io/xlsx/RichSkillXlsxExportTest.kt new file mode 100644 index 000000000..8f81d7601 --- /dev/null +++ b/api/src/test/kotlin/edu/wgu/osmt/io/xlsx/RichSkillXlsxExportTest.kt @@ -0,0 +1,76 @@ +package edu.wgu.osmt.io.xlsx + +import edu.wgu.osmt.SpringTest +import edu.wgu.osmt.collection.Collection +import edu.wgu.osmt.config.AppConfig +import edu.wgu.osmt.db.PublishStatus +import edu.wgu.osmt.keyword.Keyword +import edu.wgu.osmt.keyword.KeywordTypeEnum +import edu.wgu.osmt.richskill.RichSkillAndCollections +import edu.wgu.osmt.richskill.RichSkillDescriptor +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDateTime +import java.util.UUID + +internal class RichSkillXlsxExportTest @Autowired constructor( + appConfig: AppConfig +) : SpringTest() { + + val richSkillXlsExport = RichSkillXlsxExport(appConfig) + + val collection = Collection( + id = 123, + creationDate = LocalDateTime.now(), + name = "name", + status = PublishStatus.Draft, + updateDate = LocalDateTime.now(), + uuid = UUID.randomUUID().toString() + ) + + @Test + fun`columnTranslations() should retrieve only 14 columns if no Alignments are present`() { + //Arrange + val rsd = RichSkillDescriptor( + id = 123, + creationDate = LocalDateTime.now(), + name = "name", + statement = "statement", + updateDate = LocalDateTime.now(), + uuid = UUID.randomUUID().toString() + ) + + //Act + val result = richSkillXlsExport.columnTranslations(listOf(RichSkillAndCollections(rsd, setOf(collection)))) + + //Assert + Assertions.assertThat(result).hasSize(14) + } + + @Test + fun`columnTranslations() should retrieve 17 columns if Alignments are present`() { + //Arrange + val rsd = RichSkillDescriptor( + id = 123, + creationDate = LocalDateTime.now(), + keywords = listOf( + Keyword( + id = 123, + creationDate = LocalDateTime.now(), + type = KeywordTypeEnum.Alignment, + updateDate = LocalDateTime.now() + )), + name = "name", + statement = "statement", + updateDate = LocalDateTime.now(), + uuid = UUID.randomUUID().toString() + ) + + //Act + val result = richSkillXlsExport.columnTranslations(listOf(RichSkillAndCollections(rsd, setOf(collection)))) + + //Assert + Assertions.assertThat(result).hasSize(17) + } +} \ No newline at end of file diff --git a/api/src/test/kotlin/edu/wgu/osmt/mockdata/MockData.kt b/api/src/test/kotlin/edu/wgu/osmt/mockdata/MockData.kt index 97d41f0be..95c6d6915 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/mockdata/MockData.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/mockdata/MockData.kt @@ -3,9 +3,9 @@ package edu.wgu.osmt.mockdata import com.fasterxml.jackson.dataformat.xml.XmlMapper import edu.wgu.osmt.collection.CollectionDoc import edu.wgu.osmt.config.AppConfig -import edu.wgu.osmt.csv.BlsJobCode -import edu.wgu.osmt.csv.OnetJobCode -import edu.wgu.osmt.csv.RichSkillRow +import edu.wgu.osmt.io.csv.BlsJobCode +import edu.wgu.osmt.io.csv.OnetJobCode +import edu.wgu.osmt.io.csv.RichSkillRow import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.jobcode.JobCode import edu.wgu.osmt.keyword.Keyword diff --git a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt index 67683e492..31b257c6e 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt @@ -3,14 +3,14 @@ package edu.wgu.osmt.richskill import edu.wgu.osmt.BaseDockerizedTest import edu.wgu.osmt.HasDatabaseReset import edu.wgu.osmt.HasElasticsearchReset -import edu.wgu.osmt.RoutePaths.EXPORT_LIBRARY +import edu.wgu.osmt.RoutePaths.EXPORT_LIBRARY_CSV import edu.wgu.osmt.SpringTest import edu.wgu.osmt.api.model.ApiFilteredSearch import edu.wgu.osmt.api.model.ApiSearch import edu.wgu.osmt.collection.CollectionEsRepo import edu.wgu.osmt.config.AppConfig -import edu.wgu.osmt.csv.BatchImportRichSkill -import edu.wgu.osmt.csv.RichSkillRow +import edu.wgu.osmt.io.csv.BatchImportRichSkill +import edu.wgu.osmt.io.csv.RichSkillRow import edu.wgu.osmt.jobcode.JobCodeEsRepo import edu.wgu.osmt.keyword.KeywordEsRepo import edu.wgu.osmt.mockdata.MockData @@ -252,7 +252,7 @@ internal class RichSkillControllerTest @Autowired constructor( val headers : MutableMap = HashMap() headers["key"] = "value" val notNullJwt : Jwt? = Jwt("tokenValue", Instant.MIN, Instant.MAX,headers,headers) - val csvTaskResult = TaskResult(UUID.randomUUID().toString(),MediaType.APPLICATION_JSON_VALUE,TaskStatus.Processing, EXPORT_LIBRARY) + val csvTaskResult = TaskResult(UUID.randomUUID().toString(),MediaType.APPLICATION_JSON_VALUE,TaskStatus.Processing, EXPORT_LIBRARY_CSV) val service = mockk() @@ -261,7 +261,7 @@ internal class RichSkillControllerTest @Autowired constructor( mockkStatic(TaskResult::class) every { Task.processingResponse(any()) } returns HttpEntity(csvTaskResult) - val result = richSkillController.exportLibrary(user = notNullJwt) + val result = richSkillController.exportLibraryCsv(user = notNullJwt) assertThat(result.body?.uuid).isNotBlank() } diff --git a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt index 3cccadc36..dd72c1a64 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt @@ -12,7 +12,7 @@ import edu.wgu.osmt.api.model.ApiSearch import edu.wgu.osmt.api.model.ApiSimilaritySearch import edu.wgu.osmt.collection.CollectionDoc import edu.wgu.osmt.collection.CollectionEsRepo -import edu.wgu.osmt.csv.BatchImportRichSkill +import edu.wgu.osmt.io.csv.BatchImportRichSkill import edu.wgu.osmt.db.ListFieldUpdate import edu.wgu.osmt.jobcode.JobCodeEsRepo import edu.wgu.osmt.keyword.KeywordEsRepo diff --git a/docs/int/osmt-v1.0.x-openapi3.yaml b/docs/int/osmt-v1.0.x-openapi3.yaml index d018e9302..fd8444156 100644 --- a/docs/int/osmt-v1.0.x-openapi3.yaml +++ b/docs/int/osmt-v1.0.x-openapi3.yaml @@ -681,6 +681,32 @@ paths: parameters: uuid: '$response.body#/uuid' + /api/collections/{uuid}/xlsx: + get: + tags: + - Collections + summary: Retrieve the skills in a collection as a XLSX (MS Excel) + description: Retrieve the skills that belong to the collection in XLSX (MS Excel) format + parameters: + - name: uuid + in: path + description: uuid of a collection + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/vnd.ms-excel: + schema: + $ref: '#/components/schemas/TaskResult' + links: + CollectionXlsxResults: + operationRef: '/api/results/media/{uuid}' + parameters: + uuid: '$response.body#/uuid' + /api/collections/{uuid}/updateSkills: post: tags: diff --git a/docs/int/osmt-v2.x-openapi3.yaml b/docs/int/osmt-v2.x-openapi3.yaml index 8ce85d4e2..ca577704b 100644 --- a/docs/int/osmt-v2.x-openapi3.yaml +++ b/docs/int/osmt-v2.x-openapi3.yaml @@ -739,6 +739,32 @@ paths: parameters: uuid: '$response.body#/uuid' + /api/collections/{uuid}/xlsx: + get: + tags: + - Collections + summary: Retrieve the skills in a collection as a XLSX (MS Excel) + description: Retrieve the skills that belong to the collection in XLSX (MS Excel) format + parameters: + - name: uuid + in: path + description: uuid of a collection + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/vnd.ms-excel: + schema: + $ref: '#/components/schemas/TaskResult' + links: + CollectionCsvResults: + operationRef: '/api/results/media/{uuid}' + parameters: + uuid: '$response.body#/uuid' + /api/collections/{uuid}/updateSkills: post: tags: diff --git a/ui/karma.ci.conf.js b/ui/karma.ci.conf.js index 8e4d57219..0f9c67583 100644 --- a/ui/karma.ci.conf.js +++ b/ui/karma.ci.conf.js @@ -13,7 +13,12 @@ module.exports = function(config) { require('karma-sonarqube-unit-reporter'), require('@angular-devkit/build-angular/plugins/karma') ], - files: ['src/app/**/*.spec.ts'], + files: [ + { + pattern: 'src/app/**/*.spec.ts', + type: 'js' + } + ], reporters: ['progress', 'sonarqubeUnit', 'coverage'], port: 9876, // karma web server port colors: true, diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index 745219af9..a0045af9b 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -92,9 +92,12 @@ import {LabelWithSelectComponent} from "./table/skills-library-table/label-with- import {LibraryExportComponent} from "./navigation/libraryexport.component" import {MyWorkspaceComponent} from "./my-workspace/my-workspace.component" import {CollectionPipe} from "./pipes" +import { SharedModule } from "@shared/shared.module" +import { OsmtCoreModule } from "./core/osmt-core.module" +import { ExportCollectionComponent } from "./export/export-collection.component" +import { ExportRsdComponent } from "./export/export-rsd.component" +import { OsmtFormModule } from "./form/osmt-form.module" import { ConvertToCollectionComponent } from "./my-workspace/convert-to-collection/convert-to-collection.component" -import {SharedModule} from "@shared/shared.module" -import {OsmtFormModule} from "./form/osmt-form.module" import { SizePaginationComponent } from "./table/skills-library-table/size-pagination/size-pagination.component" export function initializeApp( @@ -149,6 +152,8 @@ export function initializeApp( CollectionsListComponent, CollectionSearchResultsComponent, PublishCollectionComponent, + ExportCollectionComponent, + ExportRsdComponent, DetailCardComponent, DetailCardSectionComponent, @@ -212,6 +217,7 @@ export function initializeApp( ReactiveFormsModule, CommonModule, SharedModule, + OsmtCoreModule, OsmtFormModule, FormsModule ], diff --git a/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.spec.ts b/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.spec.ts index 45407faf5..1aa87a616 100644 --- a/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.spec.ts +++ b/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.spec.ts @@ -1,8 +1,9 @@ import { Component, Type } from "@angular/core" -import { async, ComponentFixture, TestBed } from "@angular/core/testing" +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing" import { By } from "@angular/platform-browser" import { Router } from "@angular/router" import * as FileSaver from "file-saver" +import { ExportCollectionComponent } from "src/app/export/export-collection.component" import { ActivatedRouteStubSpec } from "test/util/activated-route-stub.spec" import { createMockTaskResult } from "../../../../../../test/resource/mock-data" import { CollectionServiceStub } from "../../../../../../test/resource/mock-stubs" @@ -31,30 +32,30 @@ class TestHostComponent { export function createComponent(T: Type): Promise { - hostFixture = TestBed.createComponent(T) - hostComponent = hostFixture.componentInstance + hostFixture = TestBed.createComponent(T); + hostComponent = hostFixture.componentInstance; - const debugEl = hostFixture.debugElement.query(By.directive(CollectionPublicActionBarComponent)) - childComponent = debugEl.componentInstance + const debugEl = hostFixture.debugElement.query(By.directive(CollectionPublicActionBarComponent)); + childComponent = debugEl.componentInstance; // 1st change detection triggers ngOnInit which gets a hero - hostFixture.detectChanges() + hostFixture.detectChanges(); return hostFixture.whenStable().then(() => { // 2nd change detection displays the async-fetched hero - hostFixture.detectChanges() - }) + hostFixture.detectChanges(); + }); } -let hostFixture: ComponentFixture -let hostComponent: TestHostComponent -let childComponent: CollectionPublicActionBarComponent +let hostFixture: ComponentFixture; +let hostComponent: TestHostComponent; +let childComponent: CollectionPublicActionBarComponent; describe("CollectionPublicActionBarComponent", () => { - beforeEach(async(() => { - const routerSpy = ActivatedRouteStubSpec.createRouterSpy() + beforeEach(waitForAsync(() => { + const routerSpy = ActivatedRouteStubSpec.createRouterSpy(); TestBed.configureTestingModule({ declarations: [ @@ -64,91 +65,41 @@ describe("CollectionPublicActionBarComponent", () => { providers: [ ToastService, { provide: CollectionService, useClass: CollectionServiceStub }, - { provide: Router, useValue: routerSpy } + { provide: Router, useValue: routerSpy }, + ExportCollectionComponent ] }) - .compileComponents() + .compileComponents(); - spyOn(FileSaver, "saveAs").and.stub() + spyOn(FileSaver, "saveAs").and.stub(); - createComponent(TestHostComponent) - })) + createComponent(TestHostComponent); + })); it("should be created", () => { - expect(hostComponent).toBeTruthy() - expect(childComponent).toBeTruthy() - }) - - it("pollCsv should return", (done) => { - // Arrange - childComponent.taskUuidInProgress = "123" - childComponent.intervalHandle = 1 - - // Act - childComponent.pollCsv() - - // Assert - /* Delay the handling to give time for the async method to complete. */ - setTimeout(() => { - expect(childComponent.taskUuidInProgress).toBeFalsy() - expect(FileSaver.saveAs).toHaveBeenCalled() - done() - }, 2000) - }) - it("pollCsv should fail", (done) => { - // Arrange - childComponent.taskUuidInProgress = undefined - childComponent.intervalHandle = 1 - - // Act - childComponent.pollCsv() - - // Assert - /* Delay the handling to give time for the async method to complete. */ - setTimeout(() => { - expect(FileSaver.saveAs).not.toHaveBeenCalled() - done() - }, 2000) - }) + expect(hostComponent).toBeTruthy(); + expect(childComponent).toBeTruthy(); + }); it("onCopyURL should return", () => { // Arrange - const element: HTMLTextAreaElement = document.createElement("TextArea") as HTMLTextAreaElement + const element: HTMLTextAreaElement = document.createElement("TextArea") as HTMLTextAreaElement; // Act - childComponent.onCopyURL(element) + childComponent.onCopyURL(element); // Assert /* Nothing to check */ - }) + }); it("onCopyJSON should return", () => { // Arrange - const element: HTMLTextAreaElement = document.createElement("TextArea") as HTMLTextAreaElement + const element: HTMLTextAreaElement = document.createElement("TextArea") as HTMLTextAreaElement; // Act - childComponent.onCopyJSON(element) + childComponent.onCopyJSON(element); // Assert /* Nothing to check */ }) - - it("onDownloadCsv should return", (done) => { - // Arrange - const task = createMockTaskResult() - childComponent.collectionUuid = "myUUID" - childComponent.taskUuidInProgress = undefined - - // Act - childComponent.onDownloadCsv() - const result = childComponent.taskUuidInProgress - - // Assert - /* Delay the handling to give time for the async method to complete. */ - setTimeout(() => { - expect(result === task.uuid).toBeTrue() // Due to conditional types, cannot use .toEqual() - expect(FileSaver.saveAs).toHaveBeenCalled() - done() - }, 2000) - }) -}) +}); diff --git a/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.ts b/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.ts index 56286f0d4..6d6cacc71 100644 --- a/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.ts +++ b/ui/src/app/collection/detail/collection-public/action-bar/collection-public-action-bar.component.ts @@ -1,12 +1,14 @@ -import {Component, Inject, Input, LOCALE_ID, OnInit} from "@angular/core" -import {CollectionService} from "../../../service/collection.service" -import {Router} from "@angular/router" -import {ToastService} from "../../../../toast/toast.service" -import {SvgHelper, SvgIcon} from "../../../../core/SvgHelper" -import {formatDate} from "@angular/common" -import {ITaskResult} from "../../../../task/ApiTaskResult" +import { Component, Inject, Input, LOCALE_ID, OnInit } from "@angular/core" +import { Router } from "@angular/router" + import { Observable } from "rxjs" -import * as FileSaver from "file-saver"; + +import { SvgHelper, SvgIcon } from "../../../../core/SvgHelper" +import { ExportCollectionComponent } from "../../../../export/export-collection.component" +import { CollectionService } from "../../../service/collection.service" +import { TableActionDefinition } from "../../../../table/skills-library-table/has-action-definitions" +import { ToastService } from "../../../../toast/toast.service" + @Component({ selector: "app-collection-public-action-bar", @@ -22,11 +24,36 @@ export class CollectionPublicActionBarComponent implements OnInit { downloadIcon = SvgHelper.path(SvgIcon.DOWNLOAD) collectionJsonObservable = new Observable() + exporter = new ExportCollectionComponent( + this.collectionService, + this.toastService, + this.locale + ) jsonClipboard = "" - taskUuidInProgress: string | undefined - csvExport: string | undefined - intervalHandle: number | undefined + action = new TableActionDefinition({ + label: "Download", + icon: this.downloadIcon, + menu: [ + { + label: "Download as CSV", + visible: () => true, + callback: () => this.exporter.getCollectionCsv( + this.collectionUuid, + this.collectionName + ) + }, + { + label: "Download as Excel Workbook", + visible: () => true, + callback: () => this.exporter.getCollectionXlsx( + this.collectionUuid, + this.collectionName + ) + } + ], + visible: () => true + }) constructor( protected router: Router, @@ -44,27 +71,6 @@ export class CollectionPublicActionBarComponent implements OnInit { } } - pollCsv(): void { - if (this.taskUuidInProgress === undefined) { // fail fast - clearInterval(this.intervalHandle) - return - } - - this.collectionService.getCsvTaskResultsIfComplete(this.taskUuidInProgress) - .subscribe(({body, status}) => { - if (status === 200) { - this.csvExport = body as string - this.taskUuidInProgress = undefined - - clearInterval(this.intervalHandle) - - 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 - ${this.collectionName} ${date}.csv`) - } - }) - } - onCopyURL(fullPath: HTMLTextAreaElement): void { fullPath.select() document.execCommand("copy") @@ -72,14 +78,6 @@ export class CollectionPublicActionBarComponent implements OnInit { this.toastService.showToast("Success!", "URL copied to clipboard") } - onDownloadCsv(): void { - this.collectionService.requestCollectionSkillsCsv(this.collectionUuid) - .subscribe((taskStarted: ITaskResult) => { - this.taskUuidInProgress = taskStarted.uuid - this.intervalHandle = setInterval(() => this.pollCsv(), 1000) - }) - } - onCopyJSON(collectionJson: HTMLTextAreaElement): void { this.collectionJsonObservable.subscribe(() => { collectionJson.select() @@ -88,4 +86,5 @@ export class CollectionPublicActionBarComponent implements OnInit { this.toastService.showToast("Success!", "JSON copied to clipboard") }) } + } diff --git a/ui/src/app/collection/detail/collection-public/action-bar/horizontal/collection-public-horizontal-action-bar.component.html b/ui/src/app/collection/detail/collection-public/action-bar/horizontal/collection-public-horizontal-action-bar.component.html index 47b34a62c..e7b206b4f 100644 --- a/ui/src/app/collection/detail/collection-public/action-bar/horizontal/collection-public-horizontal-action-bar.component.html +++ b/ui/src/app/collection/detail/collection-public/action-bar/horizontal/collection-public-horizontal-action-bar.component.html @@ -16,17 +16,8 @@ - + + - - - + + + + + diff --git a/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.scss b/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.spec.ts b/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.spec.ts new file mode 100644 index 000000000..0c1920c56 --- /dev/null +++ b/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing" + +import { HorizontalActionBarItemComponent } from "./horizontal-action-bar-item.component" + +describe("HorizontalActionBarItemComponent", () => { + let component: HorizontalActionBarItemComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ HorizontalActionBarItemComponent ] + }) + .compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(HorizontalActionBarItemComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it("should create", () => { + expect(component).toBeTruthy() + }) +}) diff --git a/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.ts b/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.ts new file mode 100644 index 000000000..d5572536e --- /dev/null +++ b/ui/src/app/core/horizontal-action-bar-item/horizontal-action-bar-item.component.ts @@ -0,0 +1,15 @@ +import { Component, Input } from "@angular/core" + +import { TableActionDefinition } from "../../table/skills-library-table/has-action-definitions" + +@Component({ + selector: "app-horizontal-action-bar-item", + templateUrl: "./horizontal-action-bar-item.component.html", + styleUrls: ["./horizontal-action-bar-item.component.scss", "../../table/skills-library-table/action-bar-item.components.scss"] +}) +export class HorizontalActionBarItemComponent { + + @Input() + action?: TableActionDefinition + +} diff --git a/ui/src/app/core/osmt-core.module.ts b/ui/src/app/core/osmt-core.module.ts new file mode 100644 index 000000000..8e99ac0b2 --- /dev/null +++ b/ui/src/app/core/osmt-core.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from "@angular/common" +import { NgModule } from "@angular/core" + +import { DropUpMenuComponent } from "./drop-up-menu/drop-up-menu.component" +import { HorizontalActionBarItemComponent } from "./horizontal-action-bar-item/horizontal-action-bar-item.component" +import { VerticalActionBarItemComponent } from "./vertical-action-bar-item/vertical-action-bar-item.component" + +@NgModule({ + declarations: [ + DropUpMenuComponent, + HorizontalActionBarItemComponent, + VerticalActionBarItemComponent + ], + imports: [ + CommonModule + ], + exports: [ + DropUpMenuComponent, + HorizontalActionBarItemComponent, + VerticalActionBarItemComponent + ] +}) +export class OsmtCoreModule { } diff --git a/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.html b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.html new file mode 100644 index 000000000..972e33523 --- /dev/null +++ b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.html @@ -0,0 +1,20 @@ +
+ + + +
diff --git a/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.scss b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.scss new file mode 100644 index 000000000..30b6beaaa --- /dev/null +++ b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.scss @@ -0,0 +1,15 @@ +.dropup { + position: inherit; + display: inherit; + width: 100%; +} + +.dropup-content { + margin-left: 110px!important; + margin-bottom: -90px!important; + padding-right: 20px; +} + +hr { + width: 100%; +} diff --git a/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.spec.ts b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.spec.ts new file mode 100644 index 000000000..e80bc171d --- /dev/null +++ b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing" + +import { VerticalActionBarItemComponent } from "./vertical-action-bar-item.component" + +describe("ActionBarItemComponent", () => { + let component: VerticalActionBarItemComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ VerticalActionBarItemComponent ] + }) + .compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(VerticalActionBarItemComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it("should create", () => { + expect(component).toBeTruthy() + }) +}) diff --git a/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.ts b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.ts new file mode 100644 index 000000000..d1a636d94 --- /dev/null +++ b/ui/src/app/core/vertical-action-bar-item/vertical-action-bar-item.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from "@angular/core" + +import { TableActionDefinition } from "../../table/skills-library-table/has-action-definitions" + +@Component({ + selector: "app-vertical-action-bar-item", + templateUrl: "./vertical-action-bar-item.component.html", + styleUrls: [ + "./vertical-action-bar-item.component.scss", + "../../table/skills-library-table/action-bar-item.components.scss" + ] +}) +export class VerticalActionBarItemComponent { + + @Input() + action?: TableActionDefinition + @Input() + data?: any + +} diff --git a/ui/src/app/core/vertical-action-bar.component.html b/ui/src/app/core/vertical-action-bar.component.html index 94b08b958..aa2f72cfe 100644 --- a/ui/src/app/core/vertical-action-bar.component.html +++ b/ui/src/app/core/vertical-action-bar.component.html @@ -1,19 +1,9 @@
- +
+ + +
@@ -36,4 +26,3 @@
- diff --git a/ui/src/app/export/export-collection.component.spec.ts b/ui/src/app/export/export-collection.component.spec.ts new file mode 100644 index 000000000..4f07f1b70 --- /dev/null +++ b/ui/src/app/export/export-collection.component.spec.ts @@ -0,0 +1,85 @@ +import { HttpClientTestingModule } from "@angular/common/http/testing" +import { ComponentFixture, TestBed } from "@angular/core/testing" + +import { ExportCollectionComponent } from "./export-collection.component" +import { CollectionService } from "../collection/service/collection.service" +import { CollectionServiceStub } from "../../../test/resource/mock-stubs" + +describe("ExportCollectionComponent", () => { + let component: ExportCollectionComponent + let fixture: ComponentFixture + let collectionService: CollectionService + + beforeEach(async () => { + TestBed.configureTestingModule({ + declarations: [ + ExportCollectionComponent + ], + providers: [ + { provide: CollectionService, useClass: CollectionServiceStub }, + ], + imports: [ + HttpClientTestingModule + ] + }) + }) + + beforeEach(() => { + fixture = TestBed.createComponent(ExportCollectionComponent) + collectionService = TestBed.inject(CollectionService) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("getCollectionCsv should work", () => { + const uuid = "d18ef6a0-e8f6-49b1-92bb-eadfaef6b1a9" + const entityName = "Collection name" + const spyLoader = spyOn(component["toastService"], "showBlockingLoader") + const spyService = spyOn(collectionService, "requestCollectionSkillsCsv").and.callThrough() + component.getCollectionCsv(uuid, entityName) + expect(spyLoader) + expect(spyService).toHaveBeenCalled() + }) + + it("getCollectionXlsx should work", () => { + const uuid = "d18ef6a0-e8f6-49b1-92bb-eadfaef6b1a9" + const entityName = "Collection name" + const spyLoader = spyOn(component["toastService"], "showBlockingLoader") + const spyService = spyOn(collectionService, "requestCollectionSkillsXlsx").and.callThrough() + component.getCollectionXlsx(uuid, entityName) + expect(spyLoader) + expect(spyService).toHaveBeenCalled() + }) + + it("pollCsv should return", () => { + component.taskUuidInProgress = undefined + const spy = spyOn(collectionService, "getCsvTaskResultsIfComplete").and.callThrough() + component.pollCsv() + expect(spy).not.toHaveBeenCalled() + }) + + it("pollCsv should call getCsvTaskResultIfComplete", () => { + component.taskUuidInProgress = "345-dfh-23421a-as3423" + const spy = spyOn(collectionService, "getCsvTaskResultsIfComplete").and.callThrough() + component.pollCsv() + expect(spy).toHaveBeenCalled() + }) + + it("pollXlsx should return", () => { + component.taskUuidInProgress = undefined + const spy = spyOn(collectionService, "getXlsxTaskResultsIfComplete").and.callThrough() + component.pollXlsx() + expect(spy).not.toHaveBeenCalled() + }) + + it("pollXlsx should call getCsvTaskResultIfComplete", () => { + component.taskUuidInProgress = "345-dfh-23421a-as3423" + const spy = spyOn(collectionService, "getXlsxTaskResultsIfComplete").and.callThrough() + component.pollXlsx() + expect(spy).toHaveBeenCalled() + }) +}) diff --git a/ui/src/app/export/export-collection.component.ts b/ui/src/app/export/export-collection.component.ts new file mode 100644 index 000000000..03c8bc998 --- /dev/null +++ b/ui/src/app/export/export-collection.component.ts @@ -0,0 +1,90 @@ +import { formatDate } from "@angular/common"; +import { Component, Inject, LOCALE_ID } from "@angular/core"; + +import { CollectionService } from "../collection/service/collection.service"; +import { ITaskResult } from "../task/ApiTaskResult"; +import { ToastService } from "../toast/toast.service"; + +import * as FileSaver from "file-saver"; + +@Component({ + selector: "app-export-collection", + template: `` +}) +export class ExportCollectionComponent { + uuid = ""; + entityName = ""; + + taskUuidInProgress: string | undefined; + intervalHandle: number | undefined; + + constructor( + protected collectionService: CollectionService, + protected toastService: ToastService, + @Inject(LOCALE_ID) protected locale: string + ) {}; + + getCollectionCsv(uuid: string, entityName: string): void { + this.uuid = uuid; + this.entityName = entityName; + this.toastService.showBlockingLoader() + this.collectionService.requestCollectionSkillsCsv(this.uuid) + .subscribe((taskStarted: ITaskResult) => { + this.taskUuidInProgress = taskStarted.uuid; + this.intervalHandle = setInterval(() => this.pollCsv(), 1000); + }); + }; + + getCollectionXlsx(uuid: string, entityName: string): void { + this.uuid = uuid; + this.entityName = entityName; + this.toastService.showBlockingLoader() + this.collectionService.requestCollectionSkillsXlsx(this.uuid) + .subscribe((taskStarted: ITaskResult) => { + this.taskUuidInProgress = taskStarted.uuid; + this.intervalHandle = setInterval(() => this.pollXlsx(), 1000); + }); + }; + + pollCsv(): void { + if (this.taskUuidInProgress === undefined) { // fail fast + clearInterval(this.intervalHandle); + return; + } + + this.collectionService.getCsvTaskResultsIfComplete(this.taskUuidInProgress) + .subscribe(({body, status}) => { + if (status === 200) { + this.taskUuidInProgress = undefined; + + clearInterval(this.intervalHandle); + + 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 - ${this.entityName} - ${date}.csv`); + this.toastService.hideBlockingLoader() + } + }); + }; + + pollXlsx(): void { + if (this.taskUuidInProgress === undefined) { // fail fast + clearInterval(this.intervalHandle); + return; + } + + this.collectionService.getXlsxTaskResultsIfComplete(this.taskUuidInProgress) + .subscribe(({body, status}) => { + if (status === 200) { + this.taskUuidInProgress = undefined; + + clearInterval(this.intervalHandle); + + const blob = new Blob([body], { type: "application/vnd.ms-excel;charset=utf-8;" }); + const date = formatDate(new Date(), "yyyy-MM-dd", this.locale); + FileSaver.saveAs(blob, `RSD Skills Excel - ${this.entityName} - ${date}.xlsx`); + this.toastService.hideBlockingLoader() + } + }); + }; +} diff --git a/ui/src/app/export/export-rsd-component.spec.ts b/ui/src/app/export/export-rsd-component.spec.ts new file mode 100644 index 000000000..93c84758f --- /dev/null +++ b/ui/src/app/export/export-rsd-component.spec.ts @@ -0,0 +1,77 @@ +import { HttpClientTestingModule } from "@angular/common/http/testing" +import { ComponentFixture, TestBed } from "@angular/core/testing" + +import { ExportRsdComponent } from "./export-rsd.component" +import { RichSkillService } from "../richskill/service/rich-skill.service" +import { RichSkillServiceStub } from "../../../test/resource/mock-stubs" + +describe("ExportRsdComponent", () => { + let component: ExportRsdComponent + let fixture: ComponentFixture + let richSkillService: RichSkillService + + beforeEach(async () => { + TestBed.configureTestingModule({ + declarations: [ + ExportRsdComponent + ], + providers: [ + { provide: RichSkillService, useClass: RichSkillServiceStub }, + ], + imports: [ + HttpClientTestingModule + ] + }) + }) + + beforeEach(() => { + fixture = TestBed.createComponent(ExportRsdComponent) + richSkillService = TestBed.inject(RichSkillService) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("getRsdCsv should work", () => { + const uuid = "384c3133-2e1b-4703-9225-b666ccda5879" + const entityName = "RSD name" + const spyLoader = spyOn(component["toastService"], "showBlockingLoader") + const spyService = spyOn(richSkillService, "getSkillCsvByUuid").and.callThrough() + component.getRsdCsv(uuid, entityName) + expect(spyLoader) + expect(spyService).toHaveBeenCalled() + }) + + it("getRsdXlsx should work", () => { + const uuid = "384c3133-2e1b-4703-9225-b666ccda5879" + const entityName = "RSD name" + const spyLoader = spyOn(component["toastService"], "showBlockingLoader") + const spyService = spyOn(richSkillService, "exportSearchXlsx").and.callThrough() + component.getRsdXlsx(uuid, entityName) + expect(spyLoader) + expect(spyService).toHaveBeenCalled() + }) + + it("exportSearchCsv should return", () => { + const uuids = ["384c3133-2e1b-4703-9225-b666ccda5879"] + const entityName = ["RSD name"] + const spyLoader = spyOn(component["toastService"], "showBlockingLoader") + const spyService = spyOn(richSkillService, "exportSearchCsv").and.callThrough() + component.exportSearchCsv(uuids, entityName) + expect(spyLoader) + expect(spyService).toHaveBeenCalled() + }) + + it("exportSearchXlsx should return", () => { + const uuids = ["384c3133-2e1b-4703-9225-b666ccda5879"] + const entityName = ["RSD name"] + const spyLoader = spyOn(component["toastService"], "showBlockingLoader") + const spyService = spyOn(richSkillService, "exportSearchXlsx").and.callThrough() + component.exportSearchXlsx(uuids, entityName) + expect(spyLoader) + expect(spyService).toHaveBeenCalled() + }) +}) diff --git a/ui/src/app/export/export-rsd.component.ts b/ui/src/app/export/export-rsd.component.ts new file mode 100644 index 000000000..8b6a6fd32 --- /dev/null +++ b/ui/src/app/export/export-rsd.component.ts @@ -0,0 +1,120 @@ +import { formatDate } from "@angular/common"; +import { Component, Inject, LOCALE_ID } from "@angular/core"; + +import { RichSkillService } from "../richskill/service/rich-skill.service"; +import { ApiTaskResult } from "../task/ApiTaskResult"; +import { ToastService } from "../toast/toast.service"; + +import * as FileSaver from "file-saver"; + +@Component({ + selector: "app-export-rsd", + template: `` +}) +export class ExportRsdComponent { + uuid = ""; + matchingQuery: string[] = [""]; + + taskUuidInProgress: string | undefined; + intervalHandle: number | undefined; + + constructor( + protected richSkillService: RichSkillService, + protected toastService: ToastService, + @Inject(LOCALE_ID) protected locale: string + ) { + } + + getRsdCsv(uuid: string, entityName: string): void { + this.uuid = uuid; + const skillExportName = entityName ? entityName : "OSMT Skill" + this.toastService.showBlockingLoader() + this.richSkillService.getSkillCsvByUuid(this.uuid) + .subscribe((csv: string) => { + 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 Skills - ${skillExportName} ${date}.csv`) + this.toastService.hideBlockingLoader() + }) + } + + getRsdXlsx(uuid: string, entityName: string): void { + // Implementation differs from getRsdCsv, the below endpoint schedules a task + this.matchingQuery = [uuid] + this.richSkillService.exportSearchXlsx(this.matchingQuery) + .subscribe((apiTask) => { + this.richSkillService.getResultExportedXlsxLibrary( + apiTask.id.slice(1)).subscribe( + response => { + this.downloadAsXlsx(response.body) + } + ) + }) + } + + exportSearchCsv(uuids: string[], matchingQuery: string[]): void { + this.matchingQuery = matchingQuery; + this.richSkillService.exportSearchCsv(uuids) + .subscribe((apiTask) => { + this.richSkillService.getResultExportedCsvLibrary(apiTask.id.slice(1)).subscribe( + response => { + this.downloadAsCsv(response.body) + } + ) + }) + } + + exportSearchXlsx(uuids: string[], matchingQuery: string[]): void { + this.matchingQuery = matchingQuery; + this.richSkillService.exportSearchXlsx(uuids) + .subscribe((apiTask) => { + this.richSkillService.getResultExportedXlsxLibrary( + apiTask.id.slice(1) + ).subscribe( + response => { + this.downloadAsXlsx(response.body) + } + ) + }) + } + + exportLibraryCsv(): void { + this.matchingQuery = ["Library Export"] + this.richSkillService.libraryExportCsv() + .subscribe((apiTaskResult: ApiTaskResult) => { + this.richSkillService.getResultExportedCsvLibrary( + apiTaskResult.id.slice(1) + ).subscribe( + response => { + this.downloadAsCsv(response.body) + } + ) + }) + } + + exportLibraryXlsx(): void { + this.matchingQuery = ["Library Export"] + this.richSkillService.libraryExportXlsx() + .subscribe((apiTaskResult: ApiTaskResult) => { + this.richSkillService.getResultExportedXlsxLibrary( + apiTaskResult.id.slice(1) + ).subscribe( + response => { + this.downloadAsXlsx(response.body) + } + ) + }) + } + + private downloadAsCsv(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 Skills - ${this.matchingQuery} - ${date}.csv`) + } + + private downloadAsXlsx(body: string): void { + const blob = new Blob([body], { type: "application/vnd.ms-excel;charset=utf-8;" }); + const date = formatDate(new Date(), "yyyy-MM-dd", this.locale); + FileSaver.saveAs(blob, `RSD Skills - ${this.matchingQuery} - ${date}.xlsx`); + } +} diff --git a/ui/src/app/my-workspace/my-workspace.component.spec.ts b/ui/src/app/my-workspace/my-workspace.component.spec.ts index 083737a42..68462cd1d 100644 --- a/ui/src/app/my-workspace/my-workspace.component.spec.ts +++ b/ui/src/app/my-workspace/my-workspace.component.spec.ts @@ -163,4 +163,26 @@ describe("MyWorkspaceComponent", () => { expect(auditLog).toBeFalsy() }) + it("Download should have submenu", () => { + const actionDownload = component.actionDefinitions()[1] + expect(actionDownload?.menu?.length).toEqual(2) + }) + + it("Menu action download should call callback correctly", () => { + const actionDownload = component.actionDefinitions()[1] + const downloadXlsx = actionDownload.menu?.pop() + const downloadCsv = actionDownload.menu?.pop() + const spyDownloadCsv = spyOn(component.exporter, "getCollectionCsv") + const spyDownloadXlsx = spyOn(component.exporter, "getCollectionXlsx") + expect(actionDownload).toBeTruthy() + if (downloadCsv?.callback && actionDownload) { + downloadCsv.callback(actionDownload) + expect(spyDownloadCsv).toHaveBeenCalled() + } + if (downloadXlsx?.callback && actionDownload) { + downloadXlsx.callback(actionDownload) + expect(spyDownloadXlsx).toHaveBeenCalled() + } + }) + }) diff --git a/ui/src/app/my-workspace/my-workspace.component.ts b/ui/src/app/my-workspace/my-workspace.component.ts index 45196cd2e..b43cfbcc0 100644 --- a/ui/src/app/my-workspace/my-workspace.component.ts +++ b/ui/src/app/my-workspace/my-workspace.component.ts @@ -1,13 +1,14 @@ -import {Component, Inject, LOCALE_ID, OnInit} from "@angular/core" -import {ManageCollectionComponent} from "../collection/detail/manage-collection.component" -import {RichSkillService} from "../richskill/service/rich-skill.service" -import {ToastService} from "../toast/toast.service" -import {CollectionService} from "../collection/service/collection.service" -import {ActivatedRoute, Router} from "@angular/router" -import {Title} from "@angular/platform-browser" -import {AuthService} from "../auth/auth-service" -import {TableActionDefinition} from "../table/skills-library-table/has-action-definitions" -import {ApiSearch} from "../richskill/service/rich-skill-search.service" +import { Component, Inject, LOCALE_ID, OnInit } from "@angular/core" +import { Title } from "@angular/platform-browser" +import { ActivatedRoute, Router } from "@angular/router" + +import { AuthService } from "../auth/auth-service" +import { ManageCollectionComponent } from "../collection/detail/manage-collection.component" +import { CollectionService } from "../collection/service/collection.service" +import { RichSkillService } from "../richskill/service/rich-skill.service" +import { ApiSearch } from "../richskill/service/rich-skill-search.service" +import { TableActionDefinition } from "../table/skills-library-table/has-action-definitions" +import { ToastService } from "../toast/toast.service" export const WORKSPACE_COLLECTIONS_UUIDS = "workspace-collections-uuids" @@ -54,9 +55,26 @@ export class MyWorkspaceComponent extends ManageCollectionComponent implements O visible: () => true }), new TableActionDefinition({ - label: "Download as CSV", + label: "Download", icon: this.downloadIcon, - callback: () => this.generateCsv(this.collection?.name ?? ""), + menu: [ + { + label: "Download as CSV", + visible: () => true, + callback: () => this.exporter.getCollectionCsv( + this.uuidParam ?? "", + this.collection?.name ?? "" + ) + }, + { + label: "Download as Excel Workbook", + visible: () => true, + callback: () => this.exporter.getCollectionXlsx( + this.uuidParam ?? "", + this.collection?.name ?? "" + ), + } + ], visible: () => !this.workspaceEmpty() }), new TableActionDefinition({ diff --git a/ui/src/app/navigation/libraryexport.component.html b/ui/src/app/navigation/libraryexport.component.html index c840b94db..192dbe70e 100644 --- a/ui/src/app/navigation/libraryexport.component.html +++ b/ui/src/app/navigation/libraryexport.component.html @@ -1,6 +1,13 @@ - +
+ + + +
diff --git a/ui/src/app/navigation/libraryexport.component.scss b/ui/src/app/navigation/libraryexport.component.scss new file mode 100644 index 000000000..a069bae70 --- /dev/null +++ b/ui/src/app/navigation/libraryexport.component.scss @@ -0,0 +1,4 @@ +.dropup:hover .dropup-content.dropup-content-visible { + margin-left: 20px; + margin-bottom: -133px; +} diff --git a/ui/src/app/navigation/libraryexport.component.spec.ts b/ui/src/app/navigation/libraryexport.component.spec.ts index 50eeb193e..185959a8a 100644 --- a/ui/src/app/navigation/libraryexport.component.spec.ts +++ b/ui/src/app/navigation/libraryexport.component.spec.ts @@ -56,15 +56,9 @@ describe("LibraryExportComponent", () => { it("Should call export library with result", () => { const service = TestBed.inject(RichSkillService) - const spy = spyOn(service, "libraryExport").and.returnValue(of(apiTaskResultForCSV)) - component.onDownloadLibrary() + const spy = spyOn(service, "libraryExportCsv").and.returnValue(of(apiTaskResultForCSV)) + component.exporter.exportLibraryCsv() 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 index 29ac42841..7ceb4caef 100644 --- a/ui/src/app/navigation/libraryexport.component.ts +++ b/ui/src/app/navigation/libraryexport.component.ts @@ -1,24 +1,34 @@ -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" +import { Component, OnInit, LOCALE_ID, Inject } from "@angular/core" +import { ActivatedRoute } from "@angular/router" + +import { AbstractSearchComponent } from "./abstract-search.component" +import { AuthService } from "../auth/auth-service" +import { SvgHelper, SvgIcon } from "../core/SvgHelper" +import { ExportRsdComponent } from "../export/export-rsd.component" +import { RichSkillService } from "../richskill/service/rich-skill.service" +import { SearchService } from "../search/search.service" +import { TableActionDefinition } from "../table/skills-library-table/has-action-definitions" +import { ToastService } from "../toast/toast.service" @Component({ selector: "app-libraryexport", - templateUrl: "./libraryexport.component.html" + templateUrl: "./libraryexport.component.html", + styleUrls: [ + "../table/skills-library-table/action-bar-item.components.scss", + "./libraryexport.component.scss", + ] }) export class LibraryExportComponent extends AbstractSearchComponent implements OnInit { searchIcon = SvgHelper.path(SvgIcon.SEARCH) dismissIcon = SvgHelper.path(SvgIcon.DISMISS) + exporter = new ExportRsdComponent( + this.richSkillService, + this.toastService, + this.locale + ); + constructor( protected searchService: SearchService, protected route: ActivatedRoute, @@ -33,23 +43,22 @@ export class LibraryExportComponent extends AbstractSearchComponent implements O 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`) + get action(): TableActionDefinition { + return new TableActionDefinition({ + menu: [ + { + label: "Download as CSV", + visible: () => true, + callback: () => this.exporter.exportLibraryCsv(), + }, + { + label: "Download as Excel Workbook", + visible: () => true, + callback: () => this.exporter.exportLibraryXlsx(), + } + ], + visible: () => true + }) } } diff --git a/ui/src/app/richskill/detail/AbstractRichSkillDetailComponent.ts b/ui/src/app/richskill/detail/AbstractRichSkillDetailComponent.ts index 34aa28c0a..a2aed5b33 100644 --- a/ui/src/app/richskill/detail/AbstractRichSkillDetailComponent.ts +++ b/ui/src/app/richskill/detail/AbstractRichSkillDetailComponent.ts @@ -90,17 +90,17 @@ export abstract class AbstractRichSkillDetailComponent extends QuickLinksHelper return this.joinGenericKeywords("; ", employers) } - private joinList(delimeter: string, list: string[]): string { + private joinList(delimiter: string, list: string[]): string { return list .filter(item => item) - .join(delimeter) + .join(delimiter) } - private joinGenericKeywords(delimeter: string, keywords: INamedReference[]): string { + private joinGenericKeywords(delimiter: string, keywords: INamedReference[]): string { const filteredList: string[] = keywords .map(keyword => (keyword.name ? keyword.name : keyword.id) as string) - return this.joinList(delimeter, filteredList) + return this.joinList(delimiter, filteredList) } protected formatAssociatedCollections(isAuthorized: boolean): string { diff --git a/ui/src/app/richskill/detail/rich-skill-public/action-bar/action-bar-horizontal/public-skill-action-bar-horizontal.component.html b/ui/src/app/richskill/detail/rich-skill-public/action-bar/action-bar-horizontal/public-skill-action-bar-horizontal.component.html index 0220002f8..bd44c8c8c 100644 --- a/ui/src/app/richskill/detail/rich-skill-public/action-bar/action-bar-horizontal/public-skill-action-bar-horizontal.component.html +++ b/ui/src/app/richskill/detail/rich-skill-public/action-bar/action-bar-horizontal/public-skill-action-bar-horizontal.component.html @@ -16,17 +16,8 @@ - + + - + +