diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt index 0727d03db..6739bca85 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiNamedReference.kt @@ -7,7 +7,6 @@ import edu.wgu.osmt.db.JobCodeLevel import edu.wgu.osmt.jobcode.JobCode import edu.wgu.osmt.keyword.Keyword - @JsonInclude(JsonInclude.Include.NON_EMPTY) data class ApiNamedReference( val id: String? = null, @@ -75,16 +74,44 @@ data class ApiStringListUpdate( @JsonInclude(JsonInclude.Include.NON_EMPTY) data class ApiJobCode( + val id: Long? = null, val code: String, val targetNode: String? = null, val targetNodeName: String? = null, val frameworkName: String? = null, val level: JobCodeLevel? = null, - val parents: List? = null + val parents: List? = null, + val jobCodeLevelAsNumber: Int? = null ) { companion object factory { fun fromJobCode(jobCode: JobCode, level: JobCodeLevel? = null, parents: List? = null): ApiJobCode { - return ApiJobCode(code=jobCode.code, targetNodeName=jobCode.name, targetNode=jobCode.url, frameworkName=jobCode.framework, level=level, parents=parents) + return ApiJobCode( + id = jobCode.id, + code = jobCode.code, + targetNodeName = jobCode.name, + targetNode = jobCode.url, + frameworkName = jobCode.framework, + level = level, + parents = parents, + jobCodeLevelAsNumber = jobCode.jobCodeLevelAsNumber + ) + } + + fun getLevelFromJobCode(jobCode: JobCode): JobCodeLevel { + return when (jobCode.code) { + jobCode.majorCode -> { + JobCodeLevel.Major + } + jobCode.minorCode -> { + JobCodeLevel.Minor + } + jobCode.broadCode -> { + JobCodeLevel.Broad + } + else -> { + JobCodeLevel.Detailed + } + } } } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/JobCodeSortEnum.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/JobCodeSortEnum.kt new file mode 100644 index 000000000..cb6d2a03f --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/JobCodeSortEnum.kt @@ -0,0 +1,38 @@ +package edu.wgu.osmt.api.model + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.data.domain.Sort + +enum class JobCodeSortEnum(override val apiValue: String): SortOrder { + NameAsc("name.asc") { + override val sort = Sort.by("name.sort_insensitive").ascending() + }, + NameDesc("name.desc") { + override val sort = Sort.by("name.sort_insensitive").descending() + }, + CodeAsc("code.asc") { + override val sort = Sort.by("code.keyword").ascending() + }, + CodeDesc("code.desc") { + override val sort = Sort.by("code.keyword").descending() + }, + JobCodeAsc("jobCodeLevel.asc") { + override val sort = Sort.by("jobCodeLevelAsNumber").ascending() + }, + JobCodeDesc("jobCodeLevel.desc") { + override val sort = Sort.by("jobCodeLevelAsNumber").descending() + }; + + companion object : SortOrderCompanion { + override val logger: Logger = LoggerFactory.getLogger(JobCodeSortEnum::class.java) + + override val defaultSort = NameAsc + + override fun forApiValue(apiValue: String): JobCodeSortEnum { + return values().find { it.apiValue == apiValue } ?: NameAsc.also { + logger.warn("Sort with value $apiValue could not be found; using default ${NameAsc.apiValue} sort") + } + } + } +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/JobCodeUpdate.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/JobCodeUpdate.kt index 509448550..47eb9b621 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/api/model/JobCodeUpdate.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/JobCodeUpdate.kt @@ -17,4 +17,4 @@ data class JobCodeUpdate( @JsonProperty("parents") val parents: List? = null ) { -} \ No newline at end of file +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCode.kt b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCode.kt index f00bcb7ca..ffe3041b4 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCode.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCode.kt @@ -3,6 +3,7 @@ package edu.wgu.osmt.jobcode import com.fasterxml.jackson.annotation.JsonIgnore import edu.wgu.osmt.config.INDEX_JOBCODE_DOC import edu.wgu.osmt.db.DatabaseData +import edu.wgu.osmt.db.JobCodeLevel import org.elasticsearch.core.Nullable import org.springframework.data.elasticsearch.annotations.* import java.time.LocalDateTime @@ -75,7 +76,8 @@ data class JobCode( otherFields = [ InnerField(suffix = "", type = FieldType.Search_As_You_Type), InnerField(suffix = "raw", analyzer = "whitespace_exact", type = FieldType.Text), - InnerField(suffix = "keyword", type = FieldType.Keyword) + InnerField(suffix = "keyword", type = FieldType.Keyword), + InnerField(suffix = "sort_insensitive", type = FieldType.Keyword, normalizer = "lowercase_normalizer") ] ) val name: String? = null, // human readable label @@ -121,7 +123,24 @@ data class JobCode( @Field @Nullable - val jobRoleCode: String? = JobCodeBreakout.jobRoleCode(code) + val jobRoleCode: String? = JobCodeBreakout.jobRoleCode(code), + + @Field(excludeFromSource = true) + @Nullable + val jobCodeLevelAsNumber: Int = when (code) { + majorCode -> { + 1 + } + minorCode -> { + 2 + } + broadCode -> { + 3 + } + else -> { + 4 + } + } ) : DatabaseData { companion object { diff --git a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeController.kt b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeController.kt index 99fb58d6c..57bd48475 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeController.kt @@ -2,12 +2,17 @@ package edu.wgu.osmt.jobcode; import edu.wgu.osmt.RoutePaths import edu.wgu.osmt.api.model.ApiJobCode +import edu.wgu.osmt.api.model.JobCodeSortEnum import edu.wgu.osmt.api.model.JobCodeUpdate +import edu.wgu.osmt.db.JobCodeLevel import edu.wgu.osmt.elasticsearch.OffsetPageable +import edu.wgu.osmt.task.RemoveJobCodeTask +import edu.wgu.osmt.task.Task +import edu.wgu.osmt.task.TaskMessageService import edu.wgu.osmt.task.TaskResult -import edu.wgu.osmt.task.TaskStatus import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -26,7 +31,8 @@ import org.springframework.web.server.ResponseStatusException @Transactional class JobCodeController @Autowired constructor( val jobCodeEsRepo: JobCodeEsRepo, - val jobCodeRepository: JobCodeRepository + val jobCodeRepository: JobCodeRepository, + val taskMessageService: TaskMessageService, ) { @GetMapping(RoutePaths.JOB_CODE_LIST, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -34,10 +40,34 @@ class JobCodeController @Autowired constructor( fun allPaginated( @RequestParam(required = true) size: Int, @RequestParam(required = true) from: Int, - @RequestParam(required = false) sort: String? + @RequestParam(required = false) sort: String?, + @RequestParam(required = false) query: String? ): HttpEntity> { - val searchResults = jobCodeEsRepo.typeAheadSearch("", OffsetPageable(from, size, null)) - return ResponseEntity.status(200).body(searchResults.map { ApiJobCode.fromJobCode(it.content) }.toList()) + val sortEnum: JobCodeSortEnum = JobCodeSortEnum.forValueOrDefault(sort) + val searchResults = jobCodeEsRepo.typeAheadSearch(query, OffsetPageable(from, size, sortEnum.sort)) + val responseHeaders = HttpHeaders() + responseHeaders.add("X-Total-Count", searchResults.totalHits.toString()) + return ResponseEntity.status(200).headers(responseHeaders).body(searchResults.map { + val jobCodeLevel = ApiJobCode.getLevelFromJobCode(it.content) + val parents = mutableListOf() + val majorCode = it.content.majorCode + val minorCode = it.content.minorCode + val broadCode = it.content.broadCode + val detailedCode = it.content.detailedCode + if (detailedCode != null && jobCodeLevel != JobCodeLevel.Detailed) { + jobCodeRepository.findByCode(detailedCode)?.let { jobCodeDao -> parents.add(jobCodeDao) } + } + if (broadCode != null && jobCodeLevel != JobCodeLevel.Broad) { + jobCodeRepository.findByCode(broadCode)?.let { jobCodeDao -> parents.add(jobCodeDao) } + } + if (minorCode != null && jobCodeLevel != JobCodeLevel.Minor) { + jobCodeRepository.findByCode(minorCode)?.let { jobCodeDao -> parents.add(jobCodeDao) } + } + if (majorCode != null && jobCodeLevel != JobCodeLevel.Major) { + jobCodeRepository.findByCode(majorCode)?.let { jobCodeDao -> parents.add(jobCodeDao) } + } + ApiJobCode.fromJobCode(it.content, jobCodeLevel, parents.map { it2 -> ApiJobCode.fromJobCode(it2.toModel(), ApiJobCode.getLevelFromJobCode(it2.toModel())) }) + }.toList()) } @GetMapping(RoutePaths.JOB_CODE_DETAIL, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -68,7 +98,7 @@ class JobCodeController @Autowired constructor( @PathVariable id: Int, @RequestBody jobCodeUpdate: JobCodeUpdate ): HttpEntity { - return ResponseEntity.status(200).body(ApiJobCode(code = "1", targetNode = "target", targetNodeName = "targetNodeName", frameworkName = "frameworkName", parents = listOf())) + return ResponseEntity.status(200).body(ApiJobCode(id = 1, code = "1", targetNode = "target", targetNodeName = "targetNodeName", frameworkName = "frameworkName", parents = listOf())) } @DeleteMapping(RoutePaths.JOB_CODE_REMOVE) @@ -76,7 +106,9 @@ class JobCodeController @Autowired constructor( fun deleteJobCode( @PathVariable id: Int, ): HttpEntity { - return ResponseEntity.status(200).body(TaskResult(uuid = "uuid", contentType = "application/json", status = TaskStatus.Processing, apiResultPath = "path")) + val task = RemoveJobCodeTask(jobCodeId = id.toLong()) + taskMessageService.enqueueJob(TaskMessageService.removeJobCode, task) + return Task.processingResponse(task) } -} \ No newline at end of file +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt index f2e1b21a7..fae1a425a 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt @@ -20,7 +20,7 @@ interface CustomJobCodeRepository { val elasticSearchTemplate: ElasticsearchRestTemplate fun typeAheadSearch(query: String): SearchHits - fun typeAheadSearch(query: String, pageable: OffsetPageable): SearchHits + fun typeAheadSearch(query: String?, pageable: OffsetPageable): SearchHits fun deleteIndex() { elasticSearchTemplate.indexOps(IndexCoordinates.of(INDEX_JOBCODE_DOC)).delete() @@ -30,12 +30,12 @@ interface CustomJobCodeRepository { class CustomJobCodeRepositoryImpl @Autowired constructor(override val elasticSearchTemplate: ElasticsearchRestTemplate) : CustomJobCodeRepository { - override fun typeAheadSearch(query: String, pageable: OffsetPageable): SearchHits { - val nsq: NativeSearchQueryBuilder - val disjunctionQuery = JobCodeQueries.multiPropertySearch(query) - nsq = - NativeSearchQueryBuilder().withPageable(pageable).withQuery(disjunctionQuery) - .withSort(SortBuilders.fieldSort("${JobCode::code.name}.keyword").order(SortOrder.ASC)) + override fun typeAheadSearch(query: String?, pageable: OffsetPageable): SearchHits { + val nsq: NativeSearchQueryBuilder = NativeSearchQueryBuilder().withPageable(pageable) + if (!query.isNullOrEmpty()) { + val disjunctionQuery = JobCodeQueries.multiPropertySearch(query) + nsq.withQuery(disjunctionQuery) + } return elasticSearchTemplate.search(nsq.build(), JobCode::class.java) } diff --git a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeRepository.kt b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeRepository.kt index 32138c1de..b5225c9f8 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeRepository.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeRepository.kt @@ -1,10 +1,15 @@ package edu.wgu.osmt.jobcode +import edu.wgu.osmt.api.model.ApiBatchResult import edu.wgu.osmt.api.model.JobCodeUpdate +import edu.wgu.osmt.db.JobCodeLevel +import edu.wgu.osmt.richskill.RichSkillJobCodeRepository import org.jetbrains.exposed.sql.SizedIterable import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Repository @@ -18,10 +23,12 @@ interface JobCodeRepository { fun findById(id: Long): JobCodeDao? fun findByCode(code: String): JobCodeDao? fun findByCodeOrCreate(code: String, framework: String? = null): JobCodeDao + fun hasChildren(jobCodeDao: JobCodeDao): Boolean fun findBlsCode(code: String): JobCodeDao? fun create(code: String, framework: String? = null): JobCodeDao fun createFromApi(jobCodes: List): List fun onetsByDetailCode(detailedCode: String): SizedIterable + fun remove(jobCodeId: Long): ApiBatchResult companion object { const val BLS_FRAMEWORK = "bls" @@ -37,6 +44,10 @@ class JobCodeRepositoryImpl: JobCodeRepository { @Lazy lateinit var jobCodeEsRepo: JobCodeEsRepo + @Autowired + @Lazy + lateinit var richSkillJobCodeRepository: RichSkillJobCodeRepository + val dao = JobCodeDao.Companion override val table = JobCodeTable @@ -61,7 +72,6 @@ class JobCodeRepositoryImpl: JobCodeRepository { this.framework = jobCodeUpdate.framework this.name = jobCodeUpdate.targetNodeName this.creationDate = LocalDateTime.now(ZoneOffset.UTC) - this.name = "my name" this.major = "my major" }.also { jobCodeEsRepo.save(it.toModel()) } } @@ -72,6 +82,16 @@ class JobCodeRepositoryImpl: JobCodeRepository { return existing ?: create(code, framework) } + override fun hasChildren(jobCodeDao: JobCodeDao): Boolean { + return findAll().any { jobCode -> + jobCode.code != jobCodeDao.code && + (jobCodeDao.code == JobCodeBreakout.majorCode(jobCode.code) || + jobCodeDao.code == JobCodeBreakout.minorCode(jobCode.code) || + jobCodeDao.code == JobCodeBreakout.broadCode(jobCode.code) || + jobCodeDao.code == JobCodeBreakout.detailedCode(jobCode.code)) + } + } + override fun create(code: String, framework: String?): JobCodeDao { val maybeDetailed = JobCodeBreakout.detailedCode(code).let{ findBlsCode(code) } return dao.new { @@ -92,4 +112,50 @@ class JobCodeRepositoryImpl: JobCodeRepository { override fun onetsByDetailCode(detailedCode: String): SizedIterable { return table.select {table.code regexp "${detailedCode}.[0-90-9]"}.let{dao.wrapRows(it)} } + + override fun remove(jobCodeId: Long): ApiBatchResult { + val jobCodeFound = findById(jobCodeId) + val jobCodeEsFound = jobCodeEsRepo.findById(jobCodeId.toInt()) + var hasChildren = false + var hasRSDs = false + if (jobCodeFound != null && jobCodeEsFound.isPresent) { + hasChildren = hasChildren(jobCodeFound) + hasRSDs = richSkillJobCodeRepository.hasRSDs(jobCodeFound) + if (!hasChildren && !hasRSDs) { + transaction { + table.deleteWhere{ table.id eq jobCodeFound.id } + jobCodeEsRepo.delete(jobCodeEsFound.get()) + } + return ApiBatchResult( + success = true, + modifiedCount = 1, + totalCount = 1 + ) + } + } + return ApiBatchResult( + success = false, + modifiedCount = 0, + totalCount = 0, + message = JobCodeErrorMessages.forDeleteError(hasChildren, hasRSDs) + ) + } +} + +enum class JobCodeErrorMessages(val apiValue: String) { + JobCodeNotExist("You cannot delete this occupation because you doesn't exist"), + JobCodeHasChildren("You cannot delete this occupation because has children"), + JobCodeHasRSD("You cannot delete this occupation because is used in one or more RSDs"); + + companion object { + fun forDeleteError(hasChildren: Boolean, hasRSDs: Boolean): String { + return if (hasChildren) { + JobCodeHasChildren.apiValue + } else if (hasRSDs) { + JobCodeHasRSD.apiValue + } else { + JobCodeNotExist.apiValue + } + } + } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeTaskProcessor.kt b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeTaskProcessor.kt new file mode 100644 index 000000000..b9ea644f8 --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeTaskProcessor.kt @@ -0,0 +1,46 @@ +package edu.wgu.osmt.jobcode + +import com.github.sonus21.rqueue.annotation.RqueueListener +import edu.wgu.osmt.collection.UpdateCollectionSkillsTaskProcessor +import edu.wgu.osmt.task.RemoveJobCodeTask +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 javax.transaction.Transactional + +@Component +@Profile("apiserver") +@Transactional +class JobCodeTaskProcessor { + + val logger: Logger = LoggerFactory.getLogger(UpdateCollectionSkillsTaskProcessor::class.java) + + @Autowired + lateinit var taskMessageService: TaskMessageService + + @Autowired + lateinit var jobCodeRepository: JobCodeRepository + + @RqueueListener( + value = [TaskMessageService.removeJobCode], + deadLetterQueueListenerEnabled = "true", + deadLetterQueue = TaskMessageService.deadLetters, + concurrency = "1" + ) + fun removeJobCode(task: RemoveJobCodeTask) { + logger.info("Started processing to remove job code task id: ${task.jobCodeId}") + + val batchResult = jobCodeRepository.remove(task.jobCodeId) + + taskMessageService.publishResult( + task.copy(result=batchResult, status= TaskStatus.Ready) + ) + + logger.info("Task ${task.uuid} completed") + } + +} \ No newline at end of file diff --git a/api/src/main/kotlin/edu/wgu/osmt/keyword/ApiKeyword.kt b/api/src/main/kotlin/edu/wgu/osmt/keyword/ApiKeyword.kt index c530c8884..bd6499700 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/keyword/ApiKeyword.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/keyword/ApiKeyword.kt @@ -28,4 +28,4 @@ data class NamedReference( } } -} \ No newline at end of file +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/keyword/NamedReferencesController.kt b/api/src/main/kotlin/edu/wgu/osmt/keyword/NamedReferencesController.kt index a2dc13b2b..a8e88c0cb 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/keyword/NamedReferencesController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/keyword/NamedReferencesController.kt @@ -79,7 +79,14 @@ class NamedReferencesController @Autowired constructor( fun deleteNamedReference( @PathVariable id: Int, ): HttpEntity { - return ResponseEntity.status(200).body(TaskResult(uuid = "uuid", contentType = "application/json", status = TaskStatus.Processing, apiResultPath = "path")) + return ResponseEntity.status(200).body( + TaskResult( + uuid = "uuid", + contentType = "application/json", + status = TaskStatus.Processing, + apiResultPath = "path" + ) + ) } -} \ No newline at end of file +} diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillJobCodeRepository.kt b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillJobCodeRepository.kt new file mode 100644 index 000000000..706a0f201 --- /dev/null +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillJobCodeRepository.kt @@ -0,0 +1,23 @@ +package edu.wgu.osmt.richskill + +import edu.wgu.osmt.jobcode.JobCodeDao +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.select +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +interface RichSkillJobCodeRepository { + val table: Table + + fun hasRSDs(jobCodeDao: JobCodeDao): Boolean +} + +@Repository +@Transactional +class RichSkillJobCodeRepositoryImpl: RichSkillJobCodeRepository { + override val table = RichSkillJobCodes + + override fun hasRSDs(jobCodeDao: JobCodeDao): Boolean { + return !table.select { table.jobCodeId eq jobCodeDao.id }.empty() + } +} 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 39573e7af..e54aad73e 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt @@ -30,7 +30,8 @@ import java.util.* 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") + JsonSubTypes.Type(value = RemoveCollectionSkillsTask::class, name = "RemoveCollectionSkillsTask"), + JsonSubTypes.Type(value = RemoveJobCodeTask::class, name = "RemoveJobCodeTask") ) interface Task { @@ -165,6 +166,17 @@ data class RemoveCollectionSkillsTask( override val apiResultPath = RoutePaths.TASK_DETAIL_BATCH } +data class RemoveJobCodeTask( + val jobCodeId: Long = 0, + override val uuid: String = UUID.randomUUID().toString(), + override val start: Date = Date(), + override val result: ApiBatchResult? = null, + override val status: TaskStatus = TaskStatus.Processing, +): Task { + override val contentType = MediaType.APPLICATION_JSON_VALUE + override val apiResultPath = RoutePaths.TASK_DETAIL_BATCH +} + enum class TaskStatus { Processing, Ready 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 f8ac08e5d..3a99652ba 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/task/TaskMessageService.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/task/TaskMessageService.kt @@ -49,5 +49,6 @@ class TaskMessageService { const val skillsForFullLibraryXlsx = "full-library-skills-xlsx-process" const val skillsForCustomListExportCsv = "custom-rsd-list-csv-export" const val skillsForCustomListExportXlsx = "custom-rsd-list-xlsx-export" + const val removeJobCode = "remove-job-code" } } diff --git a/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionRepositoryTest.kt b/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionRepositoryTest.kt index 9f9207b15..8a2c995a6 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionRepositoryTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionRepositoryTest.kt @@ -69,8 +69,8 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes private fun random_skill(): RichSkillDescriptorDao { return richSkillRepository.create(RsdUpdateObject( - name=UUID.randomUUID().toString(), - statement=UUID.randomUUID().toString() + name =UUID.randomUUID().toString(), + statement =UUID.randomUUID().toString() ), userString)!! } @@ -247,4 +247,4 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes assertThat(batchResult?.success).isEqualTo(false) } -} \ No newline at end of file +} diff --git a/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeControllerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeControllerTest.kt index d722194fc..43487aadb 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeControllerTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeControllerTest.kt @@ -33,7 +33,7 @@ internal class JobCodeControllerTest @Autowired constructor( this.name = "my name" this.major = "my major" }.also { jobCodeEsRepo.save(it.toModel()) } - val result = jobCodeController.allPaginated(50, 0, null) + val result = jobCodeController.allPaginated(50, 0, "name.asc", "name") Assertions.assertThat(result.body).hasSizeGreaterThan(0) } @@ -75,12 +75,13 @@ internal class JobCodeControllerTest @Autowired constructor( @Test fun `Delete should return status 200`() { + jobCodeController.taskMessageService.rqueueMessageSender.registerQueue("remove-job-code") val result = jobCodeController.deleteJobCode( 1 ) Assertions.assertThat(result).isNotNull - Assertions.assertThat((result as ResponseEntity).statusCode).isEqualTo(HttpStatus.OK) + Assertions.assertThat((result as ResponseEntity).statusCode).isEqualTo(HttpStatus.ACCEPTED) } -} \ No newline at end of file +} diff --git a/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeErrorMessagesTest.kt b/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeErrorMessagesTest.kt new file mode 100644 index 000000000..6ea278932 --- /dev/null +++ b/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeErrorMessagesTest.kt @@ -0,0 +1,29 @@ +package edu.wgu.osmt.jobcode + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test + +class JobCodeErrorMessagesTest { + + @Test + fun `Error should be doesn't exist`() { + val hasChildren = false + val hasRSDs = false + Assertions.assertThat(JobCodeErrorMessages.forDeleteError(hasChildren, hasRSDs)).isEqualTo(JobCodeErrorMessages.JobCodeNotExist.apiValue) + } + + @Test + fun `Error should be job code has children`() { + val hasChildren = true + val hasRSDs = false + Assertions.assertThat(JobCodeErrorMessages.forDeleteError(hasChildren, hasRSDs)).isEqualTo(JobCodeErrorMessages.JobCodeHasChildren.apiValue) + } + + @Test + fun `Error should be job code has RSDs`() { + val hasChildren = false + val hasRSDs = true + Assertions.assertThat(JobCodeErrorMessages.forDeleteError(hasChildren, hasRSDs)).isEqualTo(JobCodeErrorMessages.JobCodeHasRSD.apiValue) + } + +} diff --git a/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeRepositoryTest.kt b/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeRepositoryTest.kt index c2c3ba327..3ce1cfb5b 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeRepositoryTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/jobcode/JobCodeRepositoryTest.kt @@ -40,4 +40,37 @@ class JobCodeRepositoryTest @Autowired constructor( assertThat(result.map{it.code}).containsAll(listOf(jobCode1, jobCode2, jobCode3)) assertThat(result.count()).isEqualTo(3) } + + @Test + fun `JobCode has children`() { + val majorJobCode = jobCodeRepository.create("95-0000") + jobCodeRepository.create("95-1000") + jobCodeRepository.create("95-1100") + val majorJobCodeHasChildren = jobCodeRepository.hasChildren(majorJobCode) + assertThat(majorJobCodeHasChildren).isEqualTo(true) + } + + @Test + fun `Job code doesn't have children`() { + val majorJobCode = jobCodeRepository.create("96-0000") + val majorJobCodeHasChildren = jobCodeRepository.hasChildren(majorJobCode) + assertThat(majorJobCodeHasChildren).isEqualTo(false) + } + + @Test + fun `Job code cannot be deleted`() { + val majorJobCode = jobCodeRepository.create("97-0000") + jobCodeRepository.create("97-1000") + val apiBatchResult = jobCodeRepository.remove(majorJobCode.id.value) + assertThat(apiBatchResult.success).isEqualTo(false) + assertThat(apiBatchResult.totalCount).isEqualTo(0) + } + + @Test + fun `Job code can be deleted`() { + val majorJobCode = jobCodeRepository.create("98-0000") + val apiBatchResult = jobCodeRepository.remove(majorJobCode.id.value) + assertThat(apiBatchResult.success).isEqualTo(true) + assertThat(apiBatchResult.totalCount).isEqualTo(1) + } } diff --git a/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordControllerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordControllerTest.kt index ca87f991a..dc54bed23 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordControllerTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordControllerTest.kt @@ -6,28 +6,18 @@ import edu.wgu.osmt.HasElasticsearchReset import edu.wgu.osmt.SpringTest import edu.wgu.osmt.api.model.KeywordSortEnum import edu.wgu.osmt.api.model.SkillSortEnum -import edu.wgu.osmt.api.model.SortOrder import edu.wgu.osmt.collection.CollectionEsRepo import edu.wgu.osmt.db.ListFieldUpdate import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.jobcode.JobCodeEsRepo import edu.wgu.osmt.mockdata.MockData -import edu.wgu.osmt.richskill.RichSkillDescriptor import edu.wgu.osmt.richskill.RichSkillEsRepo import edu.wgu.osmt.richskill.RichSkillRepository import edu.wgu.osmt.richskill.RsdUpdateObject -import io.mockk.InternalPlatformDsl.toArray -import io.mockk.every -import io.mockk.mockk import org.assertj.core.api.Assertions import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired -import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.context.SecurityContext -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.oauth2.core.user.OAuth2UserAuthority -import org.springframework.security.oauth2.jwt.Jwt import org.springframework.test.util.ReflectionTestUtils import org.springframework.transaction.annotation.Transactional import org.springframework.web.util.UriComponentsBuilder @@ -166,4 +156,4 @@ internal class KeywordControllerTest @Autowired constructor( Assertions.assertThat(result).isNotNull Assertions.assertThat(result.body?.size == 1) } -} \ No newline at end of file +} diff --git a/api/src/test/kotlin/edu/wgu/osmt/keyword/NamedReferencesControllerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/keyword/NamedReferencesControllerTest.kt index 29b7509f1..1973fa1e9 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/keyword/NamedReferencesControllerTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/keyword/NamedReferencesControllerTest.kt @@ -82,4 +82,4 @@ internal class NamedReferencesControllerTest @Autowired constructor( Assertions.assertThat((result as ResponseEntity).statusCode).isEqualTo(HttpStatus.OK) } -} \ No newline at end of file +} diff --git a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillJobCodeRepositoryTest.kt b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillJobCodeRepositoryTest.kt new file mode 100644 index 000000000..e26acc6ba --- /dev/null +++ b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillJobCodeRepositoryTest.kt @@ -0,0 +1,40 @@ +package edu.wgu.osmt.richskill + +import edu.wgu.osmt.BaseDockerizedTest +import edu.wgu.osmt.HasDatabaseReset +import edu.wgu.osmt.SpringTest +import edu.wgu.osmt.db.ListFieldUpdate +import edu.wgu.osmt.jobcode.JobCodeRepository +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional + +@Transactional +class RichSkillJobCodeRepositoryTest @Autowired constructor( + val richSkillRepository: RichSkillRepository, + val richSkillJobCodeRepository: RichSkillJobCodeRepository, + val jobCodeRepository: JobCodeRepository +) : SpringTest(), BaseDockerizedTest, HasDatabaseReset { + + + val userString = "unittestuser" + + @Test + fun `The job code should have RSDs`() { + val jobCodeDao = jobCodeRepository.create("95-0000") + richSkillRepository.create( + RsdUpdateObject(statement = "This is an statement", name = "Test 20230506", jobCodes = ListFieldUpdate(add = listOf(jobCodeDao))), + userString + ) + assertThat(richSkillJobCodeRepository.hasRSDs(jobCodeDao)).isEqualTo(true) + } + + @Test + fun `The job code should not have RSDs`() { + val jobCodeDao = jobCodeRepository.create("96-0000") + assertThat(richSkillJobCodeRepository.hasRSDs(jobCodeDao)).isEqualTo(false) + } + +} diff --git a/docs/int/openapi.yaml b/docs/int/openapi.yaml index 7ae132fe3..8b6c1299c 100644 --- a/docs/int/openapi.yaml +++ b/docs/int/openapi.yaml @@ -518,13 +518,14 @@ paths: /api/metadata/jobcodes: get: summary: Get all Job Codes - description: Returns all Job Codes + description: Returns all Job Codes with pagination, also supports search by query. tags: - Metadata parameters: - $ref: '#/components/parameters/PaginationSizeQueryRequired' - $ref: '#/components/parameters/PaginationFromQueryRequired' - $ref: '#/components/parameters/SortOrderQuery' + - $ref: '#/components/parameters/Query' responses: '200': description: OK @@ -549,7 +550,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/JobCode' + $ref: '#/components/schemas/JobCodeUpdate' responses: '200': description: OK @@ -607,7 +608,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/JobCode' + $ref: '#/components/schemas/JobCodeUpdate' responses: '200': description: OK @@ -1438,7 +1439,7 @@ components: items: type: string - JobCode: + JobCodeUpdate: type: object required: - code @@ -1456,7 +1457,16 @@ components: parents: type: array items: - $ref: '#/components/schemas/JobCode' + $ref: '#/components/schemas/JobCodeUpdate' + + JobCode: + allOf: + - $ref: '#/components/schemas/JobCodeUpdate' + - properties: + id: + type: number + jobCodeLevelAsNumber: + type: number JobCodeLevel: type: string @@ -2039,6 +2049,14 @@ components: items: $ref: '#/components/schemas/PublishStatus' + Query: + name: query + in: query + description: Matching query to search + required: false + schema: + type: string + SortOrderQuery: name: sort in: query diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index f28be2974..07a6a0a4d 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -117,7 +117,8 @@ import { JobCodeListRowComponent } from "./metadata/job-codes/job-code-list-row/ import { JobCodeTableComponent } from "./metadata/job-codes/job-code-table/job-code-table.component" import { NamedReferenceListRowComponent } from "./metadata/named-references/named-reference-list-row/named-reference-list-row.component" import { NamedReferenceTableComponent } from "./metadata/named-references/named-reference-table/named-reference-table.component" -import { MetadataSelectorComponent } from "./metadata/detail/metadata-selector/metadata-selector.component" +import { MetadataSelectorComponent } from "./metadata/detail/metadata-selector/metadata-selector.component"; +import { JobCodeParentsPipe } from "./pipes" export function initializeApp( appConfig: AppConfig, @@ -243,6 +244,7 @@ export function initializeApp( NamedReferenceListRowComponent, NamedReferenceTableComponent, MetadataSelectorComponent, + JobCodeParentsPipe, ], imports: [ NgIdleKeepaliveModule.forRoot(), diff --git a/ui/src/app/metadata/PaginatedMetadata.ts b/ui/src/app/metadata/PaginatedMetadata.ts index fb5bb9373..6273236e8 100644 --- a/ui/src/app/metadata/PaginatedMetadata.ts +++ b/ui/src/app/metadata/PaginatedMetadata.ts @@ -1,11 +1,15 @@ import { ApiJobCode } from "./job-codes/Jobcode" import { ApiNamedReference } from "./named-references/NamedReference" +import { PaginatedData } from "../models" + +export class PaginatedMetadata implements PaginatedData { -export class PaginatedMetadata { totalCount = 0 - metadata: ApiJobCode[]|ApiNamedReference[] = [] - constructor(metadata: ApiJobCode[]|ApiNamedReference[], totalCount: number) { - this.metadata = metadata + data: ApiJobCode[] | ApiNamedReference[] = [] + + constructor(data: ApiJobCode[] | ApiNamedReference[], totalCount: number) { + this.data = data this.totalCount = totalCount } + } diff --git a/ui/src/app/metadata/detail/metadata-list/metadata-list.component.html b/ui/src/app/metadata/detail/metadata-list/metadata-list.component.html index ef3cea520..7b86dcf1e 100644 --- a/ui/src/app/metadata/detail/metadata-list/metadata-list.component.html +++ b/ui/src/app/metadata/detail/metadata-list/metadata-list.component.html @@ -1,8 +1,22 @@ - diff --git a/ui/src/app/metadata/detail/metadata-list/metadata-list.component.spec.ts b/ui/src/app/metadata/detail/metadata-list/metadata-list.component.spec.ts index 7f7c951ab..b0782b7b9 100644 --- a/ui/src/app/metadata/detail/metadata-list/metadata-list.component.spec.ts +++ b/ui/src/app/metadata/detail/metadata-list/metadata-list.component.spec.ts @@ -5,6 +5,10 @@ import { PaginatedMetadata } from "../../PaginatedMetadata" import { ApiJobCode } from "../../job-codes/Jobcode" import { AuthServiceStub } from "@test/resource/mock-stubs"; import { AuthService } from "../../../auth/auth-service"; +import { JobCodeService } from "../../job-codes/service/job-code.service" +import { HttpClientTestingModule } from "@angular/common/http/testing" +import { createMockJobcode } from "@test/resource/mock-data" +import { of } from "rxjs" describe("ManageMetadataComponent", () => { let component: MetadataListComponent @@ -15,6 +19,10 @@ describe("ManageMetadataComponent", () => { declarations: [ MetadataListComponent ], providers: [ { provide: AuthService, useClass: AuthServiceStub }, + JobCodeService + ], + imports: [ + HttpClientTestingModule ] }) .compileComponents() @@ -29,19 +37,60 @@ describe("ManageMetadataComponent", () => { it("should create", () => { expect(component).toBeTruthy() }) + it("isJobCodeDataSelected returns false if JobCode MetadataType is not selected", () => { expect(component.isJobCodeDataSelected).toBeFalse() }) + it("isJobCodeDataSelected returns true if JobCode MetadataType is selected", () => { component.selectedMetadataType = MetadataType.JobCode expect(component.isJobCodeDataSelected).toBeTrue() }) + it("emptyResults returns true if Metadata is empty", () => { component.results = new PaginatedMetadata([], 0) expect(component.emptyResults).toBeTrue() }) + it("emptyResults returns false if Metadata is not empty", () => { component.results = new PaginatedMetadata([new ApiJobCode(), new ApiJobCode()], 2) expect(component.emptyResults).toBeFalse() }) + + it("matching query should be updated", () => { + const matchingQuery = "95-0000" + component.searchForm.get("search")?.patchValue(matchingQuery) + component.handleDefaultSubmit() + expect(component.matchingQuery).toEqual(matchingQuery) + }) + + it("When clearSearch is called matching query should be empty", () => { + component.matchingQuery = "This is a query" + component.clearSearch() + expect(component.matchingQuery).toBe("") + }) + + it("handleDeleteJobCode should call deleteJobCodeWithResult", () => { + const mockJobCode = createMockJobcode() + const spyService = spyOn(component["jobCodeService"], "deleteJobCodeWithResult").and.returnValue( + of({success: true}) + ) + spyOn(window, 'confirm').and.callFake(function () { + return true; + }); + component["handleDeleteJobCode"](mockJobCode) + expect(spyService).toHaveBeenCalled() + }) + + it("handleDeleteJobCode should not call deleteJobCodeWithResult", () => { + const mockJobCode = createMockJobcode() + const spyService = spyOn(component["jobCodeService"], "deleteJobCodeWithResult").and.returnValue( + of({success: true}) + ) + spyOn(window, 'confirm').and.callFake(function () { + return false; + }); + component["handleDeleteJobCode"](mockJobCode) + expect(spyService).not.toHaveBeenCalled() + }) }) diff --git a/ui/src/app/metadata/detail/metadata-list/metadata-list.component.ts b/ui/src/app/metadata/detail/metadata-list/metadata-list.component.ts index 368b2610c..d207c2c86 100644 --- a/ui/src/app/metadata/detail/metadata-list/metadata-list.component.ts +++ b/ui/src/app/metadata/detail/metadata-list/metadata-list.component.ts @@ -1,53 +1,30 @@ -import {Component, OnInit, ViewChild} from "@angular/core" -import { FormControl, FormGroup } from "@angular/forms" -import { Observable, Subject } from "rxjs" +import { Component, OnInit } from "@angular/core" +import { FormControl } from "@angular/forms" +import { Subject } from "rxjs" import { PaginatedMetadata } from "../../PaginatedMetadata" -import { ApiSortOrder } from "../../../richskill/ApiSkill" import { ApiJobCode, IJobCode } from "../../job-codes/Jobcode" -import { TableActionBarComponent } from "../../../table/skills-library-table/table-action-bar.component" -import { Whitelabelled } from "../../../../whitelabel" import { ApiNamedReference, INamedReference } from "../../named-references/NamedReference" import { TableActionDefinition } from "../../../table/skills-library-table/has-action-definitions" import { ButtonAction } from "../../../auth/auth-roles" import { AuthService } from "../../../auth/auth-service" import { MetadataType } from "../../rsd-metadata.enum" +import { JobCodeService } from "../../job-codes/service/job-code.service" +import { ToastService } from "../../../toast/toast.service" +import { AbstractListComponent } from "../../../table/abstract-list.component" @Component({ selector: "app-metadata-list", templateUrl: "./metadata-list.component.html" }) -export class MetadataListComponent extends Whitelabelled implements OnInit { +export class MetadataListComponent extends AbstractListComponent implements OnInit { - @ViewChild(TableActionBarComponent) actionBar!: TableActionBarComponent - - title = "Metadata" - handleSelectedMetadata?: IJobCode[]|INamedReference[] - selectedMetadataType = "category" - matchingQuery: string = "" + selectedMetadataType = MetadataType.Category typeControl: FormControl = new FormControl(this.selectedMetadataType) - columnSort: ApiSortOrder = ApiSortOrder.NameAsc - from = 0 - size = 50 showSearchEmptyMessage = false - resultsLoaded: Observable | undefined canDeleteMetadata = this.authService.isEnabledByRoles(ButtonAction.MetadataAdmin) - searchForm = new FormGroup({ - search: new FormControl("") - }) - sampleJobCodeResult = new PaginatedMetadata([ - new ApiJobCode({code: "code1", targetNodeName: "targetNodeName1", frameworkName: "frameworkName1", url: "url1", broad: "broad1", level: "Broad"}), - new ApiJobCode({code: "code2", targetNodeName: "targetNodeName2", frameworkName: "frameworkName2", url: "url2", broad: "broad1", level: "Broad"}), - new ApiJobCode({code: "code3", targetNodeName: "targetNodeName3", frameworkName: "frameworkName3", url: "url3", broad: "broad1", level: "Broad"}), - new ApiJobCode({code: "code4", targetNodeName: "targetNodeName4", frameworkName: "frameworkName4", url: "url4", broad: "broad1", level: "Broad"}), - new ApiJobCode({code: "code5", targetNodeName: "targetNodeName5", frameworkName: "frameworkName5", url: "url5", broad: "broad1", level: "Broad"}), - new ApiJobCode({code: "code6", targetNodeName: "targetNodeName6", frameworkName: "frameworkName6", url: "url6", broad: "broad1", level: "Broad"}), - new ApiJobCode({code: "code7", targetNodeName: "targetNodeName7", frameworkName: "frameworkName7", url: "url7", broad: "broad1", level: "Broad"}), - new ApiJobCode({code: "code8", targetNodeName: "targetNodeName8", frameworkName: "frameworkName8", url: "url8", broad: "broad1", level: "Broad"}), - ], 8) - sampleNamedReferenceResult = new PaginatedMetadata([ new ApiNamedReference({id: "id1", framework: "framework1", name: "name1", type: MetadataType.Category, value: "value1"}), new ApiNamedReference({id: "id2", framework: "framework2", name: "name2", type: MetadataType.Category, value: "value2"}), @@ -62,7 +39,11 @@ export class MetadataListComponent extends Whitelabelled implements OnInit { results: PaginatedMetadata = this.sampleNamedReferenceResult clearSelectedItemsFromTable = new Subject() - constructor(protected authService: AuthService) { + constructor( + protected authService: AuthService, + protected jobCodeService: JobCodeService, + protected toastService: ToastService + ) { super() } @@ -70,127 +51,136 @@ export class MetadataListComponent extends Whitelabelled implements OnInit { this.typeControl.valueChanges.subscribe( value => { this.selectedMetadataType = value - if (this.selectedMetadataType === MetadataType.JobCode) { - this.results = this.sampleJobCodeResult - } - else { - this.results = this.sampleNamedReferenceResult - } + this.loadNextPage() }) - this.searchForm.get("search")?.valueChanges.subscribe( value => this.matchingQuery = value!) - } - - clearSearch(): boolean { - this.searchForm.reset() - return false - } - - handleDefaultSubmit(): boolean { this.loadNextPage() - this.from = 0 - - return false - } - loadNextPage(): void {} - handleSelectAll(selectAllChecked: boolean): void {} + } - handleNewSelection(selected: IJobCode[]|INamedReference[]): void { - this.handleSelectedMetadata = selected + clearSearch(): void { + this.searchForm.get("search")?.patchValue("") + this.matchingQuery = "" + this.handleDefaultSubmit() } - handleHeaderColumnSort(sort: ApiSortOrder): void { - this.columnSort = sort + handleDefaultSubmit(): void { + this.matchingQuery = this.searchForm.get("search")?.value ?? "" this.from = 0 this.loadNextPage() } - get totalCount(): number { - return this.results?.totalCount ?? 0 - } - - get metadataCountLabel(): string { - if (this.totalCount > 0) { - if (this.selectedMetadataType !== MetadataType.Category) { - return `${this.totalCount} ${this.selectedMetadataType}${this.totalCount > 1 ? "s" : ""}` - } - else if (this.totalCount > 1) { - return `${this.totalCount} categories` - } - else { - return `${this.totalCount} category` - } + loadNextPage(): void { + this.selectedData = [] + if (this.isJobCodeDataSelected) { + this.resultsLoaded = this.jobCodeService.paginatedJobCodes(this.size, this.from, this.columnSort, this.matchingQuery) + this.resultsLoaded.subscribe(jobCodes => this.results = jobCodes) + } else { + this.results = this.sampleNamedReferenceResult } - return `0 ${this.selectedMetadataType}s` - } - get firstRecordNo(): number { - return this.from + 1 - } - get lastRecordNo(): number { - return Math.min(this.from + this.curPageCount, this.totalCount) } - get totalPageCount(): number { - return Math.ceil(this.totalCount / this.size) - } - get currentPageNo(): number { - return Math.floor(this.from / this.size) + 1 - } - - get curPageCount(): number { - return this.results?.metadata.length ?? 0 + get metadataCountLabel(): string { + return `${this.totalCount} ${this.selectedMetadataType}` } - get emptyResults(): boolean { - return this.curPageCount < 1 - } get isJobCodeDataSelected(): boolean { - console.log(this.selectedMetadataType === MetadataType.JobCode.toString()) return this.selectedMetadataType === MetadataType.JobCode } - getSelectAllCount(): number { - return this.curPageCount + getJobCodes(): IJobCode[] { + return (this.results?.data) as IJobCode[] } - getSelectAllEnabled(): boolean { - return true + getNamedReferences(): INamedReference[] { + return (this.results?.data) as INamedReference[] } - focusActionBar(): void { - this.actionBar?.focus() - } - getJobCodes(): IJobCode[] { - return (this.results?.metadata) as IJobCode[] + rowActions(): TableActionDefinition[] { + const tableActions = [] + if (this.canDeleteMetadata && !this.selectAllChecked) { + tableActions.push(new TableActionDefinition({ + label: `Delete`, + callback: (action: TableActionDefinition, metadata?: IJobCode | INamedReference) => this.handleClickDeleteItem(metadata), + visible: () => !this.selectAllChecked + })) + } + return tableActions } - getNamedReferences(): INamedReference[] { - return (this.results?.metadata) as INamedReference[] + tableActions(): TableActionDefinition[] { + const tableActions: TableActionDefinition[] = [] + tableActions.push( + new TableActionDefinition({ + label: "Back to Top", + icon: "up", + offset: true, + callback: () => this.handleClickBackToTop(), + visible: () => true + }), + new TableActionDefinition({ + label: "Delete Selected", + icon: "remove", + callback: () => this.handleDeleteMultipleMetadata(), + visible: () => (this.selectedData?.length ?? 0) > 0 + }) + ) + return tableActions } - public get searchQuery(): string { - return this.searchForm.get("search")?.value ?? "" + + get selectedJobCodesOrderedByLevel(): IJobCode[] { + return (this.selectedData as IJobCode[]).sort((a, b) => { + if ((a.jobCodeLevelAsNumber ?? 0) > (b.jobCodeLevelAsNumber ?? 0)) { + return -1 + } else if ((a.jobCodeLevelAsNumber) ?? 0 < (b.jobCodeLevelAsNumber ?? 0)) { + return 1 + } + return 0 + }) } - navigateToPage(newPageNo: number): void { - this.from = (newPageNo - 1) * this.size - this.loadNextPage() + private handleDeleteMultipleMetadata(): void { + if (this.isJobCodeDataSelected) { + if (confirm("Confirm that you want to delete multiple job codes")) { + this.handleDeleteMultipleJobCodes(this.selectedJobCodesOrderedByLevel, 0) + } + } } - handlePageClicked(newPageNo: number): void { - this.navigateToPage(newPageNo) + private handleDeleteMultipleJobCodes(jobCodes: IJobCode[], index: number, notDeleted = 0): void { + if (index < jobCodes.length) { + this.jobCodeService.deleteJobCodeWithResult(jobCodes[index].id ?? 0).subscribe(data => { + if (data && data.success) { + this.handleDeleteMultipleJobCodes(jobCodes, index + 1, notDeleted) + } else if (data && !data.success) { + this.handleDeleteMultipleJobCodes(jobCodes, index + 1, notDeleted + 1) + } + }) + } else { + if (notDeleted > 0) { + this.toastService.showToast("Warning", "Some occupations cannot be deleted") + } else { + this.toastService.showToast("Success", "All selected occupations have been deleted") + } + this.loadNextPage() + } } - rowActions(): TableActionDefinition[] { - const tableActions = [] - if (this.canDeleteMetadata) { - tableActions.push(new TableActionDefinition({ - label: `Delete`, - callback: (action: TableActionDefinition, skill?: IJobCode|INamedReference) => this.handleClickDeleteItem(action, skill), - })) + private handleClickDeleteItem(metadata: IJobCode | INamedReference | undefined): void { + if (this.isJobCodeDataSelected) { + this.handleDeleteJobCode(metadata as IJobCode) } - return tableActions } - // tslint:disable-next-line:typedef - private handleClickDeleteItem(action: TableActionDefinition, skill: IJobCode|INamedReference | undefined) { + private handleDeleteJobCode(jobCode: IJobCode): void { + if (confirm("Confirm that you want to delete the job code with name " + (jobCode as ApiJobCode)?.targetNodeName)) { + this.jobCodeService.deleteJobCodeWithResult((jobCode as ApiJobCode)?.id ?? 0).subscribe(data => { + if (data && data.success) { + this.toastService.showToast("Success", "You deleted a job code " + (jobCode as ApiJobCode)?.targetNodeName) + this.loadNextPage() + } else if (data && !data.success) { + this.toastService.showToast("Warning", data.message ?? "You cannot delete this job code") + } + }) + } } + } diff --git a/ui/src/app/metadata/detail/metadata-selector/metadata-selector.component.html b/ui/src/app/metadata/detail/metadata-selector/metadata-selector.component.html index 89bd0ea40..052d7e7a2 100644 --- a/ui/src/app/metadata/detail/metadata-selector/metadata-selector.component.html +++ b/ui/src/app/metadata/detail/metadata-selector/metadata-selector.component.html @@ -4,7 +4,7 @@ diff --git a/ui/src/app/metadata/job-codes/Jobcode.ts b/ui/src/app/metadata/job-codes/Jobcode.ts index 5217fbed0..179874ffc 100644 --- a/ui/src/app/metadata/job-codes/Jobcode.ts +++ b/ui/src/app/metadata/job-codes/Jobcode.ts @@ -1,10 +1,12 @@ export type JobCodeLevel = "Major" | "Minor" | "Broad" | "Detailed" | "Onet" export interface IJobCode { + id?: number targetNodeName?: string code: string targetNode?: number frameworkName?: string + jobCodeLevelAsNumber?: number level?: JobCodeLevel parents?: IJobCode[] major?: string @@ -18,6 +20,7 @@ export interface IJobCode { } export class ApiJobCode implements IJobCode { + id?: number code = "" targetNodeName?: string targetNode?: number diff --git a/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.html b/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.html index fa7651888..660dbb57b 100644 --- a/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.html +++ b/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.html @@ -1,17 +1,21 @@ - + {{jobCode.targetNodeName}} - - + + + {{jobCode.code}} + + {{jobCode.level}} - - - {{jobCode.parents}} - - - {{jobCode.frameworkName}} - + + +
    + +
+
diff --git a/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.spec.ts b/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.spec.ts index 4b1accb59..14ecce827 100644 --- a/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.spec.ts +++ b/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing" import { JobCodeListRowComponent } from "./job-code-list-row.component" +import { createMockJobcode, mockJobCodeWithParents } from "@test/resource/mock-data" describe("JobCodeListRowComponent", () => { let component: JobCodeListRowComponent @@ -21,4 +22,12 @@ describe("JobCodeListRowComponent", () => { it("should create", () => { expect(component).toBeTruthy() }) + + it("Parents should be ordered", () => { + component.jobCode = mockJobCodeWithParents + const sorted = component.sortedParents() + expect(sorted[0].code).toBe("13-0000") + expect(sorted[1].code).toBe("13-2000") + expect(sorted[2].code).toBe("13-2010") + }) }) diff --git a/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.ts b/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.ts index 09f3b223a..14fba521f 100644 --- a/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.ts +++ b/ui/src/app/metadata/job-codes/job-code-list-row/job-code-list-row.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core" +import { Component, EventEmitter, Input, Output } from "@angular/core" import { TableActionDefinition } from "../../../table/skills-library-table/has-action-definitions" import { SvgHelper, SvgIcon } from "../../../core/SvgHelper" import { ApiJobCode, IJobCode } from "../Jobcode" @@ -38,4 +38,15 @@ export class JobCodeListRowComponent { return false } } + + sortedParents(): IJobCode[] { + return this.jobCode?.parents?.sort((a, b) => { + if (a.code < b.code) { + return -1 + } else if (a.code > b.code) { + return 1 + } + return 0 + }) ?? [] + } } diff --git a/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.html b/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.html index e7e622ca2..c9106921e 100644 --- a/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.html +++ b/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.html @@ -1,20 +1,19 @@ - - - - + - + - diff --git a/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.spec.ts b/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.spec.ts index 34370ccda..8d12dd899 100644 --- a/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.spec.ts +++ b/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.spec.ts @@ -1,5 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing" import { JobCodeTableComponent } from "./job-code-table.component" +import { ApiSortOrder } from "../../../richskill/ApiSkill" +import { curry } from "lodash" describe("JobCodeTableComponent", () => { let component: JobCodeTableComponent @@ -21,4 +23,46 @@ describe("JobCodeTableComponent", () => { it("should create", () => { expect(component).toBeTruthy() }) + + it("Current sort should be name.asc", () => { + component.sortColumn("name", true) + expect(component.currentSort).toEqual(ApiSortOrder.NameAsc) + }) + + it("Current sort should be name.desc", () => { + component.sortColumn("name", false) + expect(component.currentSort).toEqual(ApiSortOrder.NameDesc) + }) + + it("Current sort should be code.asc", () => { + component.sortColumn("code", true) + expect(component.currentSort).toEqual(ApiSortOrder.CodeAsc) + }) + + it("Current sort should be code.desc", () => { + component.sortColumn("code", false) + expect(component.currentSort).toEqual(ApiSortOrder.CodeDesc) + }) + + it("Current sort should be jobCodeLevel.asc", () => { + component.sortColumn("jobCodeLevel", true) + expect(component.currentSort).toEqual(ApiSortOrder.JobCodeLevelAsc) + }) + + it("Current sort should be jobCodeLevel.desc", () => { + component.sortColumn("jobCodeLevel", false) + expect(component.currentSort).toEqual(ApiSortOrder.JobCodeLevelDesc) + }) + + it("Get code sort should be true", () => { + component.currentSort = ApiSortOrder.CodeAsc + const codeSort = component.getCodeSort() + expect(codeSort).toBeTrue() + }) + + it("Get job code level should be true", () => { + component.currentSort = ApiSortOrder.JobCodeLevelAsc + const codeSort = component.getJobCodeLevelSort() + expect(codeSort).toBeTrue() + }) }) diff --git a/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.ts b/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.ts index 7fdece162..4c50ede17 100644 --- a/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.ts +++ b/ui/src/app/metadata/job-codes/job-code-table/job-code-table.component.ts @@ -2,6 +2,7 @@ import { AfterViewInit, Component, Input, QueryList, ViewChildren } from "@angul import { AbstractTableComponent } from "../../../table/abstract-table.component" import { JobCodeListRowComponent } from "../job-code-list-row/job-code-list-row.component" import { IJobCode } from "../Jobcode" +import { ApiSortOrder } from "../../../richskill/ApiSkill" @Component({ selector: "app-job-code-table", @@ -18,5 +19,25 @@ export class JobCodeTableComponent extends AbstractTableComponent impl this.rowReferences.first.focusFirstColumnInRow() } } + + sortColumn(column: string, ascending: boolean): void { + if (column.toLowerCase() === "name") { + this.currentSort = ascending ? ApiSortOrder.NameAsc : this.currentSort = ApiSortOrder.NameDesc + } else if (column.toLowerCase() === "code") { + this.currentSort = ascending ? ApiSortOrder.CodeAsc : ApiSortOrder.CodeDesc + } else { + this.currentSort = ascending ? ApiSortOrder.JobCodeLevelAsc : ApiSortOrder.JobCodeLevelDesc + } + this.columnSorted.emit(this.currentSort) + } + + getCodeSort(): boolean | undefined { + return this.currentSort == ApiSortOrder.CodeAsc; + } + + getJobCodeLevelSort(): boolean | undefined { + return this.currentSort == ApiSortOrder.JobCodeLevelAsc; + } + } diff --git a/ui/src/app/metadata/job-codes/service/job-code.service.spec.ts b/ui/src/app/metadata/job-codes/service/job-code.service.spec.ts index 3d9122dd7..cf368bace 100644 --- a/ui/src/app/metadata/job-codes/service/job-code.service.spec.ts +++ b/ui/src/app/metadata/job-codes/service/job-code.service.spec.ts @@ -2,7 +2,7 @@ import { fakeAsync, TestBed, tick } from "@angular/core/testing" import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing" import { Location } from "@angular/common" import { Router } from "@angular/router" -import { JobCodeService, PaginatedJobCodes } from "./job-code.service" +import { JobCodeService } from "./job-code.service" import { AuthServiceData, AuthServiceStub, RouterData, RouterStub } from "@test/resource/mock-stubs" import { AppConfig } from "../../../app.config" import { EnvironmentService } from "../../../core/environment.service" @@ -10,11 +10,12 @@ import { AuthService } from "../../../auth/auth-service" import { apiTaskResultForDeleteJobCode, createMockJobcode, - createMockPaginatedJobCodes + createMockPaginatedMetaDataWithJobCodes } from "@test/resource/mock-data" import { ApiSortOrder } from "../../../richskill/ApiSkill" import { ApiJobCode, ApiJobCodeUpdate } from "../Jobcode" import { ApiBatchResult } from "../../../richskill/ApiBatchResult" +import { PaginatedMetadata } from "../../PaginatedMetadata" const ASYNC_WAIT_PERIOD = 3000 @@ -48,16 +49,16 @@ describe("JobCodeService", () => { // Arrange RouterData.commands = [] AuthServiceData.isDown = false - const path = "api/metadata/jobcodes?sort=name.asc&size=3&from=0" - const testData: PaginatedJobCodes = createMockPaginatedJobCodes(3, 10) + const path = "api/metadata/jobcodes?size=3&from=0&sort=name.asc&query=" + const testData: PaginatedMetadata = createMockPaginatedMetaDataWithJobCodes(3, 10) // Act // noinspection LocalVariableNamingConventionJS - const result$ = testService.getJobCodes(testData.jobCodes.length, 0, ApiSortOrder.NameAsc) + const result$ = testService.paginatedJobCodes(testData.data.length, 0, ApiSortOrder.NameAsc, undefined) // Assert result$ - .subscribe((data: PaginatedJobCodes) => { + .subscribe((data: PaginatedMetadata) => { expect(data).toEqual(testData) expect(RouterData.commands).toEqual([ ]) // No errors expect(AuthServiceData.isDown).toEqual(false) @@ -65,7 +66,7 @@ describe("JobCodeService", () => { const req = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + "/" + path) expect(req.request.method).toEqual("GET") - req.flush(testData.jobCodes, { + req.flush(testData.data, { headers: { "x-total-count": "" + testData.totalCount} }) }) @@ -163,13 +164,14 @@ describe("JobCodeService", () => { }) it("deleteJobCodeWithResult() should works", fakeAsync(() => { - const result$ = testService.deleteJobCodeWithResult(apiTaskResultForDeleteJobCode.uuid) + const jobCodeId = 2 + const result$ = testService.deleteJobCodeWithResult(jobCodeId) tick(ASYNC_WAIT_PERIOD) // Assert result$.subscribe((data: ApiBatchResult) => { expect(RouterData.commands).toEqual([]) // No Errors }) - const req = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + `/api/metadata/jobcodes/${apiTaskResultForDeleteJobCode.uuid}/remove`) + const req = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + `/api/metadata/jobcodes/${jobCodeId}/remove`) expect(req.request.method).toEqual("DELETE") expect(req.request.headers.get("Accept")).toEqual("application/json") })) diff --git a/ui/src/app/metadata/job-codes/service/job-code.service.ts b/ui/src/app/metadata/job-codes/service/job-code.service.ts index 67698b34c..22a061fbe 100644 --- a/ui/src/app/metadata/job-codes/service/job-code.service.ts +++ b/ui/src/app/metadata/job-codes/service/job-code.service.ts @@ -1,41 +1,48 @@ import { Location } from "@angular/common" -import { HttpClient, HttpHeaders } from "@angular/common/http" +import { HttpClient, HttpHeaders, HttpParams } from "@angular/common/http" import { Injectable } from "@angular/core" import { Router } from "@angular/router" import { Observable } from "rxjs" import { map, share } from "rxjs/operators" import { ApiJobCode, IJobCode, IJobCodeUpdate } from "../Jobcode" -import { AuthService } from "../../../auth/auth-service"; -import { AbstractDataService } from "../../abstract-data.service"; -import { ApiSortOrder } from "../../../richskill/ApiSkill"; -import { ApiBatchResult } from "../../../richskill/ApiBatchResult"; -import { ApiTaskResult, ITaskResult } from "../../../task/ApiTaskResult"; +import { AuthService } from "../../../auth/auth-service" +import { AbstractDataService } from "../../abstract-data.service" +import { ApiSortOrder } from "../../../richskill/ApiSkill" +import { ApiBatchResult } from "../../../richskill/ApiBatchResult" +import { ApiTaskResult, ITaskResult } from "../../../task/ApiTaskResult" +import { PaginatedMetadata } from "../../PaginatedMetadata" @Injectable({ providedIn: "root" }) -export class JobCodeService extends AbstractDataService{ +export class JobCodeService extends AbstractDataService { private baseServiceUrl = "api/metadata/jobcodes" - constructor(protected httpClient: HttpClient, protected authService: AuthService, - protected router: Router, protected location: Location) { + constructor( + protected httpClient: HttpClient, + protected authService: AuthService, + protected router: Router, + protected location: Location + ) { super(httpClient, authService, router, location) } - getJobCodes( - size: number = 50, - from: number = 0, - sort: ApiSortOrder | undefined - ): Observable { - const params = this.buildTableParams(size, from, undefined, sort) + paginatedJobCodes( + size = 50, + from = 0, + sort: ApiSortOrder | undefined, + query: string | undefined + ): Observable { + const params = new HttpParams({ + fromObject: {size, from, sort: sort ?? "", query: query ?? ""} + }) return this.get({ path: `${this.baseServiceUrl}`, params, - }) - .pipe(share()) + }).pipe(share()) .pipe(map(({body, headers}) => { - return new PaginatedJobCodes( + return new PaginatedMetadata( body || [], Number(headers.get("X-Total-Count")) ) @@ -71,11 +78,11 @@ export class JobCodeService extends AbstractDataService{ .pipe(map(({body}) => new ApiJobCode(this.safeUnwrapBody(body, errorMsg)))) } - deleteJobCodeWithResult(id: string): Observable { + deleteJobCodeWithResult(id: number): Observable { return this.pollForTaskResult(this.deleteJobCode(id)) } - deleteJobCode(id: string): Observable { + deleteJobCode(id: number): Observable { return this.httpClient.delete(this.buildUrl("api/metadata/jobcodes/" + id + "/remove"), { headers: this.wrapHeaders(new HttpHeaders({ Accept: "application/json" @@ -86,12 +93,3 @@ export class JobCodeService extends AbstractDataService{ .pipe(map((body) => new ApiTaskResult(this.safeUnwrapBody(body, "unwrap failure")))) } } - -export class PaginatedJobCodes { - totalCount = 0 - jobCodes: ApiJobCode[] = [] - constructor(jobCodes: ApiJobCode[], totalCount: number) { - this.jobCodes = jobCodes - this.totalCount = totalCount - } -} diff --git a/ui/src/app/metadata/rsd-metadata.enum.ts b/ui/src/app/metadata/rsd-metadata.enum.ts index bad69fee3..bf30b8996 100644 --- a/ui/src/app/metadata/rsd-metadata.enum.ts +++ b/ui/src/app/metadata/rsd-metadata.enum.ts @@ -1,10 +1,10 @@ export enum MetadataType { - Category = "category", - Keyword = "keyword", - Standard = "standard", - Certification = "certification", - Alignment = "alignment", - Employer = "employer", - Author = "author", - JobCode = "jobcode" + Category = "categories", + Keyword = "keywords", + Standard = "standards", + Certification = "certifications", + Alignment = "alignments", + Employer = "employers", + Author = "authors", + JobCode = "occupations" } diff --git a/ui/src/app/models/index.ts b/ui/src/app/models/index.ts index f13546be6..a90d4bd93 100644 --- a/ui/src/app/models/index.ts +++ b/ui/src/app/models/index.ts @@ -1,3 +1,4 @@ export * from "./app-config.model" export * from "./filter-dropdown.model" +export * from "./paginated-data.model" export * from "./select-all-event.model" diff --git a/ui/src/app/models/paginated-data.model.ts b/ui/src/app/models/paginated-data.model.ts new file mode 100644 index 000000000..07dffc349 --- /dev/null +++ b/ui/src/app/models/paginated-data.model.ts @@ -0,0 +1,4 @@ +export interface PaginatedData { + data: T[] + totalCount: number +} diff --git a/ui/src/app/pipes/index.ts b/ui/src/app/pipes/index.ts index 795d12242..6fb5acf18 100644 --- a/ui/src/app/pipes/index.ts +++ b/ui/src/app/pipes/index.ts @@ -1 +1,2 @@ export * from "./collection.pipe" +export * from "./job-code-parents.pipe" diff --git a/ui/src/app/pipes/job-code-parents.pipe.spec.ts b/ui/src/app/pipes/job-code-parents.pipe.spec.ts new file mode 100644 index 000000000..2eb816c6f --- /dev/null +++ b/ui/src/app/pipes/job-code-parents.pipe.spec.ts @@ -0,0 +1,16 @@ +import { JobCodeParentsPipe } from './job-code-parents.pipe'; +import { mockJobCodesParents } from "@test/resource/mock-data" + +describe("JobCodeParentsPipe", () => { + + it("create an instance", () => { + const pipe = new JobCodeParentsPipe(); + expect(pipe).toBeTruthy(); + }); + + it("should transform correctly", () => { + const pipe = new JobCodeParentsPipe(); + const [parent] = mockJobCodesParents + expect(pipe.transform(parent)).toEqual(parent.code + " " + parent.targetNodeName) + }) +}); diff --git a/ui/src/app/pipes/job-code-parents.pipe.ts b/ui/src/app/pipes/job-code-parents.pipe.ts new file mode 100644 index 000000000..21f838ab2 --- /dev/null +++ b/ui/src/app/pipes/job-code-parents.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { IJobCode } from "../metadata/job-codes/Jobcode" + +@Pipe({ + name: 'jobCodeParents' +}) +export class JobCodeParentsPipe implements PipeTransform { + + transform(jobCode: IJobCode): string { + return jobCode.code + " " + jobCode.targetNodeName; + } + +} diff --git a/ui/src/app/richskill/ApiSkill.ts b/ui/src/app/richskill/ApiSkill.ts index 7a985f9aa..8f1445abb 100644 --- a/ui/src/app/richskill/ApiSkill.ts +++ b/ui/src/app/richskill/ApiSkill.ts @@ -203,10 +203,13 @@ export enum ApiSortOrder { SkillAsc = "skill.asc", SkillDesc = "skill.desc", NameAsc = "name.asc", - NameDesc = "name.desc" + NameDesc = "name.desc", + CodeAsc = "code.asc", + CodeDesc = "code.desc", + JobCodeLevelAsc = "jobCodeLevel.asc", + JobCodeLevelDesc = "jobCodeLevel.desc", } - export enum AuditOperationType { Insert = "Insert", Update = "Update", diff --git a/ui/src/app/table/abstract-list.component.ts b/ui/src/app/table/abstract-list.component.ts new file mode 100644 index 000000000..e05a1df91 --- /dev/null +++ b/ui/src/app/table/abstract-list.component.ts @@ -0,0 +1,103 @@ +import { QuickLinksHelper } from "../core/quick-links-helper" +import { Component, ElementRef, ViewChild } from "@angular/core" +import { TableActionDefinition } from "./skills-library-table/has-action-definitions" +import { FormControl, FormGroup } from "@angular/forms" +import { TableActionBarComponent } from "./skills-library-table/table-action-bar.component" +import { PaginatedMetadata } from "../metadata/PaginatedMetadata" +import { ApiSortOrder } from "../richskill/ApiSkill" +import { Observable } from "rxjs" +import { PaginatedData } from "../models" + +@Component({ + selector: "app-abstract-list", + template: "" +}) +export abstract class AbstractListComponent extends QuickLinksHelper { + + from = 0 + size = 50 + matchingQuery?: string = "" + title = "Metadata" + columnSort: ApiSortOrder = ApiSortOrder.NameAsc + @ViewChild("titleHeading") titleElement!: ElementRef + @ViewChild(TableActionBarComponent) actionBar!: TableActionBarComponent + searchForm = new FormGroup({ + search: new FormControl("") + }) + results!: PaginatedData + selectedData?: T[] + resultsLoaded: Observable | undefined + selectAllChecked = false + + abstract rowActions(): TableActionDefinition[] + + abstract tableActions(): TableActionDefinition[] + + abstract loadNextPage(): void + + handleClickBackToTop(): void { + this.focusAndScrollIntoView(this.titleElement.nativeElement) + } + + navigateToPage(newPageNo: number): void { + this.from = (newPageNo - 1) * this.size + this.loadNextPage() + } + + handlePageClicked(newPageNo: number): void { + this.navigateToPage(newPageNo) + } + + focusActionBar(): void { + this.actionBar?.focus() + } + + get firstRecordNo(): number { + return this.from + 1 + } + get lastRecordNo(): number { + return Math.min(this.from + this.curPageCount, this.totalCount) + } + + get totalPageCount(): number { + return Math.ceil(this.totalCount / this.size) + } + get currentPageNo(): number { + return Math.floor(this.from / this.size) + 1 + } + + get emptyResults(): boolean { + return this.curPageCount < 1 + } + + get totalCount(): number { + return (this.results as any)?.totalCount ?? 0 + } + + get curPageCount(): number { + return this.results?.data.length ?? 0 + } + + getSelectAllCount(): number { + return this.curPageCount + } + + getSelectAllEnabled(): boolean { + return true + } + + handleNewSelection(selected: T[]): void { + this.selectedData = selected + } + + handleHeaderColumnSort(sort: ApiSortOrder): void { + this.columnSort = sort + this.from = 0 + this.loadNextPage() + } + + handleSelectAll(selectAllChecked: boolean): void { + this.selectAllChecked = selectAllChecked + } + +} diff --git a/ui/src/app/table/abstract-table.component.ts b/ui/src/app/table/abstract-table.component.ts index 46da000fa..44924406d 100644 --- a/ui/src/app/table/abstract-table.component.ts +++ b/ui/src/app/table/abstract-table.component.ts @@ -1,4 +1,4 @@ -import {Component, EventEmitter, HostListener, Input, OnInit, Output} from "@angular/core" +import { Component, EventEmitter, HostListener, Input, OnChanges, OnInit, Output, SimpleChanges } from "@angular/core" import {ApiSortOrder} from "../richskill/ApiSkill" import {TableActionDefinition} from "./skills-library-table/has-action-definitions" import {SvgHelper, SvgIcon} from "../core/SvgHelper" @@ -13,7 +13,7 @@ import {SelectAllEvent} from "../models" selector: "app-abstract-table", template: `` }) -export class AbstractTableComponent implements OnInit { +export class AbstractTableComponent implements OnInit, OnChanges { @Input() items: SummaryT[] = [] @Input() currentSort?: ApiSortOrder = undefined @@ -35,6 +35,7 @@ export class AbstractTableComponent implements OnInit { checkIcon = SvgHelper.path(SvgIcon.CHECK) isShiftPressed = false + isAllPageSelected = false constructor() { } @@ -42,6 +43,14 @@ export class AbstractTableComponent implements OnInit { this.clearSelected.subscribe(next => this.selectedItems = new Set()) } + ngOnChanges(changes: SimpleChanges): void { + if (changes.items) { + this.isAllPageSelected = false + this.selectedItems.clear() + this.rowSelected.emit(Array.from(this.selectedItems)) + } + } + getNameSort(): boolean | undefined { if (this.currentSort) { switch (this.currentSort) { @@ -100,20 +109,27 @@ export class AbstractTableComponent implements OnInit { onRowToggle(item: SummaryT): void { if (this.selectedItems.has(item)) { this.selectedItems.delete(item) + if (this.isAllPageSelected) { + this.isAllPageSelected = false + } } else { this.selectedItems.add(item) this.shiftSelection(item) + if (this.selectedItems.size === this.items.length) { + this.isAllPageSelected = true + } } + this.selectAllSelected.emit(this.isAllPageSelected) this.rowSelected.emit(Array.from(this.selectedItems)) } shiftSelection(item: SummaryT): void {} handleSelectAll(event: SelectAllEvent): void { - const isCheckboxSelected: boolean = event.selected - const isAllResultsSelected: boolean = event.selected && event.value === SelectAll.SELECT_ALL + this.isAllPageSelected = event.selected + const isAllResultsSelected: boolean = event.selected && event.value === SelectAll.SELECT_ALL // select all across pages this.selectAllSelected.emit(isAllResultsSelected) - if (isCheckboxSelected) { + if (this.isAllPageSelected) { this.items.forEach(it => this.selectedItems.add(it)) } else { this.selectedItems.clear() diff --git a/ui/src/app/table/select-all/select-all.component.html b/ui/src/app/table/select-all/select-all.component.html index 9479e75b0..253e78640 100644 --- a/ui/src/app/table/select-all/select-all.component.html +++ b/ui/src/app/table/select-all/select-all.component.html @@ -15,7 +15,7 @@
- +