diff --git a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt index 8ccc01009..bc03a1cd4 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt @@ -32,6 +32,7 @@ object RoutePaths { const val COLLECTION_CSV = "$COLLECTION_DETAIL/csv" 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}" diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt index 818426da4..f9481d8fa 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollection.kt @@ -40,8 +40,8 @@ class ApiCollection(private val collection: Collection, private val ss: List get() = ss.map { ApiSkillSummary.fromSkill(it, appConfig) } + @get:JsonProperty + val owner: String? + get() = collection.workspaceOwner + companion object { fun fromDao(collectionDao: CollectionDao, appConfig: AppConfig): ApiCollection { return ApiCollection(collectionDao.toModel(), collectionDao.skills.map{ it.toModel() }, appConfig) diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt index 167a906ad..9f599de70 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiCollectionUpdate.kt @@ -1,5 +1,6 @@ package edu.wgu.osmt.api.model +import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonProperty import edu.wgu.osmt.db.PublishStatus @@ -7,6 +8,7 @@ data class ApiCollectionUpdate( @JsonProperty("name") val name: String? = null, + @JsonFormat(with= [JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES]) @JsonProperty("status") val publishStatus: PublishStatus? = null, diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt index cf775c1bf..0bd7993bd 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSkill.kt @@ -110,7 +110,7 @@ class ApiSkill(private val rsd: RichSkillDescriptor, private val cs: Set { return collectionRepository.createFromApi( apiCollectionUpdates, - richSkillRepository, oAuthHelper.readableUsername(user) + richSkillRepository, + oAuthHelper.readableUserName(user), + oAuthHelper.readableUserIdentifier(user) ).map { ApiCollection.fromDao(it, appConfig) } @@ -122,14 +126,13 @@ class CollectionController @Autowired constructor( val updated = collectionRepository.updateFromApi( existing.id.value, apiUpdate, - richSkillRepository, oAuthHelper.readableUsername(user) + richSkillRepository, oAuthHelper.readableUserName(user) ) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) return ApiCollection.fromDao(updated, appConfig) } - @PostMapping(RoutePaths.COLLECTION_SKILLS_UPDATE, produces = [MediaType.APPLICATION_JSON_VALUE]) @ResponseBody fun updateSkills( @@ -142,7 +145,7 @@ class CollectionController @Autowired constructor( @AuthenticationPrincipal user: Jwt? ): HttpEntity { val publishStatuses = status.mapNotNull { PublishStatus.forApiValue(it) }.toSet() - val task = UpdateCollectionSkillsTask(uuid, skillListUpdate, publishStatuses=publishStatuses, userString = oAuthHelper.readableUsername(user)) + val task = UpdateCollectionSkillsTask(uuid, skillListUpdate, publishStatuses=publishStatuses, userString = oAuthHelper.readableUserName(user)) taskMessageService.enqueueJob(TaskMessageService.updateCollectionSkills, task) return Task.processingResponse(task) @@ -164,7 +167,7 @@ class CollectionController @Autowired constructor( ): HttpEntity { val filterStatuses = filterByStatus.mapNotNull { PublishStatus.forApiValue(it) }.toSet() val publishStatus = PublishStatus.forApiValue(newStatus) ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST) - val task = PublishTask(AppliesToType.Collection, search, filterByStatus=filterStatuses, publishStatus = publishStatus, userString = oAuthHelper.readableUsername(user)) + val task = PublishTask(AppliesToType.Collection, search, filterByStatus=filterStatuses, publishStatus = publishStatus, userString = oAuthHelper.readableUserName(user)) taskMessageService.enqueueJob(TaskMessageService.publishSkills, task) return Task.processingResponse(task) @@ -174,7 +177,7 @@ class CollectionController @Autowired constructor( fun getSkillsForCollectionCsv( @PathVariable uuid: String ): HttpEntity { - if (collectionRepository.findByUUID(uuid)!!.publishStatus() == PublishStatus.Draft && !oAuthHelper.hasRole(appConfig.roleAdmin)) { + if (collectionRepository.findByUUID(uuid)!!.status == PublishStatus.Draft && !oAuthHelper.hasRole(appConfig.roleAdmin)) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } val task = CsvTask(collectionUuid = uuid) @@ -186,7 +189,6 @@ class CollectionController @Autowired constructor( fun removeCollection( @PathVariable uuid: String ): HttpEntity { - val task = RemoveCollectionSkillsTask(collectionUuid = uuid) taskMessageService.enqueueJob(TaskMessageService.removeCollectionSkills, task) return Task.processingResponse(task) @@ -203,4 +205,28 @@ class CollectionController @Autowired constructor( val sizedIterable = auditLogRepository.findByTableAndId(CollectionTable.tableName, entityId = collection!!.id.value, offsetPageable = pageable) return ResponseEntity.status(200).body(sizedIterable.toList().map{it.toModel()}) } + + @GetMapping(RoutePaths.WORKSPACE_PATH, produces = [MediaType.APPLICATION_JSON_VALUE]) + @ResponseBody + fun getOrCreateWorkspace( + @AuthenticationPrincipal user: Jwt? + ): ApiCollection? { + return collectionRepository.findByOwner( + oAuthHelper.readableUserIdentifier(user))?.let { + ApiCollection.fromDao(it, appConfig + ) + } ?: collectionRepository.createFromApi( + listOf( + ApiCollectionUpdate( + DEFAULT_WORKSPACE_NAME, + PublishStatus.Workspace, + oAuthHelper.readableUserName(user), + ApiStringListUpdate() + ) + ), + richSkillRepository, + oAuthHelper.readableUserName(user), + oAuthHelper.readableUserIdentifier(user) + ).firstOrNull()?.let { ApiCollection.fromDao(it, appConfig) } + } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt index 6013a0b7d..7f285f835 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionDao.kt @@ -17,6 +17,8 @@ class CollectionDao(id: EntityID) : LongEntity(id), OutputsModel) : LongEntity(id), OutputsModel) : LongEntity(id), OutputsModel, richSkillRepository: RichSkillRepository, - user: String + user: String, + email: String, ): List fun collectionUpdateObjectFromApi( @@ -107,11 +110,11 @@ class CollectionRepositoryImpl @Autowired constructor( return query?.let { dao.wrapRow(it) } } - override fun create(name: String, user: String): CollectionDao? { - return create(CollectionUpdateObject(name = name), user) + override fun create(name: String, user: String, email: String): CollectionDao? { + return create(CollectionUpdateObject(name = name), user, email) } - override fun create(updateObject: CollectionUpdateObject, user: String): CollectionDao? { + override fun create(updateObject: CollectionUpdateObject, user: String, email: String): CollectionDao? { if (updateObject.name.isNullOrBlank()) { return null } @@ -125,6 +128,9 @@ class CollectionRepositoryImpl @Autowired constructor( } updateObject.copy(id = newCollection.id.value).applyToDao(newCollection) + if(PublishStatus.Workspace == newCollection.status) { + newCollection.workspaceOwner = email + } newCollection.let { collectionEsRepo.save(it.toDoc()) @@ -235,10 +241,16 @@ class CollectionRepositoryImpl @Autowired constructor( } + override fun findByOwner(owner: String): CollectionDao? { + val query = table.select { table.workspaceOwner eq owner }.firstOrNull() + return query?.let { dao.wrapRow(it) } + } + override fun createFromApi( apiUpdates: List, richSkillRepository: RichSkillRepository, - user: String + user: String, + email: String ): List { // pre validate all rows val allErrors = apiUpdates.mapIndexed { i, updateDto -> @@ -251,7 +263,7 @@ class CollectionRepositoryImpl @Autowired constructor( // create records val newSkills = apiUpdates.map { update -> val updateObject = collectionUpdateObjectFromApi(update, richSkillRepository) - create(updateObject, user) + create(updateObject, user, email) } return newSkills.filterNotNull() } @@ -270,7 +282,8 @@ class CollectionRepositoryImpl @Autowired constructor( val collectionUpdateObject = collectionUpdateObjectFromApi(collectionUpdate, richSkillRepository) val updateObjectWithId = collectionUpdateObject.copy( - id = existingCollectionId + id = existingCollectionId, + publishStatus = collectionUpdate.publishStatus ) return update(updateObjectWithId, user) diff --git a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionTable.kt b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionTable.kt index 48fcaa0a1..1140b8c62 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionTable.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/collection/CollectionTable.kt @@ -1,12 +1,18 @@ package edu.wgu.osmt.collection +import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.db.PublishStatusUpdate import edu.wgu.osmt.db.TableWithUpdate import edu.wgu.osmt.keyword.KeywordTable import edu.wgu.osmt.richskill.RichSkillDescriptorTable import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insertIgnore import org.jetbrains.exposed.sql.`java-time`.datetime import java.time.LocalDateTime @@ -23,8 +29,10 @@ object CollectionTable: TableWithUpdate, PublishStatusUp onDelete = ReferenceOption.RESTRICT, onUpdate = ReferenceOption.CASCADE ).nullable() - - + val workspaceOwner = varchar("workspace_owner", 64).index().default("") + val status = customEnumeration( + "status", + fromDb = { value -> PublishStatus.forApiValue(value as String)!! }, toDb = { it.name }).default(PublishStatus.Draft) } object CollectionSkills : Table("CollectionSkills") { diff --git a/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt b/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt index e02911182..62bc730bc 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/config/AppConfig.kt @@ -31,6 +31,12 @@ class AppConfig( @Value("\${app.loginSuccessRedirectUrl}") val loginSuccessRedirectUrl: String, + @Value("\${app.userName:name}") + val userName: String, + + @Value("\${app.userIdentifier:email}") + val userIdentifier: String, + @Value("\${app.allowPublicSearching}") val allowPublicSearching: Boolean = true, diff --git a/api/src/main/kotlin/edu/wgu/osmt/config/Constants.kt b/api/src/main/kotlin/edu/wgu/osmt/config/Constants.kt index 3b5332703..1212728a9 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/config/Constants.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/config/Constants.kt @@ -2,6 +2,7 @@ package edu.wgu.osmt.config const val UNAUTHENTICATED_USERNAME = "unauthenticated" const val QUOTED_SEARCH_REGEX_PATTERN = "([\"\'])(?:(?=(\\\\?))\\2.)*?\\1" +const val DEFAULT_WORKSPACE_NAME = "My Workspace" // API parameter constants const val CATEGORY_ASC = "name.asc" diff --git a/api/src/main/kotlin/edu/wgu/osmt/csv/BatchImportRichSkill.kt b/api/src/main/kotlin/edu/wgu/osmt/csv/BatchImportRichSkill.kt index 2135298a2..9f1342e9c 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/csv/BatchImportRichSkill.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/csv/BatchImportRichSkill.kt @@ -118,7 +118,7 @@ class BatchImportRichSkill: CsvImport { fun parseCollections(rowValue: String?): List? { return splitField(rowValue)?.filter { it.isNotBlank() }?.mapNotNull { collectionName -> val collection = collectionRepository.findByName(collectionName) - collection ?: collectionRepository.create(CollectionUpdateObject(name = collectionName, author = NullableFieldUpdate(keywordRepository.getDefaultAuthor())), user) + collection ?: collection?.let { collectionRepository.create(CollectionUpdateObject(name = collectionName, author = NullableFieldUpdate(keywordRepository.getDefaultAuthor())), user, email = it.workspaceOwner) } } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/db/PublishStatus.kt b/api/src/main/kotlin/edu/wgu/osmt/db/PublishStatus.kt index 6c146a64b..18a81f590 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/db/PublishStatus.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/db/PublishStatus.kt @@ -1,16 +1,18 @@ package edu.wgu.osmt.db +import com.fasterxml.jackson.annotation.JsonValue + const val UNARCHIVED = "unarchived" const val DELETED = "deleted" - +const val WORKSPACE = "workspace" const val PUBLISHED = "published" const val ARCHIVED = "archived" const val DRAFT = "draft" -enum class PublishStatus(val apiValue: String) { +enum class PublishStatus(@JsonValue val apiValue: String) { Unarchived(UNARCHIVED), Deleted(DELETED), - + Workspace(WORKSPACE), Published(PUBLISHED), Archived(ARCHIVED), Draft(DRAFT); diff --git a/api/src/main/kotlin/edu/wgu/osmt/db/UpdateObject.kt b/api/src/main/kotlin/edu/wgu/osmt/db/UpdateObject.kt index ea0053a68..af9f68e1d 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/db/UpdateObject.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/db/UpdateObject.kt @@ -1,5 +1,7 @@ package edu.wgu.osmt.db +import edu.wgu.osmt.collection.CollectionDao +import org.apache.commons.lang3.StringUtils import org.jetbrains.exposed.dao.LongEntity import java.time.LocalDateTime import java.time.ZoneOffset @@ -27,5 +29,11 @@ interface HasPublishStatus { } else -> {} // draft is non-op } + if(dao is CollectionDao && publishStatus != null) { + dao.status = publishStatus as PublishStatus + if (dao.status != PublishStatus.Workspace) { + dao.workspaceOwner = StringUtils.EMPTY + } + } } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/elasticsearch/SearchController.kt b/api/src/main/kotlin/edu/wgu/osmt/elasticsearch/SearchController.kt index d7cc9287b..d8091e0bf 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/elasticsearch/SearchController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/elasticsearch/SearchController.kt @@ -3,7 +3,13 @@ package edu.wgu.osmt.elasticsearch import edu.wgu.osmt.PaginationDefaults import edu.wgu.osmt.RoutePaths import edu.wgu.osmt.api.GeneralApiException -import edu.wgu.osmt.api.model.* +import edu.wgu.osmt.api.model.ApiJobCode +import edu.wgu.osmt.api.model.ApiNamedReference +import edu.wgu.osmt.api.model.ApiSearch +import edu.wgu.osmt.api.model.ApiSimilaritySearch +import edu.wgu.osmt.api.model.ApiSkillSummary +import edu.wgu.osmt.api.model.CollectionSortEnum +import edu.wgu.osmt.api.model.SkillSortEnum import edu.wgu.osmt.collection.CollectionDoc import edu.wgu.osmt.collection.CollectionEsRepo import edu.wgu.osmt.config.AppConfig @@ -14,12 +20,21 @@ import edu.wgu.osmt.keyword.KeywordTypeEnum import edu.wgu.osmt.richskill.RichSkillDoc import edu.wgu.osmt.richskill.RichSkillEsRepo import org.springframework.beans.factory.annotation.Autowired -import org.springframework.http.* +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 import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.oauth2.jwt.Jwt import org.springframework.stereotype.Controller import org.springframework.transaction.annotation.Transactional -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.server.ResponseStatusException import org.springframework.web.util.UriComponentsBuilder diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/CreateSkillsTaskProcessor.kt b/api/src/main/kotlin/edu/wgu/osmt/richskill/CreateSkillsTaskProcessor.kt index 28778d3d0..d232bf286 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/CreateSkillsTaskProcessor.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/CreateSkillsTaskProcessor.kt @@ -37,7 +37,7 @@ class CreateSkillsTaskProcessor { fun process(task: CreateSkillsTask) { logger.info("Started processing createSkillsTask uuid: ${task.uuid}") - val results = richSkillRepository.createFromApi(task.apiSkillUpdates, task.userString).map { + val results = richSkillRepository.createFromApi(task.apiSkillUpdates, task.userString, task.userIdentifier).map { it.uuid } 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 707543dc3..3bd85c727 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt @@ -82,7 +82,7 @@ class RichSkillController @Autowired constructor( @RequestBody apiSkillUpdates: List, @AuthenticationPrincipal user: Jwt? ): HttpEntity { - val task = CreateSkillsTask(apiSkillUpdates) + val task = CreateSkillsTask(apiSkillUpdates, oAuthHelper.readableUserName(user), oAuthHelper.readableUserIdentifier(user)) taskMessageService.enqueueJob(TaskMessageService.createSkills, task) return Task.processingResponse(task) } @@ -150,7 +150,7 @@ class RichSkillController @Autowired constructor( ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) val updatedSkill = - richSkillRepository.updateFromApi(existingSkill.id.value, skillUpdate, oAuthHelper.readableUsername(user)) + richSkillRepository.updateFromApi(existingSkill.id.value, skillUpdate, oAuthHelper.readableUserName(user), oAuthHelper.readableUserIdentifier(user)) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) return ApiSkill.fromDao(updatedSkill, appConfig) @@ -181,7 +181,7 @@ class RichSkillController @Autowired constructor( search, filterByStatus=filterStatuses, publishStatus = publishStatus, - userString = oAuthHelper.readableUsername(user), + userString = oAuthHelper.readableUserName(user), collectionUuid = if (collectionUuid.isNullOrBlank()) null else collectionUuid ) diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillRepository.kt b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillRepository.kt index 3dbc158a3..254144a3f 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillRepository.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillRepository.kt @@ -1,7 +1,11 @@ package edu.wgu.osmt.richskill import edu.wgu.osmt.api.FormValidationException -import edu.wgu.osmt.api.model.* +import edu.wgu.osmt.api.model.ApiAlignmentListUpdate +import edu.wgu.osmt.api.model.ApiBatchResult +import edu.wgu.osmt.api.model.ApiReferenceListUpdate +import edu.wgu.osmt.api.model.ApiSkillUpdate +import edu.wgu.osmt.api.model.ApiStringListUpdate import edu.wgu.osmt.auditlog.AuditLog import edu.wgu.osmt.auditlog.AuditLogRepository import edu.wgu.osmt.auditlog.AuditOperationType @@ -41,9 +45,9 @@ interface RichSkillRepository : PaginationHelpers { fun findManyByUUIDs(uuids: List): List? fun create(updateObject: RsdUpdateObject, user: String): RichSkillDescriptorDao? - fun createFromApi(skillUpdates: List, user: String): List - fun updateFromApi(existingSkillId: Long, skillUpdate: ApiSkillUpdate, user: String): RichSkillDescriptorDao? - fun rsdUpdateFromApi(skillUpdate: ApiSkillUpdate, user: String): RsdUpdateObject + fun createFromApi(skillUpdates: List, user: String, userEmail: String): List + fun updateFromApi(existingSkillId: Long, skillUpdate: ApiSkillUpdate, user: String, email: String): RichSkillDescriptorDao? + fun rsdUpdateFromApi(skillUpdate: ApiSkillUpdate, user: String, userEmail: String): RsdUpdateObject fun changeStatusesForTask(task: PublishTask): ApiBatchResult @@ -163,7 +167,7 @@ class RichSkillRepositoryImpl @Autowired constructor( return newRsd } - override fun createFromApi(skillUpdates: List, user: String): List { + override fun createFromApi(skillUpdates: List, user: String, userEmail: String): List { // pre validate all rows val allErrors = skillUpdates.mapIndexed { i, updateDto -> updateDto.validateForCreation(i) @@ -174,7 +178,7 @@ class RichSkillRepositoryImpl @Autowired constructor( // create records val newSkills = skillUpdates.map { update -> - val rsdUpdateObject = rsdUpdateFromApi(update, user) + val rsdUpdateObject = rsdUpdateFromApi(update, user, userEmail) create(rsdUpdateObject, user) } return newSkills.filterNotNull() @@ -183,21 +187,22 @@ class RichSkillRepositoryImpl @Autowired constructor( override fun updateFromApi( existingSkillId: Long, skillUpdate: ApiSkillUpdate, - user: String + user: String, + email: String ): RichSkillDescriptorDao? { val errors = skillUpdate.validate(0) if (errors?.isNotEmpty() == true) { throw FormValidationException("Invalid SkillUpdateDescriptor", errors) } - val rsdUpdateObject = rsdUpdateFromApi(skillUpdate, user) + val rsdUpdateObject = rsdUpdateFromApi(skillUpdate, user, email) val updateObjectWithId = rsdUpdateObject.copy( id = existingSkillId ) return update(updateObjectWithId, user) } - override fun rsdUpdateFromApi(skillUpdate: ApiSkillUpdate, user: String): RsdUpdateObject { + override fun rsdUpdateFromApi(skillUpdate: ApiSkillUpdate, user: String, email: String): RsdUpdateObject { val authorKeyword = skillUpdate.author?.let { keywordRepository.findOrCreate(KeywordTypeEnum.Author, value = it) } @@ -216,13 +221,13 @@ class RichSkillRepositoryImpl @Autowired constructor( skillUpdate.collections?.let { slu -> slu.add?.mapNotNull { - collectionRepository.findByName(it) ?: collectionRepository.create(it, user) + collectionRepository.findByName(it) ?: collectionRepository.create(it, user, email) }?.let { addingCollections.addAll(it) } slu.remove?.mapNotNull { - collectionRepository.findByName(it) ?: collectionRepository.create(it, user) + collectionRepository.findByName(it) ?: collectionRepository.create(it, user, email) }?.let { removingCollections.addAll(it) } diff --git a/api/src/main/kotlin/edu/wgu/osmt/security/OAuthHelper.kt b/api/src/main/kotlin/edu/wgu/osmt/security/OAuthHelper.kt index 4b2cf6040..0161972ac 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/security/OAuthHelper.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/security/OAuthHelper.kt @@ -1,9 +1,11 @@ package edu.wgu.osmt.security +import edu.wgu.osmt.config.AppConfig import edu.wgu.osmt.config.UNAUTHENTICATED_USERNAME import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.db.PublishStatus.Archived import edu.wgu.osmt.db.PublishStatus.Unarchived +import org.springframework.beans.factory.annotation.Autowired import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.oauth2.core.user.OAuth2User import org.springframework.security.oauth2.jwt.Jwt @@ -13,12 +15,19 @@ import org.springframework.stereotype.Component @Component class OAuthHelper { - fun readableUsername(user: OAuth2User?, default: String = UNAUTHENTICATED_USERNAME): String { + @Autowired + lateinit var appConfig: AppConfig + + fun readableUserName(user: OAuth2User?, default: String = UNAUTHENTICATED_USERNAME): String { return user?.name ?: default } - fun readableUsername(jwt: Jwt?, default: String = UNAUTHENTICATED_USERNAME): String { - return jwt?.claims?.get("name") as String? ?: default + fun readableUserName(jwt: Jwt?, default: String = UNAUTHENTICATED_USERNAME): String { + return jwt?.claims?.get(appConfig.userName) as String? ?: default + } + + fun readableUserIdentifier(jwt: Jwt?, default: String = UNAUTHENTICATED_USERNAME): String { + return jwt?.claims?.get(appConfig.userIdentifier) as String? ?: default } fun hasRole(role: String): Boolean { 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 8510e3bba..3e8cdc7c0 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/security/SecurityConfig.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/security/SecurityConfig.kt @@ -24,6 +24,7 @@ import edu.wgu.osmt.RoutePaths.SKILL_UPDATE import edu.wgu.osmt.RoutePaths.TASK_DETAIL_BATCH import edu.wgu.osmt.RoutePaths.TASK_DETAIL_SKILLS import edu.wgu.osmt.RoutePaths.TASK_DETAIL_TEXT +import edu.wgu.osmt.RoutePaths.WORKSPACE_PATH import edu.wgu.osmt.api.model.ApiError import edu.wgu.osmt.config.AppConfig import org.springframework.beans.factory.annotation.Autowired @@ -128,8 +129,9 @@ class SecurityConfig : WebSecurityConfigurerAdapter() { .mvcMatchers(POST, COLLECTION_CREATE).hasAnyAuthority(ADMIN, CURATOR) .mvcMatchers(POST, COLLECTION_PUBLISH).hasAnyAuthority(ADMIN) .mvcMatchers(POST, COLLECTION_UPDATE).hasAnyAuthority(ADMIN, CURATOR) - .mvcMatchers(POST, COLLECTION_SKILLS_UPDATE).hasAnyAuthority(ADMIN) + .mvcMatchers(POST, COLLECTION_SKILLS_UPDATE).hasAnyAuthority(ADMIN, CURATOR) .mvcMatchers(DELETE, COLLECTION_REMOVE).hasAnyAuthority(ADMIN) + .mvcMatchers(GET, WORKSPACE_PATH).hasAnyAuthority(ADMIN, CURATOR) .mvcMatchers("/api/**").hasAnyAuthority(ADMIN, CURATOR, VIEW, READ) } 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 3f8c815f7..d615b07ad 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/task/Task.kt @@ -85,6 +85,7 @@ data class ExportSkillsToCsvTask( data class CreateSkillsTask( val apiSkillUpdates: List = listOf(), val userString: String = "", + val userIdentifier: String = "", override val uuid: String = UUID.randomUUID().toString(), override val start: Date = Date(), override val result: List? = null, diff --git a/api/src/main/resources/config/application.properties b/api/src/main/resources/config/application.properties index 73e3561fe..0025b1261 100644 --- a/api/src/main/resources/config/application.properties +++ b/api/src/main/resources/config/application.properties @@ -46,6 +46,11 @@ app.baseDomain=osmt.wgu.edu app.baseUrl=https://${app.baseDomain} app.frontendUrl=https://${app.baseDomain} app.loginSuccessRedirectUrl=${app.frontendUrl}/login/success + +# JWT claims +app.userName=name +app.userIdentifier=email + app.security.cors.allowedOrigins=${app.baseUrl} # Stop repository auto configuration spring.data.jdbc.repositories.enabled=false diff --git a/api/src/main/resources/db/migration/V2023.01.26__add_status_and_owner_fields.sql b/api/src/main/resources/db/migration/V2023.01.26__add_status_and_owner_fields.sql new file mode 100644 index 000000000..9dd4fcf5e --- /dev/null +++ b/api/src/main/resources/db/migration/V2023.01.26__add_status_and_owner_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE Collection ADD workspace_owner VARCHAR(64) NOT NULL DEFAULT ''; +ALTER TABLE Collection ADD status enum ('unarchived','deleted','workspace','published','archived','draft') NOT NULL DEFAULT 'draft'; +CREATE INDEX Collection_workspace_owner ON Collection (workspace_owner); \ No newline at end of file diff --git a/api/src/test/kotlin/edu/wgu/osmt/SpringTest.kt b/api/src/test/kotlin/edu/wgu/osmt/SpringTest.kt index 14edda3d6..cb536595c 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/SpringTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/SpringTest.kt @@ -35,6 +35,8 @@ abstract class SpringTest: BaseDockerizedTest { val testUser = "test-user" + val testEmail = "test@email.com" + init { logger.info("Mysql port ${Containers.mysqlContainer.getMappedPort(3306)}") logger.info("Redis port ${Containers.redisContainer.getMappedPort(6379)}") diff --git a/api/src/test/kotlin/edu/wgu/osmt/TestObjectHelpers.kt b/api/src/test/kotlin/edu/wgu/osmt/TestObjectHelpers.kt index 2b5f0668b..6112c1398 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/TestObjectHelpers.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/TestObjectHelpers.kt @@ -1,6 +1,11 @@ package edu.wgu.osmt -import edu.wgu.osmt.api.model.* +import edu.wgu.osmt.api.model.ApiAlignment +import edu.wgu.osmt.api.model.ApiAlignmentListUpdate +import edu.wgu.osmt.api.model.ApiNamedReference +import edu.wgu.osmt.api.model.ApiReferenceListUpdate +import edu.wgu.osmt.api.model.ApiSkillUpdate +import edu.wgu.osmt.api.model.ApiStringListUpdate import edu.wgu.osmt.collection.CollectionDoc import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.jobcode.JobCode @@ -38,7 +43,8 @@ object TestObjectHelpers { publishStatus = publishStatus, skillIds = skillIds, skillCount = skillIds.count(), - author = author ?: authorString + "-collection" + author = author ?: authorString + "-collection", + workspaceOwner = "owner@email.com" ) fun randomString(): String = UUID.randomUUID().toString().replace("-", "") diff --git a/api/src/test/kotlin/edu/wgu/osmt/auditlog/AuditLogRepositoryTest.kt b/api/src/test/kotlin/edu/wgu/osmt/auditlog/AuditLogRepositoryTest.kt index a07ddc423..cb57dd77b 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/auditlog/AuditLogRepositoryTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/auditlog/AuditLogRepositoryTest.kt @@ -14,7 +14,11 @@ import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.jobcode.JobCodeRepository import edu.wgu.osmt.keyword.KeywordRepository import edu.wgu.osmt.keyword.KeywordTypeEnum -import edu.wgu.osmt.richskill.* +import edu.wgu.osmt.richskill.RichSkillDescriptor +import edu.wgu.osmt.richskill.RichSkillDescriptorDao +import edu.wgu.osmt.richskill.RichSkillDescriptorTable +import edu.wgu.osmt.richskill.RichSkillRepository +import edu.wgu.osmt.richskill.RsdUpdateObject import org.assertj.core.api.Assertions.assertThat import org.jetbrains.exposed.sql.SizedIterable import org.junit.jupiter.api.Test @@ -57,7 +61,7 @@ class AuditLogRepositoryTest @Autowired constructor( @Test fun `generates an audit log on collection creation`() { - val collectionDao = collectionRepository.create("test collection", testUser) + val collectionDao = collectionRepository.create("test collection", testUser, testEmail) val auditLog = collectionDao?.id?.value?.let { auditLogRepository.findByTableAndId(CollectionTable.tableName, it) }?.first() @@ -87,7 +91,7 @@ class AuditLogRepositoryTest @Autowired constructor( val initialCollectionUpdate = CollectionUpdateObject(name = "test collection", skills = ListFieldUpdate(add = listOf(initialSkill!!))) - val collectionDao = collectionRepository.create(initialCollectionUpdate, testUser) + val collectionDao = collectionRepository.create(initialCollectionUpdate, testUser, testEmail) val newAuthorDao = keywordRepository.create(KeywordTypeEnum.Author, updatedAuthorName) val collectionUpdateObject = CollectionUpdateObject( @@ -170,7 +174,7 @@ class AuditLogRepositoryTest @Autowired constructor( val keywordDaos = TestObjectHelpers.keywordsGenerator(10, KeywordTypeEnum.Keyword) .mapNotNull { keywordRepository.create(it.type, it.value) } val jobCodeDaos = listOf(jobCodeRepository.create("11-1170")) - val collectionDaos = listOf(collectionRepository.create("test collection", testUser)!!) + val collectionDaos = listOf(collectionRepository.create("test collection", testUser, testEmail)!!) val newName = "updated skill" val newStatement = "new statement" @@ -327,7 +331,7 @@ class AuditLogRepositoryTest @Autowired constructor( CollectionUpdateObject(name = initialCollectionName, skills = ListFieldUpdate(add = listOf(initialSkill!!))) // Act - val collectionDao = collectionRepository.create(initialCollectionUpdate, testUser) + val collectionDao = collectionRepository.create(initialCollectionUpdate, testUser, testEmail) val collectionUpdate = CollectionUpdateObject(id = collectionDao?.id?.value, name = updatedCollectionName) collectionRepository.update(collectionUpdate, testUser) diff --git a/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionControllerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionControllerTest.kt new file mode 100644 index 000000000..3912bf6af --- /dev/null +++ b/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionControllerTest.kt @@ -0,0 +1,73 @@ +package edu.wgu.osmt.collection + +import edu.wgu.osmt.BaseDockerizedTest +import edu.wgu.osmt.HasDatabaseReset +import edu.wgu.osmt.HasElasticsearchReset +import edu.wgu.osmt.SpringTest +import edu.wgu.osmt.config.AppConfig +import edu.wgu.osmt.db.PublishStatus +import edu.wgu.osmt.jobcode.JobCodeEsRepo +import edu.wgu.osmt.keyword.KeywordEsRepo +import edu.wgu.osmt.mockdata.MockData +import edu.wgu.osmt.richskill.RichSkillEsRepo +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.oauth2.jwt.Jwt +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.transaction.annotation.Transactional + +@Transactional +internal class CollectionControllerTest @Autowired constructor( + val collectionRepository: CollectionRepository, + val appConfig: AppConfig, + override val collectionEsRepo: CollectionEsRepo, + override val keywordEsRepo: KeywordEsRepo, + override val jobCodeEsRepo: JobCodeEsRepo, + override val richSkillEsRepo: RichSkillEsRepo +): SpringTest(), BaseDockerizedTest, HasDatabaseReset, HasElasticsearchReset { + + @Autowired + lateinit var collectionController: CollectionController + + private lateinit var mockData : MockData + + val userString = "unittestuser" + + val userEmail = "unit@test.user" + + + @BeforeAll + fun setup() { + mockData = MockData() + ReflectionTestUtils.setField(appConfig, "roleAdmin", "ROLE_Osmt_Admin") + } + + @Test + fun `workspaceByOwner() should retrieve an existing workspace`() { + // arrange + collectionRepository.create(CollectionUpdateObject(12345,"testCollection",null,null, PublishStatus.Workspace), userString, userEmail) + val jwt = Jwt.withTokenValue("foo").header("foo", "foo").claim("email", userEmail).build() + + // act + val result = collectionController.getOrCreateWorkspace(jwt) + + // assert + Assertions.assertThat(result).isNotNull + } + + @Test + fun `workspaceByOwner() should create a workspace if it does not exist`() { + // arrange + val jwt = Jwt.withTokenValue("foo").header("foo", "foo").claim("email", userEmail).build() + + // act + Assertions.assertThat(collectionRepository.findAll().toList()).hasSize(0) + val result = collectionController.getOrCreateWorkspace(jwt) + + // assert + Assertions.assertThat(collectionRepository.findAll().toList()).hasSize(1) + Assertions.assertThat(result).isNotNull + } +} \ No newline at end of file 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 3f32415d2..8ef892bf9 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionRepositoryTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/collection/CollectionRepositoryTest.kt @@ -33,19 +33,21 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes val userString = "unittestuser" + val userEmail = "unit@test.user" + @Test fun `should not create a blank collection`() { - assertThat(collectionRepository.create(CollectionUpdateObject(), userString)).isNull() - assertThat(collectionRepository.create(CollectionUpdateObject(name=""), userString)).isNull() - assertThat(collectionRepository.create(CollectionUpdateObject(name=" "), userString)).isNull() + assertThat(collectionRepository.create(CollectionUpdateObject(), userString, userEmail)).isNull() + assertThat(collectionRepository.create(CollectionUpdateObject(name=""), userString, userEmail)).isNull() + assertThat(collectionRepository.create(CollectionUpdateObject(name=" "), userString, userEmail)).isNull() } @Test fun `should create collections from ApiCollectionUpdate objects`() { val count = 1 val updates = (1..count).toList().map { random_collection_update() } - val results = collectionRepository.createFromApi(updates, richSkillRepository, userString) + val results = collectionRepository.createFromApi(updates, richSkillRepository, userString, userEmail) results.forEachIndexed { i, collectionDao -> val collectionAndSkills = CollectionAndRichSkills.fromDao(collectionDao) @@ -91,7 +93,7 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes @Test fun `should update an existing collection from an ApiCollectionUpdate`() { val originalUpdate = random_collection_update() - val originalDao = collectionRepository.createFromApi(listOf(originalUpdate), richSkillRepository, userString).first() + val originalDao = collectionRepository.createFromApi(listOf(originalUpdate), richSkillRepository, userString, userEmail).first() var newUpdate = random_collection_update() newUpdate = newUpdate.copy( @@ -115,11 +117,11 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes ) } - val collectionDao = collectionRepository.create(UUID.randomUUID().toString(), userString) + val collectionDao = collectionRepository.create(UUID.randomUUID().toString(), userString, userEmail) val collection = collectionDao!!.toModel() - val skillDaos = richSkillRepository.createFromApi(skillUpdates, userString) - val knownDaos = richSkillRepository.createFromApi(knownUpdates, userString) + val skillDaos = richSkillRepository.createFromApi(skillUpdates, userString, userEmail) + val knownDaos = richSkillRepository.createFromApi(knownUpdates, userString, userEmail) assertThat(skillDaos.size + knownDaos.size).isEqualTo(totalSkillCount) val task = UpdateCollectionSkillsTask( @@ -148,11 +150,33 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes assertThat(collectionDao).isNotNull } + @Test + fun `findByOwner() should find an existing workspace`() { + // Arrange + collectionRepository.create(CollectionUpdateObject(12345,"My Workspace",null,null,PublishStatus.Workspace), userString, userEmail) + + // Act + val collectionDao = collectionRepository.findByOwner("unit@test.user") + + // Assert + assertThat(collectionDao).isNotNull + assertThat(collectionDao!!.workspaceOwner).isEqualTo(userEmail) + } + + @Test + fun `findByOwner() should return null if there is no Workspace for an owner`() { + // Act + val collectionDao = collectionRepository.findByOwner("unit@test.user") + + // Assert + assertThat(collectionDao).isNull() + } + @Test fun testChangeStatusesForTask() { // Arrange val collectionsCount = 2 - val collections = (1..collectionsCount).toList().map { collectionRepository.create(UUID.randomUUID().toString(), userString)!!.toModel() } + val collections = (1..collectionsCount).toList().map { collectionRepository.create(UUID.randomUUID().toString(), userString, userEmail)!!.toModel() } val task = PublishTask( search=ApiSearch(uuids= collections.map { it.uuid }), @@ -167,15 +191,16 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes } + @Test fun testChangeStatusesForTaskWithCollectionId() { // Arrange val skillCount = 3 val skills = (1..skillCount).toList().map { TestObjectHelpers.apiSkillUpdateGenerator() } - val collectionDao = collectionRepository.create(UUID.randomUUID().toString(), userString) + val collectionDao = collectionRepository.create(UUID.randomUUID().toString(), userString, userEmail) val collection = collectionDao!!.toModel() - richSkillRepository.createFromApi(skills, userString) + richSkillRepository.createFromApi(skills, userString, userEmail) val task = PublishTask( search=ApiSearch(), @@ -193,7 +218,7 @@ class CollectionRepositoryTest: SpringTest(), BaseDockerizedTest, HasDatabaseRes @Test fun `remove finds and successfully removes an existing collection`() { // Arrange - val collection = collectionRepository.create(UUID.randomUUID().toString(), userString)!!.toModel() + val collection = collectionRepository.create(UUID.randomUUID().toString(), userString, userEmail)!!.toModel() val updateObject = RsdUpdateObject(name = "test skill", statement = testUser) val skillDao = richSkillRepository.create(updateObject, testUser) collection.id?.let { CollectionSkills.create(it, skillDao!!.id.value) } diff --git a/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/ElasticSearchReindexerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/ElasticSearchReindexerTest.kt index 46500fbc4..52f796a0b 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/ElasticSearchReindexerTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/ElasticSearchReindexerTest.kt @@ -43,7 +43,7 @@ internal class ElasticSearchReindexerTest @Autowired constructor( // Arrange // This creates 1 skill, 3 collections, 17 keywords and 3 jobCodes richSkillRepository.createFromApi( - (1..1).toList().map { TestObjectHelpers.apiSkillUpdateGenerator() }, "testReindexAll" + (1..1).toList().map { TestObjectHelpers.apiSkillUpdateGenerator() }, "testReindexAll", "testReindexAll" ) // Act diff --git a/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/SearchControllerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/SearchControllerTest.kt index 212bcd9d2..c1514256e 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/SearchControllerTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/elasticsearch/SearchControllerTest.kt @@ -51,7 +51,7 @@ internal class SearchControllerTest @Autowired constructor( UriComponentsBuilder.newInstance(), 50, 0, - arrayOf("draft", "published"), + arrayOf("draft", "published", "workspace"), "", ApiSearch(advanced = ApiAdvancedSearch(collectionName = collectionDoc?.name)), nullJwt) 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 d37d40df9..888b152aa 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/mockdata/MockData.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/mockdata/MockData.kt @@ -243,6 +243,8 @@ class MockData { "https://osmt.wgu.edu/credentialengineerregistry", "http://localhost:4200", "http://localhost:4200/login/success", + "user", + "user@email.com", true, true, false, @@ -297,12 +299,13 @@ class MockData { c.id!!, c.uuid!!, c.name!!, - PublishStatus.valueOf(c.status!!), + c.status?.let { PublishStatus.forApiValue(it) }!!, skillUUIDs as List, c.skillsCount, lookupKeywordValue(c.author?.toLong()), parseDateTime(c.archiveDate), - parseDateTime(c.publishDate) + parseDateTime(c.publishDate), + c.workspaceOwner!! ) } } @@ -379,7 +382,8 @@ class MockData { name = doc.name, author = lookupKeywordByValue(doc.author), archiveDate = doc.archiveDate, - publishDate = doc.publishDate + publishDate = doc.publishDate, + status = doc.publishStatus ) } @@ -393,7 +397,8 @@ class MockData { publishDate = c.publishDate, publishStatus = c.publishStatus(), skillCount = 0, - skillIds = null + skillIds = null, + workspaceOwner = c.workspaceOwner ) } diff --git a/api/src/test/kotlin/edu/wgu/osmt/mockdata/xml/Collection.kt b/api/src/test/kotlin/edu/wgu/osmt/mockdata/xml/Collection.kt index fd33f048d..e3fa674f9 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/mockdata/xml/Collection.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/mockdata/xml/Collection.kt @@ -10,4 +10,5 @@ class Collection { var author: String? = null var archiveDate: String? = null var publishDate: String? = null + var workspaceOwner: String? = null } \ No newline at end of file diff --git a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillRepositoryTest.kt b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillRepositoryTest.kt index 619fae3dd..6ab423b94 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillRepositoryTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillRepositoryTest.kt @@ -1,10 +1,18 @@ package edu.wgu.osmt.richskill -import edu.wgu.osmt.* +import edu.wgu.osmt.BaseDockerizedTest +import edu.wgu.osmt.HasDatabaseReset +import edu.wgu.osmt.HasElasticsearchReset +import edu.wgu.osmt.SpringTest +import edu.wgu.osmt.TestObjectHelpers import edu.wgu.osmt.TestObjectHelpers.apiSkillUpdateGenerator import edu.wgu.osmt.TestObjectHelpers.assertThatKeywordMatchesAlignment import edu.wgu.osmt.TestObjectHelpers.assertThatKeywordMatchesNamedReference -import edu.wgu.osmt.api.model.* +import edu.wgu.osmt.api.model.ApiAlignmentListUpdate +import edu.wgu.osmt.api.model.ApiReferenceListUpdate +import edu.wgu.osmt.api.model.ApiSearch +import edu.wgu.osmt.api.model.ApiSkillUpdate +import edu.wgu.osmt.api.model.ApiStringListUpdate import edu.wgu.osmt.collection.Collection import edu.wgu.osmt.collection.CollectionEsRepo import edu.wgu.osmt.collection.CollectionRepository @@ -43,6 +51,8 @@ class RichSkillRepositoryTest @Autowired constructor( lateinit var keywordRepository: KeywordRepository val userString = "unittestuser" + + val userEmail = "user@email.com" fun assertThatKeywordsMatchStringList(keywords: List, stringList: ApiStringListUpdate) { @@ -133,7 +143,11 @@ class RichSkillRepositoryTest @Autowired constructor( @Test fun `should update an existing skill from an ApiSkillUpdate object`() { val originalSkillUpdate = apiSkillUpdateGenerator() - val originalSkillDao = richSkillRepository.createFromApi(listOf(originalSkillUpdate), userString).first() + val originalSkillDao = richSkillRepository.createFromApi( + listOf(originalSkillUpdate), + userString, + userEmail + ).first() var newSkillUpdate = apiSkillUpdateGenerator() newSkillUpdate = newSkillUpdate.copy( @@ -146,7 +160,12 @@ class RichSkillRepositoryTest @Autowired constructor( collections=newSkillUpdate.collections?.copy(remove=originalSkillUpdate.collections?.add) ) - val updatedDao = richSkillRepository.updateFromApi(originalSkillDao.id.value, newSkillUpdate, userString) + val updatedDao = richSkillRepository.updateFromApi( + originalSkillDao.id.value, + newSkillUpdate, + userString, + userEmail + ) assertThat(updatedDao).isNotNull assertThatRichSkillMatchesApiSkillUpdate(RichSkillAndCollections.fromDao(updatedDao!!), newSkillUpdate) } @@ -156,7 +175,11 @@ class RichSkillRepositoryTest @Autowired constructor( val skillCount = 1 val skillUpdates = (1..skillCount).toList().map { apiSkillUpdateGenerator() } - val results: List = richSkillRepository.createFromApi(skillUpdates, userString) + val results: List = richSkillRepository.createFromApi( + skillUpdates, + userString, + userEmail + ) results.forEachIndexed { i, skillDao -> val skillAndCollections = RichSkillAndCollections.fromDao(skillDao) @@ -264,7 +287,12 @@ class RichSkillRepositoryTest @Autowired constructor( val apiUpdate = ApiSkillUpdate( skillName=newName ) - var apiUpdated = richSkillRepository.updateFromApi(created!!.id!!, apiUpdate, userString)?.toModel() + var apiUpdated = richSkillRepository.updateFromApi( + created!!.id!!, + apiUpdate, + userString, + userEmail + )?.toModel() assertThat(apiUpdated).isNotNull assertThat(apiUpdated?.category?.value).isEqualTo(categoryName) @@ -272,7 +300,12 @@ class RichSkillRepositoryTest @Autowired constructor( val apiUpdateBlank = ApiSkillUpdate( category="" ) - apiUpdated = richSkillRepository.updateFromApi(created.id!!, apiUpdateBlank, userString)?.toModel() + apiUpdated = richSkillRepository.updateFromApi( + created.id!!, + apiUpdateBlank, + userString, + userEmail + )?.toModel() assertThat(apiUpdated).isNotNull assertThat(apiUpdated?.category).isNull() } @@ -287,8 +320,8 @@ class RichSkillRepositoryTest @Autowired constructor( name="${searchQuery} ${UUID.randomUUID()}" )} - val skillDaos = richSkillRepository.createFromApi(skillUpdates, userString) - val knownDaos = richSkillRepository.createFromApi(knownUpdates, userString) + val skillDaos = richSkillRepository.createFromApi(skillUpdates, userString, userEmail) + val knownDaos = richSkillRepository.createFromApi(knownUpdates, userString, userEmail) assertThat(skillDaos.size + knownDaos.size).isEqualTo(totalSkillCount) val batchResult = richSkillRepository.changeStatusesForTask(PublishTask( @@ -323,26 +356,46 @@ class RichSkillRepositoryTest @Autowired constructor( val notIncludedCount = 10 val archivedSkills = (1..archivedCount).toList().flatMap { - richSkillRepository.createFromApi(listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Published)), userString).map { skillDao -> + richSkillRepository.createFromApi( + listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Published)), + userString, + userEmail + ).map { skillDao -> richSkillRepository.update(RsdUpdateObject(id=skillDao.id.value, publishStatus=PublishStatus.Archived), userString) }.filterNotNull() } val publishedSkills = (1..publishedCount).toList().flatMap { - richSkillRepository.createFromApi(listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Published)), userString) + richSkillRepository.createFromApi( + listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Published)), + userString, + userEmail + ) } val draftSkills = (1..draftCount).toList().flatMap { - richSkillRepository.createFromApi(listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Draft)), userString) + richSkillRepository.createFromApi( + listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Draft)), + userString, + userEmail + ) } // make skills that are draft+archived val deletedSkills = (1..deletedCount).toList().flatMap { - richSkillRepository.createFromApi(listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Draft)), userString).map { skillDao -> + richSkillRepository.createFromApi( + listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Draft)), + userString, + userEmail + ).map { skillDao -> richSkillRepository.update(RsdUpdateObject(id=skillDao.id.value, publishStatus=PublishStatus.Archived), userString) }.filterNotNull() } val extraSkills = (1..notIncludedCount).toList().flatMap { - richSkillRepository.createFromApi(listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Draft)), userString) + richSkillRepository.createFromApi( + listOf(apiSkillUpdateGenerator(publishStatus=PublishStatus.Draft)), + userString, + userEmail + ) } publishedSkills.forEach { assertThat(it.publishStatus()).isEqualTo(PublishStatus.Published) } @@ -351,7 +404,7 @@ class RichSkillRepositoryTest @Autowired constructor( deletedSkills.forEach { assertThat(it.publishStatus()).isEqualTo(PublishStatus.Deleted) } // create collection - val collectionDao = collectionRepository.create(UUID.randomUUID().toString(), userString) + val collectionDao = collectionRepository.create(UUID.randomUUID().toString(), userString, userEmail) val collection = collectionDao?.toModel() assertThat(collection).isNotNull @@ -379,7 +432,7 @@ class RichSkillRepositoryTest @Autowired constructor( fun `should be able to bulk publish or archive skills based on uuids`() { val totalSkillCount = 10 val skillUpdates = (1..totalSkillCount).toList().map { apiSkillUpdateGenerator() } - val skillDaos = richSkillRepository.createFromApi(skillUpdates, userString) + val skillDaos = richSkillRepository.createFromApi(skillUpdates, userString, userEmail) assertThat(skillDaos.size).isEqualTo(totalSkillCount) val toPublishCount = 3 @@ -425,8 +478,8 @@ class RichSkillRepositoryTest @Autowired constructor( val skillUpdate = apiSkillUpdateGenerator().copy(occupations = ApiStringListUpdate(add = listOf(jobCode.code))) val noiseSkillCount = 10 val noiseSkillUpdates = (1..noiseSkillCount).toList().map { apiSkillUpdateGenerator() } - richSkillRepository.createFromApi(noiseSkillUpdates, userString) - val skillDao = richSkillRepository.createFromApi(listOf(skillUpdate), userString).first() + richSkillRepository.createFromApi(noiseSkillUpdates, userString, userEmail) + val skillDao = richSkillRepository.createFromApi(listOf(skillUpdate), userString, userEmail).first() val result = richSkillRepository.containingJobCode(jobCode.code) assertThat(result.first().uuid).isEqualTo(skillDao.uuid) diff --git a/api/src/test/resources/mock-data.xml b/api/src/test/resources/mock-data.xml index 76fc4221a..f1054d0b8 100644 --- a/api/src/test/resources/mock-data.xml +++ b/api/src/test/resources/mock-data.xml @@ -632,13 +632,14 @@ 1962021-06-14 14:44:20.6025612021-06-14 14:44:20.602561AlignmentAccounting Systems2021-06-14 14:44:20.602561 - 1975b5c26-9506-4e73-b4b2-83f4a838529dPower_Skills_FrameworkPublished2021-10-01 09:00:00.00000050,64,53,63,55,61,5778 - 29124e022-0723-4bf3-8bec-0a271616b5eaSELPublished2021-10-01 09:00:00.00000050,64,63,61,57,55,5378 - 3afaa6b8a-680a-4642-add1-014f977c9ca721st_Century_SkillsPublished2021-10-01 09:00:00.00000055,57,61,53,63,64,5078 - 49903442f-ed1e-42d4-8d95-858a26204d500Published2021-10-01 09:00:00.00000050,167,168,55,61,53,5778 - 5f07b2276-4d1c-46cf-a971-746c214bc3bcHealth_Open_SkillsPublished2021-10-01 09:00:00.000000168,167,166,142,141,140,139,6688 - 63dc22c04-1aa2-4dea-9dfc-0f016573428aDEI_CollectionPublished2021-10-01 09:00:00.00000078,77,7538 - 72938a728-f6e2-41a1-8a3b-9a838a56d212CharacterPublished2021-10-01 09:00:00.00000078,77,76,7548 + 1975b5c26-9506-4e73-b4b2-83f4a838529dPower_Skills_FrameworkPublished2021-10-01 09:00:00.00000050,64,53,63,55,61,5778 + 29124e022-0723-4bf3-8bec-0a271616b5eaSELPublished2021-10-01 09:00:00.00000050,64,63,61,57,55,5378 + 3afaa6b8a-680a-4642-add1-014f977c9ca721st_Century_SkillsPublished2021-10-01 09:00:00.00000055,57,61,53,63,64,5078 + 49903442f-ed1e-42d4-8d95-858a26204d500Published2021-10-01 09:00:00.00000050,167,168,55,61,53,5778 + 5f07b2276-4d1c-46cf-a971-746c214bc3bcHealth_Open_SkillsPublished2021-10-01 09:00:00.000000168,167,166,142,141,140,139,6688 + 63dc22c04-1aa2-4dea-9dfc-0f016573428aDEI_CollectionPublished2021-10-01 09:00:00.00000078,77,7538 + 72938a728-f6e2-41a1-8a3b-9a838a56d212CharacterPublished2021-10-01 09:00:00.00000078,77,76,7548 + 8e940e731-4a74-4c48-9264-a69ad978e609My WorkspaceWorkspace50,64,53,63,55,61,5778user@email.com 340b3546e-ca1f-4129-a07d-b0ab03dec1a2Data and Data Store AccessAccess data and data stores using the .NET Framework.18Published2021-10-01 09:00:00.0000002,2,3,3,4,4,5,5,6,6,7,755,58,59,466,560,656,663,664,665,666,667,668,55,58,59,466,560,656,663,664,665,666,667,668 diff --git a/ui/src/app/PublishStatus.ts b/ui/src/app/PublishStatus.ts index f1602c0cd..de930f620 100644 --- a/ui/src/app/PublishStatus.ts +++ b/ui/src/app/PublishStatus.ts @@ -1,11 +1,12 @@ import {ApiCollectionSummary, ApiSkillSummary} from "./richskill/ApiSkillSummary"; export enum PublishStatus { - Unarchived = "Unarchived", - Published = "Published", - Archived = "Archived", - Deleted = "Deleted", - Draft = "Draft" + Unarchived = "unarchived", + Published = "published", + Archived = "archived", + Deleted = "deleted", + Draft = "draft", + Workspace = "workspace" } diff --git a/ui/src/app/app-routing.module.ts b/ui/src/app/app-routing.module.ts index 702db5057..ca963e124 100644 --- a/ui/src/app/app-routing.module.ts +++ b/ui/src/app/app-routing.module.ts @@ -21,6 +21,8 @@ import {PublishCollectionComponent} from "./collection/detail/publish-collection import {CollectionSkillSearchComponent} from "./collection/collection-skill-search.component" import {BatchImportComponent} from "./richskill/import/batch-import.component" import { ActionByRoles, ButtonAction } from "./auth/auth-roles" +import {MyWorkspaceComponent} from "./my-workspace/my-workspace.component" +import {ConvertToCollectionComponent} from "./my-workspace/convert-to-collection/convert-to-collection.component" const routes: Routes = [ @@ -144,7 +146,27 @@ const routes: Routes = [ component: AdvancedSearchComponent, canActivate: [AuthGuard], }, - + { + path: "my-workspace", + component: MyWorkspaceComponent, + canActivate: [AuthGuard] + }, + { + path: "my-workspace/:uuid/add-skills", + component: CollectionSkillSearchComponent, + canActivate: [AuthGuard], + data: { + roles: ActionByRoles.get(ButtonAction.MyWorkspace) + } + }, + { + path: "my-workspace/convert-to-collection", + component: ConvertToCollectionComponent, + canActivate: [AuthGuard], + data: { + roles: ActionByRoles.get(ButtonAction.MyWorkspace) + } + }, /* PUBLIC VIEWS */ {path: "skills/:uuid", component: RichSkillPublicComponent}, {path: "collections/:uuid", component: CollectionPublicComponent}, diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index f81052186..d02fc5c15 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -97,6 +97,9 @@ import {LogoutComponent} from "./auth/logout.component" import {NgIdleKeepaliveModule} from "@ng-idle/keepalive" import {LabelWithSelectComponent} from "./table/skills-library-table/label-with-select.component" import {LibraryExportComponent} from "./navigation/libraryexport.component" +import {MyWorkspaceComponent} from "./my-workspace/my-workspace.component" +import {CollectionPipe} from "./pipes" +import { ConvertToCollectionComponent } from "./my-workspace/convert-to-collection/convert-to-collection.component" export function initializeApp( appConfig: AppConfig, @@ -206,7 +209,10 @@ export function initializeApp( AuditLogComponent, OccupationsCardSectionComponent, CheckerComponent, - LabelWithSelectComponent + LabelWithSelectComponent, + MyWorkspaceComponent, + CollectionPipe, + ConvertToCollectionComponent, ], imports: [ NgIdleKeepaliveModule.forRoot(), diff --git a/ui/src/app/auth/auth-roles.ts b/ui/src/app/auth/auth-roles.ts index cca69e37d..80916fa18 100644 --- a/ui/src/app/auth/auth-roles.ts +++ b/ui/src/app/auth/auth-roles.ts @@ -14,6 +14,7 @@ export enum ButtonAction { LibraryExport, ExportDraftCollection, DeleteCollection, + MyWorkspace } export const ActionByRoles = new Map([ @@ -23,10 +24,11 @@ export const ActionByRoles = new Map([ [ButtonAction.CollectionUpdate, [OSMT_ADMIN, OSMT_CURATOR]], [ButtonAction.CollectionCreate, [OSMT_ADMIN, OSMT_CURATOR]], [ButtonAction.CollectionPublish, [OSMT_ADMIN]], - [ButtonAction.CollectionSkillsUpdate, [OSMT_ADMIN]], + [ButtonAction.CollectionSkillsUpdate, [OSMT_ADMIN, OSMT_CURATOR]], [ButtonAction.LibraryExport, [OSMT_ADMIN]], [ButtonAction.ExportDraftCollection, [OSMT_ADMIN]], - [ButtonAction.DeleteCollection, [OSMT_ADMIN]] + [ButtonAction.DeleteCollection, [OSMT_ADMIN]], + [ButtonAction.MyWorkspace, [OSMT_ADMIN, OSMT_CURATOR]] ]) //TODO migrate AuthServiceWgu & AuthService.hasRole & isEnabledByRoles into a singleton here. HDN Sept 15, 2022 diff --git a/ui/src/app/collection/ApiCollection.ts b/ui/src/app/collection/ApiCollection.ts index 930aa4003..270213e1e 100644 --- a/ui/src/app/collection/ApiCollection.ts +++ b/ui/src/app/collection/ApiCollection.ts @@ -8,6 +8,7 @@ export interface ICollection { creator: string id: string name: string + workspaceOwner?: string publishDate?: Date skills: string[] status: PublishStatus @@ -23,6 +24,7 @@ export class ApiCollection { id: string name: string publishDate?: Date + workspaceOwner?: string skills: string[] status: PublishStatus updateDate?: Date @@ -36,6 +38,7 @@ export class ApiCollection { creator, id, name, + workspaceOwner, publishDate, skills, status, @@ -49,6 +52,7 @@ export class ApiCollection { this.creator = creator this.id = id this.name = name + this.workspaceOwner = workspaceOwner this.publishDate = publishDate this.skills = skills this.status = status @@ -69,6 +73,7 @@ export class ApiCollectionUpdate { status?: PublishStatus author?: string skills?: IStringListUpdate + workSpaceOwner?: string constructor({name, status, author, skills}: ICollectionUpdate) { this.name = name diff --git a/ui/src/app/collection/collection-skill-search.component.html b/ui/src/app/collection/collection-skill-search.component.html index ae7671163..3da166d55 100644 --- a/ui/src/app/collection/collection-skill-search.component.html +++ b/ui/src/app/collection/collection-skill-search.component.html @@ -7,9 +7,9 @@
-

Add RSDs to Collection

+

Add RSDs to {{collectionOrWorkspace(true)}}

-

Collection: {{collection.name}}

+

Collection: {{collection.name}}

@@ -18,7 +18,7 @@

Add RSDs to Collectio - +

diff --git a/ui/src/app/collection/collection-skill-search.component.ts b/ui/src/app/collection/collection-skill-search.component.ts index b990110ee..d35605da3 100644 --- a/ui/src/app/collection/collection-skill-search.component.ts +++ b/ui/src/app/collection/collection-skill-search.component.ts @@ -14,6 +14,7 @@ import {ApiSkillSummary} from "../richskill/ApiSkillSummary"; import {SkillsListComponent} from "../richskill/list/skills-list.component"; import {ApiTaskResult} from "../task/ApiTaskResult"; import {AuthService} from "../auth/auth-service"; +import {PublishStatus} from "../PublishStatus" @Component({ selector: "app-collection-skill-search", @@ -44,7 +45,7 @@ export class CollectionSkillSearchComponent extends SkillsListComponent implemen protected toastService: ToastService, protected authService: AuthService, ) { - super(router, richSkillService, toastService, authService) + super(router, richSkillService, collectionService, toastService, authService) this.titleService.setTitle(`Add RSDs to Collection | ${this.whitelabel.toolName}`) this.uuidParam = this.route.snapshot.paramMap.get("uuid") || undefined @@ -86,7 +87,7 @@ export class CollectionSkillSearchComponent extends SkillsListComponent implemen rowActions(): TableActionDefinition[] { return [ new TableActionDefinition({ - label: "Add to Collection", + label: `Add to ${this.collectionOrWorkspace(true)}`, callback: (action: TableActionDefinition, skill?: ApiSkillSummary) => this.handleClickAddCollection(action, skill), }) ] @@ -105,7 +106,7 @@ export class CollectionSkillSearchComponent extends SkillsListComponent implemen callback: (action: TableActionDefinition, skill?: ApiSkillSummary) => this.handleClickBackToTop(action, skill), }), new TableActionDefinition({ - label: "Add to Collection", + label: `Add to ${this.collectionOrWorkspace(true)}`, icon: "collection", primary: true, callback: (action: TableActionDefinition, skill?: ApiSkillSummary) => this.handleClickAddCollection(action, skill), @@ -123,7 +124,9 @@ export class CollectionSkillSearchComponent extends SkillsListComponent implemen this.collectionUpdated.subscribe(result => { if (result) { this.toastService.hideBlockingLoader() - const message = `You added ${selectedCount} RSD${selectedCount ? "s" : ""} to the collection ${this.collection?.name}.` + const isWorkspace = this.collection?.status === PublishStatus.Workspace + const baseMessage = `You added ${selectedCount} RSD${selectedCount ? "s" : ""} to the` + const message = ` ${baseMessage} ${this.collectionOrWorkspace(false)} ${ isWorkspace ? "" : this.collection?.name}.` this.toastService.showToast("Success!", message) } }) diff --git a/ui/src/app/collection/create-collection/collection-form.component.ts b/ui/src/app/collection/create-collection/collection-form.component.ts index 43c1ddf3a..25d3c802e 100644 --- a/ui/src/app/collection/create-collection/collection-form.component.ts +++ b/ui/src/app/collection/create-collection/collection-form.component.ts @@ -33,12 +33,12 @@ export class CollectionFormComponent extends Whitelabelled implements OnInit, Ha } constructor( - private collectionService: CollectionService, - private loc: Location, - private router: Router, - private route: ActivatedRoute, - private toastService: ToastService, - private titleService: Title + protected collectionService: CollectionService, + protected loc: Location, + protected router: Router, + protected route: ActivatedRoute, + protected toastService: ToastService, + protected titleService: Title ) { super() } diff --git a/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.html b/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.html index a8c422d46..f5951c12a 100644 --- a/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.html +++ b/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.html @@ -1,6 +1,6 @@

- +

{{collectionName}}

@@ -9,7 +9,7 @@

{{c

Author: {{collectionAuthor}}

-
+
): Promise { @@ -37,7 +40,8 @@ describe("PublicCollectionDetailCardComponent", () => { TestBed.configureTestingModule({ declarations: [ - PublicCollectionDetailCardComponent + PublicCollectionDetailCardComponent, + CollectionPipe ], imports: [ ], @@ -56,4 +60,18 @@ describe("PublicCollectionDetailCardComponent", () => { it("should be created", () => { expect(component).toBeTruthy() }) + + it("display status and display label should be false", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Workspace) + expect(component.displayStatus).toBeFalse() + expect(component.displayLabel).toBeFalse() + }) + + it("display status and label should be true", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Draft) + expect(component.displayStatus).toBeTrue() + expect(component.displayLabel).toBeTrue() + }) }) diff --git a/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.ts b/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.ts index b08f98c39..d146eae20 100644 --- a/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.ts +++ b/ui/src/app/collection/detail/collection-public/public-collection-detail-card.component.ts @@ -3,6 +3,7 @@ import {ActivatedRoute, Router} from "@angular/router" import {RichSkillService} from "../../../richskill/service/rich-skill.service" import {ToastService} from "../../../toast/toast.service" import {ApiCollection} from "../../ApiCollection" +import {PublishStatus} from "../../../PublishStatus" @Component({ selector: "app-collection-public-detail-card", @@ -49,4 +50,12 @@ export class PublicCollectionDetailCardComponent implements OnInit { get collectionAuthor(): string { return this.collection?.author ?? "" } + + get displayStatus(): boolean { + return this.collection?.status !== PublishStatus.Workspace + } + + get displayLabel(): boolean { + return this.collection?.status !== PublishStatus.Workspace + } } diff --git a/ui/src/app/collection/detail/manage-collection.component.html b/ui/src/app/collection/detail/manage-collection.component.html index ec40bb504..25d8ce053 100644 --- a/ui/src/app/collection/detail/manage-collection.component.html +++ b/ui/src/app/collection/detail/manage-collection.component.html @@ -30,11 +30,11 @@
- +

- {{totalCount}} RSDsRSD found in this collection based on + {{totalCount}} RSDsRSD found in this {{collectionOrWorkspace(false)}} based on {{matchingQuery[0]}} and @@ -81,7 +81,7 @@ > -

+
-

Confirm that you want to remove {{selectedCount}} RSDs from this collection.

+

Confirm that you want to remove {{selectedCount}} RSDs from this {{this.collection.status | collection}}.

@@ -128,7 +128,7 @@

Confirm that you want Cancel

@@ -139,7 +139,7 @@

Confirm that you want

- Confirm that you want to delete {{this.collection?.name}} + Confirm that you want to {{confirmMessageText}}

@@ -150,7 +150,7 @@

Cancel

diff --git a/ui/src/app/collection/detail/manage-collection.component.spec.ts b/ui/src/app/collection/detail/manage-collection.component.spec.ts index e46d1cc7c..aa00c9b3e 100644 --- a/ui/src/app/collection/detail/manage-collection.component.spec.ts +++ b/ui/src/app/collection/detail/manage-collection.component.spec.ts @@ -552,4 +552,23 @@ describe("ManageCollectionComponent", () => { component.saveCsv(csvContent.body, "My Collection") expect(spySaveAS).toHaveBeenCalled() }) + + it("confirm message text", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Workspace) + component.collection.name = "Test Collection" + expect(component.confirmMessageText).toBe("delete Test Collection") + }) + + it("confirm button text", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Workspace) + expect(component.confirmButtonText).toBe("delete collection") + }) + + it("show log should be true", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Draft) + expect(component.showLog).toBeTrue() + }) }) diff --git a/ui/src/app/collection/detail/manage-collection.component.ts b/ui/src/app/collection/detail/manage-collection.component.ts index 6be718e07..c975f8196 100644 --- a/ui/src/app/collection/detail/manage-collection.component.ts +++ b/ui/src/app/collection/detail/manage-collection.component.ts @@ -20,6 +20,7 @@ import {formatDate} from "@angular/common" import * as FileSaver from "file-saver" import {ITaskResult} from "../../task/ApiTaskResult" import {delay, retryWhen, switchMap} from "rxjs/operators" +import {CollectionPipe} from "../../pipes" @Component({ selector: "app-manage-collection", @@ -52,6 +53,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On template: "default" | "confirm-multiple" | "confirm-delete-collection" = "default" collectionSaved?: Observable selectAllChecked = false + showLog = true collapseAuditLog = new Subject() @@ -68,7 +70,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On protected authService: AuthService, @Inject(LOCALE_ID) protected locale: string ) { - super(router, richSkillService, toastService, authService) + super(router, richSkillService, collectionService, toastService, authService) } ngOnInit(): void { @@ -275,7 +277,6 @@ export class ManageCollectionComponent extends SkillsListComponent implements On window.open(url, "_blank") } - publishAction(): void { if (this.uuidParam === undefined) { return } @@ -343,7 +344,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On if (count > 1 || this.selectAllChecked) { this.template = "confirm-multiple" } else { - if (confirm(`Confirm that you want to remove the following RSD from this collection.\n${first?.skillName}`)) { + if (confirm(`Confirm that you want to remove the following RSD from this ${this.collectionOrWorkspace(false)}.\n${first?.skillName}`)) { this.submitSkillRemoval(this.apiSearch) } } @@ -355,7 +356,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On this.skillsSaved = this.collectionService.updateSkillsWithResult(this.uuidParam ?? "", update) this.skillsSaved.subscribe(result => { if (result) { - this.toastService.showToast("Success!", `You removed ${result.modifiedCount} RSD${(result.modifiedCount ?? 0) > 1 ? "s" : ""} from this collection.`) + this.toastService.showToast("Success!", `You removed ${result.modifiedCount} RSD${(result.modifiedCount ?? 0) > 1 ? "s" : ""} from this ${this.collectionOrWorkspace(false).toLowerCase()}`) this.toastService.hideBlockingLoader() this.reloadCollection() this.loadNextPage() @@ -377,6 +378,18 @@ export class ManageCollectionComponent extends SkillsListComponent implements On return false } + get confirmMessageText(): string { + return "delete " + (this.collection?.name ?? "") + } + + get confirmButtonText(): string { + return "delete collection" + } + + collectionOrWorkspace(includesMy: boolean): string { + return new CollectionPipe().transform(this.collection?.status, includesMy) + } + protected handleClickBackToTop(action: TableActionDefinition, skill?: ApiSkillSummary): boolean { this.focusAndScrollIntoView(this.titleElement.nativeElement, "h2") return false diff --git a/ui/src/app/collection/service/collection.service.spec.ts b/ui/src/app/collection/service/collection.service.spec.ts index 8185f552f..47cd2ce93 100644 --- a/ui/src/app/collection/service/collection.service.spec.ts +++ b/ui/src/app/collection/service/collection.service.spec.ts @@ -81,7 +81,7 @@ describe("CollectionService", () => { // Arrange RouterData.commands = [] AuthServiceData.isDown = false - const path = "api/collections?sort=name.asc&status=Draft&size=3&from=0" + const path = "api/collections?sort=name.asc&status=draft&size=3&from=0" const testData: PaginatedCollections = createMockPaginatedCollections(3, 10) const statuses = new Set([ PublishStatus.Draft ]) diff --git a/ui/src/app/collection/service/collection.service.ts b/ui/src/app/collection/service/collection.service.ts index 677b413b5..b7a2de5e0 100644 --- a/ui/src/app/collection/service/collection.service.ts +++ b/ui/src/app/collection/service/collection.service.ts @@ -123,6 +123,15 @@ export class CollectionService extends AbstractService { )) } + getWorkspace(): Observable { + const errorMsg = `Could not find workspace` + return this.get({ + path: "api/workspace" + }) + .pipe(share()) + .pipe(map(({body}) => new ApiCollection(this.safeUnwrapBody(body, errorMsg)))) + } + createCollection(updateObject: ICollectionUpdate): Observable { const errorMsg = `Error creating collection` return this.post({ diff --git a/ui/src/app/my-workspace/convert-to-collection/convert-to-collection.component.spec.ts b/ui/src/app/my-workspace/convert-to-collection/convert-to-collection.component.spec.ts new file mode 100644 index 000000000..a9a4aaddd --- /dev/null +++ b/ui/src/app/my-workspace/convert-to-collection/convert-to-collection.component.spec.ts @@ -0,0 +1,78 @@ +import {ComponentFixture, TestBed} from "@angular/core/testing" + +import {ConvertToCollectionComponent} from "./convert-to-collection.component" +import {RouterTestingModule} from "@angular/router/testing" +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {AppConfig} from "../../app.config" +import {Location} from "@angular/common" +import {Title} from "@angular/platform-browser" +import {ToastService} from "../../toast/toast.service" +import {EnvironmentService} from "../../core/environment.service" +import {CollectionServiceStub, EnvironmentServiceStub} from "../../../../test/resource/mock-stubs" +import {CollectionService} from "../../collection/service/collection.service" +import {ActivatedRoute, Router} from "@angular/router" +import {ActivatedRouteStubSpec} from "../../../../test/util/activated-route-stub.spec" + +describe("ConvertToCollectionComponent", () => { + let component: ConvertToCollectionComponent + let fixture: ComponentFixture + let activatedRoute: ActivatedRouteStubSpec + + activatedRoute = new ActivatedRouteStubSpec() + activatedRoute.setParams({ uuid: "uuid1" }) + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ConvertToCollectionComponent], + imports: [ + RouterTestingModule, + HttpClientTestingModule + ], + providers: [ + AppConfig, + Location, + Title, + ToastService, + {provide: EnvironmentService, useClass: EnvironmentServiceStub}, + {provide: CollectionService, useClass: CollectionServiceStub}, + { provide: ActivatedRoute, useValue: activatedRoute }, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() + + const environmentService = TestBed.inject(EnvironmentService) + environmentService.environment.editableAuthor = true + AppConfig.settings.editableAuthor = true // Doubly sure + }) + + beforeEach(() => { + fixture = TestBed.createComponent(ConvertToCollectionComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it("should create", () => { + expect(component).toBeTruthy() + }) + + it("on submit should call create", () => { + const spyUpdateObject = spyOn(component, "updateObject") + const router = TestBed.inject(Router) + const spyRouter = spyOn(router, "navigate").and.resolveTo(true) + const service = TestBed.inject(CollectionService) + const spyService = spyOn(service, "createCollection").and.callThrough() + component.onSubmit() + expect(spyUpdateObject).toHaveBeenCalled() + expect(spyService).toHaveBeenCalled() + expect(spyRouter).toHaveBeenCalled() + }) + + it("update object should has skills", () => { + const spyLocalStorage = spyOn(localStorage, "getItem").and.returnValue("[423423, 234234]") + const updateObject = component.updateObject() + expect(spyLocalStorage).toHaveBeenCalled() + expect(updateObject.skills).toBeTruthy() + }) +}) diff --git a/ui/src/app/my-workspace/convert-to-collection/convert-to-collection.component.ts b/ui/src/app/my-workspace/convert-to-collection/convert-to-collection.component.ts new file mode 100644 index 000000000..e254cf8af --- /dev/null +++ b/ui/src/app/my-workspace/convert-to-collection/convert-to-collection.component.ts @@ -0,0 +1,48 @@ +import {Component, OnInit} from "@angular/core" +import {CollectionFormComponent} from "../../collection/create-collection/collection-form.component" +import {CollectionService} from "../../collection/service/collection.service" +import {Location} from "@angular/common" +import {ActivatedRoute, Router} from "@angular/router" +import {ToastService} from "../../toast/toast.service" +import {Title} from "@angular/platform-browser" +import {ICollectionUpdate} from "../../collection/ApiCollection" +import {WORKSPACE_COLLECTIONS_UUIDS} from "../my-workspace.component" + +@Component({ + selector: "app-convert-to-collection", + templateUrl: "../../collection/create-collection/collection-form.component.html", +}) +export class ConvertToCollectionComponent extends CollectionFormComponent implements OnInit { + + constructor( + protected collectionService: CollectionService, + protected loc: Location, + protected router: Router, + protected route: ActivatedRoute, + protected toastService: ToastService, + protected titleService: Title + ) { + super(collectionService, loc, router, route, toastService, titleService) + } + + updateObject(): ICollectionUpdate { + const formValues = this.collectionForm.value + const collectionsUuids = localStorage.getItem(WORKSPACE_COLLECTIONS_UUIDS) + return { + name: formValues.collectionName, + author: formValues.author, + skills: {add: JSON.parse(collectionsUuids ?? "")} + } + } + + + onSubmit(): void { + const updateObject = this.updateObject() + this.collectionService.createCollection(updateObject).subscribe(collection => { + this.router.navigate([`/collections/${collection.uuid}/manage`]).then(() => { + localStorage.removeItem(WORKSPACE_COLLECTIONS_UUIDS) + }) + }) + } + +} diff --git a/ui/src/app/my-workspace/my-workspace.component.spec.ts b/ui/src/app/my-workspace/my-workspace.component.spec.ts new file mode 100644 index 000000000..083737a42 --- /dev/null +++ b/ui/src/app/my-workspace/my-workspace.component.spec.ts @@ -0,0 +1,166 @@ +import {ComponentFixture, TestBed} from "@angular/core/testing" + +import {MyWorkspaceComponent} from "./my-workspace.component" +import {RouterTestingModule} from "@angular/router/testing" +import {AuthService} from "../auth/auth-service" +import {RichSkillService} from "../richskill/service/rich-skill.service" +import {AuthServiceStub, CollectionServiceStub, EnvironmentServiceStub, RichSkillServiceStub} from "../../../test/resource/mock-stubs" +import {HttpClientTestingModule} from "@angular/common/http/testing" +import {AppConfig} from "../app.config" +import {By, Title} from "@angular/platform-browser" +import {ToastService} from "../toast/toast.service" +import {EnvironmentService} from "../core/environment.service" +import {CollectionService} from "../collection/service/collection.service" +import {Router} from "@angular/router" +import {ManageCollectionComponent} from "../collection/detail/manage-collection.component" +import {createMockCollection, createMockSkillSummary} from "../../../test/resource/mock-data" +import {PublishStatus} from "../PublishStatus" +import {PublicCollectionDetailCardComponent} from "../collection/detail/collection-public/public-collection-detail-card.component" +import {VerticalActionBarComponent} from "../core/vertical-action-bar.component" +import {FilterControlsComponent} from "../table/filter-controls/filter-controls.component" +import {FilterChoiceComponent} from "../table/filter-controls/filter-choice.component" +import {TableActionBarComponent} from "../table/skills-library-table/table-action-bar.component" +import {ActionBarItemComponent} from "../table/skills-library-table/action-bar-item.component" +import {CommonModule} from "@angular/common" +import {ReactiveFormsModule} from "@angular/forms" +import {LoadingObservablesDirective} from "../loading/loading-observables.directive" +import {SkillTableComponent} from "../table/skills-library-table/skill-table.component" +import {PaginationComponent} from "../table/skills-library-table/pagination.component" +import {LabelWithSelectComponent} from "../table/skills-library-table/label-with-select.component" +import {AuditLogComponent} from "../richskill/detail/audit-log.component" +import {LabelWithFilterComponent} from "../table/skills-library-table/label-with-filter.component" +import {SkillListRowComponent} from "../richskill/list/skill-list-row.component" +import {StatusBarComponent} from "../core/status-bar.component" +import {DotsMenuComponent} from "../table/skills-library-table/dots-menu.component" + +describe("MyWorkspaceComponent", () => { + let component: MyWorkspaceComponent + let fixture: ComponentFixture + let collectionService: CollectionService + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { + path: "my-workspace/uuid1/add-skills", + component: ManageCollectionComponent + } + ]), + HttpClientTestingModule, + CommonModule, + ReactiveFormsModule + ], + declarations: [ + MyWorkspaceComponent, + PublicCollectionDetailCardComponent, + VerticalActionBarComponent, + FilterControlsComponent, + FilterChoiceComponent, + TableActionBarComponent, + ActionBarItemComponent, + LoadingObservablesDirective, + SkillTableComponent, + PaginationComponent, + LabelWithSelectComponent, + LabelWithFilterComponent, + AuditLogComponent, + SkillListRowComponent, + StatusBarComponent, + DotsMenuComponent, + ], + providers: [ + AppConfig, + Title, + ToastService, + {provide: EnvironmentService, useClass: EnvironmentServiceStub}, // Example of using a service stub + {provide: RichSkillService, useClass: RichSkillServiceStub}, + {provide: CollectionService, useClass: CollectionServiceStub}, + {provide: AuthService, useClass: AuthServiceStub}, + ] + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(MyWorkspaceComponent) + component = fixture.componentInstance + collectionService = TestBed.inject(CollectionService) + fixture.detectChanges() + }) + + it("reload collection should call get workspace", () => { + const spy = spyOn(collectionService, "getWorkspace").and.callThrough() + component.reloadCollection() + expect(spy).toHaveBeenCalled() + }) + + it("should create", () => { + expect(component).toBeTruthy() + }) + + it("actions definitions should be correct", () => { + expect(component.actionDefinitions().length).toEqual(4) + }) + + it("handle confirm delete collection", () => { + const spy = spyOn(component, "submitSkillRemoval").and.callThrough() + component.handleConfirmDeleteCollection() + expect(spy).toHaveBeenCalled() + expect(component.template).toEqual("default") + }) + + it("convert to collection action", () => { + const router = TestBed.inject(Router) + const spyNavigate = spyOn(router, "navigate").and.callThrough() + const spyLocalStorage = spyOn(localStorage, "setItem").and.callThrough() + component["convertToCollectionAction"]() + expect(spyLocalStorage).toHaveBeenCalled() + expect(spyNavigate).toHaveBeenCalled() + }) + + it("confirm message text", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Workspace) + expect(component.confirmMessageText).toBe("reset My Workspace") + }) + + it("confirm button text", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Workspace) + expect(component.confirmButtonText).toBe("reset My Workspace") + }) + + it("workspace is not empty", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Workspace) + expect(component["workspaceEmpty"]()).toBeFalse() + }) + + it("router should navigate correctly", () => { + const router = TestBed.inject(Router) + const spy = spyOn(router, "navigate").and.resolveTo(true) + component.addSkillsAction() + expect(spy).toHaveBeenCalledWith(["/my-workspace/uuid1/add-skills"]) + }) + + it("add to collection should not be visible", () => { + expect(component.addToCollectionVisible()).toBeFalse() + }) + + it("add to collection should be visible", () => { + component.selectedSkills = [createMockSkillSummary()] + expect(component.addToCollectionVisible()).toBeTrue() + }) + + it("show log should be false", () => { + const date = new Date() + component.collection = createMockCollection(date, date, date, date, PublishStatus.Workspace) + const auditLog = fixture.debugElement.query(By.css("app-audit-log")) + expect(component.showLog).toBeFalse() + expect(auditLog).toBeFalsy() + }) + +}) diff --git a/ui/src/app/my-workspace/my-workspace.component.ts b/ui/src/app/my-workspace/my-workspace.component.ts new file mode 100644 index 000000000..45196cd2e --- /dev/null +++ b/ui/src/app/my-workspace/my-workspace.component.ts @@ -0,0 +1,104 @@ +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" + +export const WORKSPACE_COLLECTIONS_UUIDS = "workspace-collections-uuids" + +@Component({ + selector: "app-my-workspace", + templateUrl: "../collection/detail/manage-collection.component.html" +}) +export class MyWorkspaceComponent extends ManageCollectionComponent implements OnInit { + + showLog = false + + constructor( + protected router: Router, + protected richSkillService: RichSkillService, + protected toastService: ToastService, + protected collectionService: CollectionService, + protected route: ActivatedRoute, + protected titleService: Title, + protected authService: AuthService, + @Inject(LOCALE_ID) protected locale: string + ) { + super(router, richSkillService, toastService, collectionService, route, titleService, authService, locale) + } + + ngOnInit(): void { + this.reloadCollection() + } + + reloadCollection(): void { + this.collectionService.getWorkspace().subscribe(collection => { + this.titleService.setTitle(`${collection.name} | Collection | ${this.whitelabel.toolName}`) + this.collection = collection + this.uuidParam = this.collection.uuid + this.loadNextPage() + }) + } + + actionDefinitions(): TableActionDefinition[] { + return [ + new TableActionDefinition({ + label: "Add RSDs to My Workspace", + icon: this.addIcon, + callback: () => this.addSkillsAction(), + visible: () => true + }), + new TableActionDefinition({ + label: "Download as CSV", + icon: this.downloadIcon, + callback: () => this.generateCsv(this.collection?.name ?? ""), + visible: () => !this.workspaceEmpty() + }), + new TableActionDefinition({ + label: "Convert to Collection", + icon: this.publishIcon, + callback: () => this.convertToCollectionAction(), + visible: () => !this.workspaceEmpty() + }), + new TableActionDefinition({ + label: "Reset My Workspace", + icon: this.deleteIcon, + callback: () => this.deleteCollectionAction(), + visible: () => !this.workspaceEmpty() + }) + ] + } + + private workspaceEmpty(): boolean { + return (this.collection?.skills?.length ?? 0) === 0 + } + + get confirmMessageText(): string { + return "reset My Workspace" + } + + get confirmButtonText(): string { + return "reset My Workspace" + } + + handleConfirmDeleteCollection(): void { + this.submitSkillRemoval(new ApiSearch({uuids: this.collection?.skills.map(s => (s as any).uuid)})) + this.template = "default" + } + + private convertToCollectionAction(): void { + const uuids = this.collection?.skills.map(s => (s as any).uuid) + localStorage.setItem(WORKSPACE_COLLECTIONS_UUIDS, JSON.stringify(uuids)) + this.router.navigate(["/my-workspace/convert-to-collection"]) + } + + addSkillsAction(): void { + this.router.navigate([`/my-workspace/${this.collection?.uuid}/add-skills`]) + } + +} diff --git a/ui/src/app/navigation/header.component.html b/ui/src/app/navigation/header.component.html index 7628dabfb..523470248 100644 --- a/ui/src/app/navigation/header.component.html +++ b/ui/src/app/navigation/header.component.html @@ -26,6 +26,11 @@

Site Navigation

  • Collections
  • +
  • + + My Workspace + +
  • Icon Interactive diff --git a/ui/src/app/navigation/header.component.spec.ts b/ui/src/app/navigation/header.component.spec.ts new file mode 100644 index 000000000..56374bef5 --- /dev/null +++ b/ui/src/app/navigation/header.component.spec.ts @@ -0,0 +1,93 @@ +import {ComponentFixture, fakeAsync, TestBed, tick} from "@angular/core/testing" +import {HeaderComponent} from "./header.component" +import {AuthService} from "../auth/auth-service" +import {AuthServiceStub} from "../../../test/resource/mock-stubs" +import {RouterTestingModule} from "@angular/router/testing" +import {AppConfig} from "../app.config" +import {EnvironmentService} from "../core/environment.service" +import {Location} from "@angular/common" +import {ConcreteService} from "../abstract.service.spec" +import {HttpClientModule} from "@angular/common/http" +import {Router} from "@angular/router" +import {MyWorkspaceComponent} from "../my-workspace/my-workspace.component" +import {RichSkillsLibraryComponent} from "../richskill/library/rich-skills-library.component" +import {ButtonAction} from "../auth/auth-roles" +import {By} from "@angular/platform-browser" + +describe("HeaderComponent", () => { + + let component: HeaderComponent + let fixture: ComponentFixture + let router: Router + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + HeaderComponent + ], + providers: [ + EnvironmentService, + AppConfig, + ConcreteService, + Location, + {provide: AuthService, useClass: AuthServiceStub}, + ], + imports: [ + HttpClientModule, + RouterTestingModule.withRoutes([ + { + path: "my-workspace", + component: MyWorkspaceComponent + }, + { + path: "skills", + component: RichSkillsLibraryComponent + } + ]) + ] + }).compileComponents() + }) + + beforeEach(() => { + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() + fixture = TestBed.createComponent(HeaderComponent) + component = fixture.componentInstance + router = TestBed.inject(Router) + fixture.detectChanges() + }) + + it("should be created", () => { + expect(component).toBeTruthy() + }) + + it("my workspace is active", fakeAsync(() => { + router.navigate(["/my-workspace"]) + tick() + expect(component.myWorkspaceActive).toBeTruthy() + })) + + it("my workspace is not visible when user doesn't have role admin or curator", () => { + const authService = TestBed.inject(AuthService) + spyOn(authService, "isEnabledByRoles").and.returnValue( false) + component.canHaveWorkspace = component["authService"].isEnabledByRoles(ButtonAction.MyWorkspace) + fixture.detectChanges() + const myWorkspace = fixture.debugElement.query(By.css("#li-my-workspace")) + expect(myWorkspace).toBeFalsy() + expect(component.canHaveWorkspace).toBeFalse() + }) + + it("my workspace is visible when user has role admin or curator", () => { + const myWorkspace = fixture.debugElement.query(By.css("#li-my-workspace")) + expect(myWorkspace).toBeTruthy() + expect(component.canHaveWorkspace).toBeTrue() + }) + + it("skills is active", fakeAsync(() => { + router.navigate(["/skills"]) + tick() + expect(router).toBeTruthy() + })) + + +}) diff --git a/ui/src/app/navigation/header.component.ts b/ui/src/app/navigation/header.component.ts index 425b31206..c8ac6b033 100644 --- a/ui/src/app/navigation/header.component.ts +++ b/ui/src/app/navigation/header.component.ts @@ -4,6 +4,7 @@ import {Whitelabelled} from "../../whitelabel" import {AuthService} from "../auth/auth-service" import {ActivatedRoute, Router} from "@angular/router" import {AppConfig} from "../app.config" +import {ButtonAction} from "../auth/auth-roles" @Component({ selector: "app-header", @@ -11,6 +12,7 @@ import {AppConfig} from "../app.config" }) export class HeaderComponent extends Whitelabelled implements OnInit { menuExpanded: boolean = false + canHaveWorkspace = this.authService.isEnabledByRoles(ButtonAction.MyWorkspace) constructor(private authService: AuthService, private router: Router, private route: ActivatedRoute, private location: Location) { super() @@ -38,8 +40,12 @@ export class HeaderComponent extends Whitelabelled implements OnInit { return window.location.pathname.startsWith("/collections") } + get myWorkspaceActive(): boolean { + return this.router.url.startsWith("/my-workspace") + } + get skillsActive(): boolean { - return !this.collectionsActive + return this.router.url.startsWith("/skills") } handleQuicklink(elementId: string): boolean { diff --git a/ui/src/app/pipes/collection.pipe.spec.ts b/ui/src/app/pipes/collection.pipe.spec.ts new file mode 100644 index 000000000..4a9cae18e --- /dev/null +++ b/ui/src/app/pipes/collection.pipe.spec.ts @@ -0,0 +1,21 @@ +import {CollectionPipe} from "./collection.pipe" +import {PublishStatus} from "../PublishStatus" + +describe("CollectionPipe", () => { + const pipe: CollectionPipe = new CollectionPipe() + + it("should return collection", () => { + const transform = pipe.transform(PublishStatus.Published) + expect(transform).toBe("Collection") + }) + + it("should return workspace", () => { + const transform = pipe.transform(PublishStatus.Workspace) + expect(transform).toBe("Workspace") + }) + + it("should return my workspace", () => { + const transform = pipe.transform(PublishStatus.Workspace, true) + expect(transform).toBe("My Workspace") + }) +}) diff --git a/ui/src/app/pipes/collection.pipe.ts b/ui/src/app/pipes/collection.pipe.ts new file mode 100644 index 000000000..325f7a5e2 --- /dev/null +++ b/ui/src/app/pipes/collection.pipe.ts @@ -0,0 +1,16 @@ +import {Pipe, PipeTransform} from "@angular/core" +import {PublishStatus} from "../PublishStatus" + +@Pipe({ + name: "collection" +}) +export class CollectionPipe implements PipeTransform { + transform(value: PublishStatus | undefined, includesMy?: boolean): string { + const isWorkspace = value === PublishStatus.Workspace + if (isWorkspace) { + return includesMy ? "My Workspace" : "Workspace" + } + return "Collection" + } + +} diff --git a/ui/src/app/pipes/index.ts b/ui/src/app/pipes/index.ts new file mode 100644 index 000000000..795d12242 --- /dev/null +++ b/ui/src/app/pipes/index.ts @@ -0,0 +1 @@ +export * from "./collection.pipe" diff --git a/ui/src/app/richskill/library/rich-skills-library.component.ts b/ui/src/app/richskill/library/rich-skills-library.component.ts index 1e7be0b75..4bd92b184 100644 --- a/ui/src/app/richskill/library/rich-skills-library.component.ts +++ b/ui/src/app/richskill/library/rich-skills-library.component.ts @@ -7,6 +7,7 @@ import {Router} from "@angular/router" import {determineFilters} from "../../PublishStatus" import {Title} from "@angular/platform-browser" import {AuthService} from "../../auth/auth-service" +import {CollectionService} from "../../collection/service/collection.service" @Component({ selector: "app-rich-skills-library", @@ -19,11 +20,12 @@ export class RichSkillsLibraryComponent extends SkillsListComponent implements O constructor( protected router: Router, protected richSkillService: RichSkillService, + protected collectionService: CollectionService, protected toastService: ToastService, protected titleService: Title, protected authService: AuthService ) { - super(router, richSkillService, toastService, authService) + super(router, richSkillService, collectionService, toastService, authService) } ngOnInit(): void { diff --git a/ui/src/app/richskill/list/skills-list.component.spec.ts b/ui/src/app/richskill/list/skills-list.component.spec.ts index 8ba21c42e..622e33eea 100644 --- a/ui/src/app/richskill/list/skills-list.component.spec.ts +++ b/ui/src/app/richskill/list/skills-list.component.spec.ts @@ -4,7 +4,7 @@ import { Component, ElementRef, Type } from "@angular/core" import { async, ComponentFixture, TestBed } from "@angular/core/testing" import { RouterTestingModule } from "@angular/router/testing" import { createMockPaginatedSkills, createMockSkillSummary } from "../../../../test/resource/mock-data" -import {AuthServiceStub, RichSkillServiceStub} from "../../../../test/resource/mock-stubs" +import {AuthServiceStub, CollectionServiceStub, RichSkillServiceStub} from "../../../../test/resource/mock-stubs" import { PublishStatus } from "../../PublishStatus" import { ToastService } from "../../toast/toast.service" import { ApiSortOrder } from "../ApiSkill" @@ -12,6 +12,8 @@ import { ApiSearch, PaginatedSkills } from "../service/rich-skill-search.service import { RichSkillService } from "../service/rich-skill.service" import { SkillsListComponent } from "./skills-list.component" import {AuthService} from "../../auth/auth-service"; +import {HttpClientModule} from "@angular/common/http" +import {CollectionService} from "../../collection/service/collection.service" @Component({ @@ -57,6 +59,7 @@ describe("SkillsListComponent", () => { ConcreteComponent ], imports: [ + HttpClientModule, RouterTestingModule.withRoutes([ { path: "collections/add-skills", component: SkillsListComponent } ]) @@ -65,6 +68,7 @@ describe("SkillsListComponent", () => { ToastService, { provide: RichSkillService, useClass: RichSkillServiceStub }, { provide: AuthService, useClass: AuthServiceStub }, + { provide: CollectionService, useClass: CollectionServiceStub }, ] }) @@ -95,6 +99,13 @@ describe("SkillsListComponent", () => { expect(component.getSelectAllCount()).toEqual(component.curPageCount) }) + it("handle click add to workspace", () => { + const collectionService = TestBed.inject(CollectionService) + const spy = spyOn(collectionService, "getWorkspace").and.callThrough() + component["handleClickAddToWorkspace"]() + expect(spy).toHaveBeenCalled() + }) + it("skillCountLabel should be correct", () => { component.setResults(createMockPaginatedSkills(0, 0)) expect(component.skillCountLabel).toEqual("0 RSDs") @@ -396,8 +407,9 @@ describe("SkillsListComponent", () => { tableActions = component.tableActions() let skill4 = createMockSkillSummary("id4", PublishStatus.Archived) let action4 = tableActions[4] - expect(action4.label).toEqual("Add to Collection") - expect(action4 && action4.callback).toBeTruthy() + expect(action4.label).toEqual("Add to") + expect(action4).toBeTruthy() + expect(action4.callback).toBeUndefined() expect(action4.callback?.(action4, skill4)).toBeFalsy() // Always false expect(action4.visible?.(skill4)).toBeTruthy() // There are selected skills @@ -478,4 +490,11 @@ describe("SkillsListComponent", () => { it("getSelectAllEnabled should be true", () => { expect(component.getSelectAllEnabled()).toBeTruthy() }) + + it("add to workspace should be visible", () => { + component.selectedSkills = [ + createMockSkillSummary("id1", PublishStatus.Draft) + ] + expect(component["addToWorkspaceVisible"]()).toBeTrue() + }) }) diff --git a/ui/src/app/richskill/list/skills-list.component.ts b/ui/src/app/richskill/list/skills-list.component.ts index 7cfd17ecc..cc5e1756f 100644 --- a/ui/src/app/richskill/list/skills-list.component.ts +++ b/ui/src/app/richskill/list/skills-list.component.ts @@ -1,4 +1,4 @@ -import {ApiSearch, PaginatedSkills} from "../service/rich-skill-search.service"; +import {ApiSearch, ApiSkillListUpdate, PaginatedSkills} from "../service/rich-skill-search.service" import {ApiSkillSummary} from "../ApiSkillSummary"; import {checkArchived, determineFilters, PublishStatus} from "../../PublishStatus"; import {TableActionDefinition} from "../../table/skills-library-table/has-action-definitions"; @@ -14,7 +14,9 @@ import {ExtrasSelectedSkillsState} from "../../collection/add-skills-collection. import {TableActionBarComponent} from "../../table/skills-library-table/table-action-bar.component"; import {AuthService} from "../../auth/auth-service"; import {ButtonAction} from "../../auth/auth-roles"; - +import {CollectionService} from "../../collection/service/collection.service" +import {ApiCollection} from "../../collection/ApiCollection" +import {CollectionPipe} from "../../pipes" @Component({ selector: "app-skills-list", @@ -24,6 +26,7 @@ export class SkillsListComponent extends QuickLinksHelper { from = 0 size = 50 + collection?: ApiCollection @ViewChild("titleHeading") titleElement!: ElementRef @ViewChild(TableActionBarComponent) tableActionBar!: TableActionBarComponent @@ -46,6 +49,7 @@ export class SkillsListComponent extends QuickLinksHelper { constructor(protected router: Router, protected richSkillService: RichSkillService, + protected collectionService: CollectionService, protected toastService: ToastService, protected authService: AuthService, ) { @@ -145,8 +149,15 @@ export class SkillsListComponent extends QuickLinksHelper { return false } + addToVisible(): boolean { + return (this.selectedSkills?.length ?? 0) > 0 + } + addToCollectionVisible(skill?: ApiSkillSummary): boolean { - return ((this.selectedSkills?.length ?? 0) > 0) && this.authService.isEnabledByRoles(ButtonAction.CollectionSkillsUpdate) + if (this.collection?.status === PublishStatus.Workspace) { + return this.addToVisible() && this.authService.isEnabledByRoles(ButtonAction.MyWorkspace) + } + return this.addToVisible() && this.authService.isEnabledByRoles(ButtonAction.CollectionSkillsUpdate) } handleFiltersChanged(newFilters: Set): void { @@ -195,7 +206,7 @@ export class SkillsListComponent extends QuickLinksHelper { })) } else { actions.push(new TableActionDefinition({ - label: "Remove from Collection", + label: `Remove from ${this.collectionOrWorkspace(true)}`, callback: (action: TableActionDefinition, skill?: ApiSkillSummary) => this.handleClickRemoveCollection(action, skill), visible: (skill?: ApiSkillSummary) => this.addToCollectionVisible(skill) })) @@ -246,19 +257,30 @@ export class SkillsListComponent extends QuickLinksHelper { if (this.showAddToCollection) { actions.push(new TableActionDefinition({ - label: "Add to Collection", - icon: "collection", + label: "Add to", + icon: "add", primary: true, - callback: (action: TableActionDefinition, skill?: ApiSkillSummary) => this.handleClickAddCollection(action, skill), - visible: (skill?: ApiSkillSummary) => this.addToCollectionVisible(skill) + visible: (skill?: ApiSkillSummary) => this.addToVisible(), + menu: [ + { + label: "Add to Collection", + callback: (action: TableActionDefinition, skill?: ApiSkillSummary) => this.handleClickAddCollection(action, skill), + visible: () => this.addToCollectionVisible() + }, + { + label: "Add to Workspace", + callback: () => this.handleClickAddToWorkspace(), + visible: () => this.addToWorkspaceVisible() + } + ] })) } else { actions.push(new TableActionDefinition({ - label: "Remove from Collection", + label: `Remove from ${this.collectionOrWorkspace(true)}`, icon: "dismiss", primary: true, callback: (action: TableActionDefinition, skill?: ApiSkillSummary) => this.handleClickRemoveCollection(action, skill), - visible: (skill?: ApiSkillSummary) => this.addToCollectionVisible(skill) + visible: (skill?: ApiSkillSummary) => this.addToCollectionVisible(skill) || this.addToWorkspaceVisible() })) } @@ -266,6 +288,10 @@ export class SkillsListComponent extends QuickLinksHelper { } + protected addToWorkspaceVisible(): boolean { + return this.addToVisible() && this.authService.isEnabledByRoles(ButtonAction.MyWorkspace) + } + protected handleClickExportSearch(): void { } @@ -274,6 +300,24 @@ export class SkillsListComponent extends QuickLinksHelper { return false } + protected handleClickAddToWorkspace(): void { + const skillListUpdate = this.getSelectAllEnabled() ? new ApiSkillListUpdate( + {add: new ApiSearch({query: this.matchingQuery?.join("")})} + ) : new ApiSkillListUpdate( + {add: new ApiSearch({uuids: this.getSelectedSkills()?.map(i => i.uuid)})} + ) + this.toastService.showBlockingLoader() + this.collectionService.getWorkspace().subscribe(workspace => { + this.collectionService.updateSkillsWithResult(workspace.uuid, skillListUpdate).subscribe(result => { + if (result) { + const message = `You added ${result.modifiedCount} RSDs to the workspace.` + this.toastService.showToast("Success!", message) + this.toastService.hideBlockingLoader() + } + }) + }) + } + protected handleClickAddCollection(action: TableActionDefinition, skill?: ApiSkillSummary): boolean { const selection = this.getSelectedSkills(skill) this.router.navigate(["/collections/add-skills"], { @@ -378,4 +422,8 @@ export class SkillsListComponent extends QuickLinksHelper { focusActionBar(): void { this.tableActionBar.focus() } + + collectionOrWorkspace(includesMy: boolean): string { + return new CollectionPipe().transform(this.collection?.status, includesMy) + } } diff --git a/ui/src/app/richskill/service/rich-skill.service.spec.ts b/ui/src/app/richskill/service/rich-skill.service.spec.ts index 5cf52bebf..575bd1e98 100644 --- a/ui/src/app/richskill/service/rich-skill.service.spec.ts +++ b/ui/src/app/richskill/service/rich-skill.service.spec.ts @@ -80,7 +80,7 @@ describe("RichSkillService", () => { // Arrange RouterData.commands = [] AuthServiceData.isDown = false - const path = "api/skills?sort=name.asc&status=Draft&size=3&from=0" + const path = "api/skills?sort=name.asc&status=draft&size=3&from=0" const testData: PaginatedSkills = createMockPaginatedSkills(3, 10) const statuses = new Set([ PublishStatus.Draft ]) @@ -301,7 +301,7 @@ describe("RichSkillService", () => { }) const req = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + "/" + path + - "?sort=skill.asc&status=Published&status=Draft&size=5&from=1") + "?sort=skill.asc&status=published&status=draft&size=5&from=1") expect(req.request.method).toEqual("POST") req.flush(testData.skills, { headers: { "x-total-count": "" + testData.totalCount} @@ -381,7 +381,7 @@ describe("RichSkillService", () => { /* Service call will make 2 requests: the requested action + the async task result */ /* Setup for request 1 */ const req1 = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + "/" + path1 + - "?newStatus=Published") + "?newStatus=published") expect(req1.request.method).toEqual("POST") req1.flush(taskResult) diff --git a/ui/src/app/search/rich-skill-search-results.component.ts b/ui/src/app/search/rich-skill-search-results.component.ts index 2b6198ba1..07bd66f31 100644 --- a/ui/src/app/search/rich-skill-search-results.component.ts +++ b/ui/src/app/search/rich-skill-search-results.component.ts @@ -13,6 +13,7 @@ import {Title} from "@angular/platform-browser"; import {AuthService} from "../auth/auth-service"; import {formatDate} from "@angular/common" import * as FileSaver from "file-saver" +import {CollectionService} from "../collection/service/collection.service" @Component({ @@ -32,6 +33,7 @@ export class RichSkillSearchResultsComponent extends SkillsListComponent impleme constructor(protected router: Router, protected richSkillService: RichSkillService, + protected collectionService: CollectionService, protected toastService: ToastService, protected searchService: SearchService, protected route: ActivatedRoute, @@ -39,7 +41,7 @@ export class RichSkillSearchResultsComponent extends SkillsListComponent impleme protected authService: AuthService, @Inject(LOCALE_ID) protected locale: string ) { - super(router, richSkillService, toastService, authService) + super(router, richSkillService, collectionService, toastService, authService) this.searchService.searchQuery$.subscribe(apiSearch => this.handleNewSearch(apiSearch) ) } diff --git a/ui/src/app/table/skills-library-table/action-bar-item.component.html b/ui/src/app/table/skills-library-table/action-bar-item.component.html index 2ed05b69a..18342f3ac 100644 --- a/ui/src/app/table/skills-library-table/action-bar-item.component.html +++ b/ui/src/app/table/skills-library-table/action-bar-item.component.html @@ -1,16 +1,26 @@ - + +
    + + +
    +
    +
    +
  • diff --git a/ui/src/app/table/skills-library-table/action-bar-item.component.ts b/ui/src/app/table/skills-library-table/action-bar-item.component.ts index 1165c8cd7..bdce117f0 100644 --- a/ui/src/app/table/skills-library-table/action-bar-item.component.ts +++ b/ui/src/app/table/skills-library-table/action-bar-item.component.ts @@ -3,7 +3,8 @@ import {TableActionDefinition} from "./has-action-definitions"; @Component({ selector: "app-action-bar-item", - templateUrl: "./action-bar-item.component.html" + templateUrl: "./action-bar-item.component.html", + styleUrls: ["./action-bar-item.components.scss"] }) export class ActionBarItemComponent implements OnInit { @Input() action: TableActionDefinition | undefined diff --git a/ui/src/app/table/skills-library-table/action-bar-item.components.scss b/ui/src/app/table/skills-library-table/action-bar-item.components.scss new file mode 100644 index 000000000..00c334d83 --- /dev/null +++ b/ui/src/app/table/skills-library-table/action-bar-item.components.scss @@ -0,0 +1,62 @@ +.dropbtn { + color: white; + padding: 16px; + font-size: 16px; + border: none; +} + +.dropup { + position: relative; + display: inline-block; +} + +.dropup-content { + display: none; + position: absolute; + background-color: #f1f1f1; + min-width: 160px; + bottom: 50px; + z-index: 1; +} + +.dropup-content a { + color: black; + padding: 12px 16px; + text-decoration: none; + display: block; +} + +.dropup-content a:hover { + background-color: #ccc; + margin-bottom: 20px; +} + +.dropup:hover .dropup-content.dropup-content-visible { + margin-left: -40px; + display: block; + margin-bottom: 30px; +} + +.dropup-item { + width: 100%; + color: black; + padding-left: 10%; + padding-top: 5%; + padding-bottom: 5%; +} + +.dropup-item:hover { + text-decoration: underline; +} + +.dropup-item:disabled { + background-color: #cccccc; + color: #666666; + text-decoration: none; + cursor:not-allowed +} + +hr { + border: 0.5px solid gray; + margin: 1px 5px; +} diff --git a/ui/src/app/table/skills-library-table/has-action-definitions.ts b/ui/src/app/table/skills-library-table/has-action-definitions.ts index ba3cbd5a7..03bd5bcae 100644 --- a/ui/src/app/table/skills-library-table/has-action-definitions.ts +++ b/ui/src/app/table/skills-library-table/has-action-definitions.ts @@ -1,10 +1,19 @@ import {Component, Input} from "@angular/core"; +import {ApiSkillSummary} from "../../richskill/ApiSkillSummary" + +export interface ITableActionDefinitionSubMenu { + label?: string, + icon?: string, + callback?: (actionDefinition: TableActionDefinition, data?: any) => void + visible?: () => boolean +} interface IActionDefinition { label?: string icon?: string primary?: boolean offset?: boolean + menu?: ITableActionDefinitionSubMenu[] callback?: (actionDefinition: TableActionDefinition, data?: any) => void visible?: (data?: any) => boolean } @@ -14,16 +23,18 @@ export class TableActionDefinition { icon: string = "dismiss" primary: boolean = false offset: boolean = false + menu?: ITableActionDefinitionSubMenu[] callback?: ((actionDefinition: TableActionDefinition, data?: any) => void) visible?: (data?: any) => boolean - constructor({label, icon, primary, offset, callback, visible}: IActionDefinition) { + constructor({label, icon, primary, offset, callback, visible, menu}: IActionDefinition) { this.label = label ?? "" this.icon = icon ?? "" this.callback = callback this.visible = visible this.primary = primary ?? false this.offset = offset ?? false + this.menu = menu ?? [] } fire(data?: any): void { diff --git a/ui/test/resource/mock-stubs.ts b/ui/test/resource/mock-stubs.ts index 54a572b49..b0c05d835 100644 --- a/ui/test/resource/mock-stubs.ts +++ b/ui/test/resource/mock-stubs.ts @@ -262,6 +262,17 @@ export class CollectionServiceStub { return of(createMockPaginatedCollections()) } + getWorkspace(): Observable { + const date = new Date() + return of(createMockCollection( + date, + date, + undefined, + undefined, + PublishStatus.Workspace + )) + } + deleteCollectionWithResult(uuid: string): Observable { return of(apiTaskResultForDeleteCollection) } @@ -269,6 +280,17 @@ export class CollectionServiceStub { deleteCollection(uuid: string): Observable { return of(apiTaskResultForDeleteCollection) } + + createCollection(updateObject: ICollectionUpdate): Observable { + const date = new Date("2020-06-25T14:58:46.313Z") + return of(new ApiCollection(createMockCollection( + date, + date, + date, + date, + PublishStatus.Draft + ))) + } } // noinspection JSUnusedGlobalSymbols