diff --git a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt index bc03a1cd4..ae0088a78 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/RoutePaths.kt @@ -13,6 +13,7 @@ object RoutePaths { const val SKILLS_PATH = "$API/skills" const val SKILLS_LIST = SKILLS_PATH const val SKILLS_CREATE = SKILLS_PATH + const val SKILLS_FILTER = "$SKILLS_PATH/filter" const val SKILL_PUBLISH = "$SKILLS_PATH/publish" const val SKILL_DETAIL = "$SKILLS_PATH/{uuid}" const val SKILL_UPDATE = "$SKILL_DETAIL/update" diff --git a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt index 57e4bd9ed..c29536470 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/api/model/ApiSearch.kt @@ -11,6 +11,9 @@ data class ApiSearch( @JsonProperty("advanced") val advanced: ApiAdvancedSearch? = null, + @JsonProperty("filtered") + val filtered: ApiFilteredSearch? = null, + @JsonProperty("uuids") val uuids: List? = null ) @@ -58,3 +61,34 @@ data class ApiSkillListUpdate( @JsonProperty("remove") val remove: ApiSearch? = null ) + +@JsonInclude(JsonInclude.Include.ALWAYS) +data class ApiFilteredSearch( + + @JsonProperty("categories") + val categories: List? = null, + + @JsonProperty("keywords") + val keywords: List? = null, + + @JsonProperty("standards") + val standards: List? = null, + + @JsonProperty("certifications") + val certifications: List? = null, + + @JsonProperty("alignments") + val alignments: List? = null, + + @JsonProperty("jobcodes") + val jobCodes: List? = null, + + @JsonProperty("employers") + val employers: List? = null, + + @JsonProperty("authors") + val authors: List? = null, + + @JsonProperty("occupations") + val occupations: List? = null +) \ No newline at end of file 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 1212728a9..a0f2df085 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/config/Constants.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/config/Constants.kt @@ -18,6 +18,7 @@ const val INDEX_JOBCODE_DOC = "jobcode_v1" const val INDEX_KEYWORD_DOC = "keyword" // ElasticSearch Sort Criteria -const val NAME_SORT_INSENSITIVE = "name.sort_insensitive" -const val CATEGORY_SORT_INSENSITIVE = "category.sort_insensitive" +const val SORT_INSENSITIVE = ".sort_insensitive" +const val NAME_SORT_INSENSITIVE = "name${SORT_INSENSITIVE}" +const val CATEGORY_SORT_INSENSITIVE = "category${SORT_INSENSITIVE}" diff --git a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt index aab76bfa4..6bc1208bc 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/jobcode/JobCodeEsRepo.kt @@ -6,6 +6,7 @@ import org.elasticsearch.index.query.BoolQueryBuilder import org.elasticsearch.index.query.Operator import org.elasticsearch.index.query.QueryBuilders.* import org.elasticsearch.search.sort.SortBuilders +import org.elasticsearch.search.sort.SortOrder import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Configuration import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate @@ -28,13 +29,18 @@ class CustomJobCodeRepositoryImpl @Autowired constructor(override val elasticSea CustomJobCodeRepository { override fun typeAheadSearch(query: String): SearchHits { - val limitedPageable = OffsetPageable(0, 10, null) - val disjunctionQuery = JobCodeQueries.multiPropertySearch(query) + val nsq: NativeSearchQueryBuilder - val nsq: NativeSearchQueryBuilder = - NativeSearchQueryBuilder().withPageable(limitedPageable).withQuery(disjunctionQuery) - .withSort(SortBuilders.scoreSort()) + val limitedPageable: OffsetPageable = if (query.isEmpty()) { + OffsetPageable(0, 10000, null) + } else { + OffsetPageable(0, 20, null) + } + val disjunctionQuery = JobCodeQueries.multiPropertySearch(query) + nsq = + NativeSearchQueryBuilder().withPageable(limitedPageable).withQuery(disjunctionQuery) + .withSort(SortBuilders.fieldSort("${JobCode::code.name}.keyword").order(SortOrder.ASC)) return elasticSearchTemplate.search(nsq.build(), JobCode::class.java) } } diff --git a/api/src/main/kotlin/edu/wgu/osmt/keyword/Keyword.kt b/api/src/main/kotlin/edu/wgu/osmt/keyword/Keyword.kt index f19887580..fec355ab4 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/keyword/Keyword.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/keyword/Keyword.kt @@ -1,13 +1,23 @@ package edu.wgu.osmt.keyword import edu.wgu.osmt.config.INDEX_KEYWORD_DOC -import edu.wgu.osmt.db.* +import edu.wgu.osmt.db.DatabaseData +import edu.wgu.osmt.db.HasUpdateDate +import edu.wgu.osmt.db.NullableFieldUpdate +import edu.wgu.osmt.db.TableWithUpdate +import edu.wgu.osmt.db.UpdateObject import org.elasticsearch.core.Nullable import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.`java-time`.datetime import org.springframework.data.annotation.Id -import org.springframework.data.elasticsearch.annotations.* +import org.springframework.data.elasticsearch.annotations.DateFormat +import org.springframework.data.elasticsearch.annotations.Document +import org.springframework.data.elasticsearch.annotations.Field +import org.springframework.data.elasticsearch.annotations.FieldType +import org.springframework.data.elasticsearch.annotations.InnerField +import org.springframework.data.elasticsearch.annotations.MultiField +import org.springframework.data.elasticsearch.annotations.Setting import java.time.LocalDateTime @Document(indexName = INDEX_KEYWORD_DOC, createIndex = true) @@ -32,7 +42,8 @@ data class Keyword( otherFields = [ InnerField(suffix = "", type = FieldType.Search_As_You_Type), InnerField(suffix = "raw", analyzer = "whitespace_exact", type = FieldType.Text), - InnerField(suffix = "keyword", type = FieldType.Keyword) + InnerField(suffix = "keyword", type = FieldType.Keyword), + InnerField(suffix = "sort_insensitive", type = FieldType.Keyword, normalizer = "lowercase_normalizer") ] ) val value: String? = null, diff --git a/api/src/main/kotlin/edu/wgu/osmt/keyword/KeywordEsRepo.kt b/api/src/main/kotlin/edu/wgu/osmt/keyword/KeywordEsRepo.kt index 48ac60879..984864a3a 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/keyword/KeywordEsRepo.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/keyword/KeywordEsRepo.kt @@ -1,9 +1,11 @@ package edu.wgu.osmt.keyword import edu.wgu.osmt.config.INDEX_KEYWORD_DOC +import edu.wgu.osmt.config.SORT_INSENSITIVE import edu.wgu.osmt.elasticsearch.OffsetPageable import org.elasticsearch.index.query.QueryBuilders import org.elasticsearch.search.sort.SortBuilders +import org.elasticsearch.search.sort.SortOrder import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Configuration import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate @@ -25,26 +27,39 @@ interface CustomKeywordRepository { class CustomKeywordRepositoryImpl @Autowired constructor(override val elasticSearchTemplate: ElasticsearchRestTemplate) : CustomKeywordRepository { override fun typeAheadSearch(query: String, type: KeywordTypeEnum): SearchHits { - val limitedPageable = OffsetPageable(0, 10, null) + val limitedPageable: OffsetPageable val bq = QueryBuilders.boolQuery() + val nsq: NativeSearchQueryBuilder - val nsq: NativeSearchQueryBuilder = - NativeSearchQueryBuilder().withPageable(limitedPageable).withQuery(bq).withSort(SortBuilders.scoreSort()) - - bq - .must(QueryBuilders.termQuery(Keyword::type.name, type.name)) - .should( - QueryBuilders.matchBoolPrefixQuery( - Keyword::value.name, - query + if(query.isEmpty()){ //retrieve all + limitedPageable = OffsetPageable(0, 10000, null) + nsq = NativeSearchQueryBuilder().withPageable(limitedPageable).withQuery(bq) + .withSort(SortBuilders.fieldSort("${Keyword::value.name}$SORT_INSENSITIVE").order(SortOrder.ASC)) + bq + .must(QueryBuilders.termQuery(Keyword::type.name, type.name)) + .should( + QueryBuilders.matchAllQuery() + ) + } + else { + limitedPageable = OffsetPageable(0, 20, null) + nsq = NativeSearchQueryBuilder().withPageable(limitedPageable).withQuery(bq) + .withSort(SortBuilders.fieldSort("${Keyword::value.name}$SORT_INSENSITIVE").order(SortOrder.ASC)) + bq + .must(QueryBuilders.termQuery(Keyword::type.name, type.name)) + .should( + QueryBuilders.matchBoolPrefixQuery( + Keyword::value.name, + query + ) ) - ) - .should( - QueryBuilders.matchPhraseQuery( - Keyword::value.name, - query - ).boost(5f) - ).minimumShouldMatch(1) + .should( + QueryBuilders.matchPhraseQuery( + Keyword::value.name, + query + ).boost(5f) + ).minimumShouldMatch(1) + } return elasticSearchTemplate.search(nsq.build(), Keyword::class.java) } 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 3bd85c727..fd032c847 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillController.kt @@ -2,17 +2,20 @@ package edu.wgu.osmt.richskill import edu.wgu.osmt.HasAllPaginated import edu.wgu.osmt.RoutePaths +import edu.wgu.osmt.RoutePaths.SKILLS_FILTER import edu.wgu.osmt.api.GeneralApiException import edu.wgu.osmt.api.model.ApiSearch import edu.wgu.osmt.api.model.ApiSkill import edu.wgu.osmt.api.model.ApiSkillUpdate import edu.wgu.osmt.api.model.SkillSortEnum +import edu.wgu.osmt.api.model.SortOrder import edu.wgu.osmt.auditlog.AuditLog import edu.wgu.osmt.auditlog.AuditLogRepository import edu.wgu.osmt.auditlog.AuditLogSortEnum import edu.wgu.osmt.config.AppConfig import edu.wgu.osmt.db.PublishStatus import edu.wgu.osmt.elasticsearch.OffsetPageable +import edu.wgu.osmt.elasticsearch.PaginatedLinks import edu.wgu.osmt.keyword.KeywordDao import edu.wgu.osmt.security.OAuthHelper import edu.wgu.osmt.task.AppliesToType @@ -23,6 +26,7 @@ import edu.wgu.osmt.task.PublishTask import edu.wgu.osmt.task.Task import edu.wgu.osmt.task.TaskMessageService import edu.wgu.osmt.task.TaskResult +import org.apache.commons.lang3.StringUtils import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -75,6 +79,50 @@ class RichSkillController @Autowired constructor( } return super.allPaginated(uriComponentsBuilder, size, from, status, sort, user) } + @PostMapping(SKILLS_FILTER, produces = [MediaType.APPLICATION_JSON_VALUE]) + @ResponseBody + fun allPaginatedWithFilters( + uriComponentsBuilder: UriComponentsBuilder, + size: Int, + from: Int, + status: Array, + @RequestBody apiSearch: ApiSearch, + sort: String?, + @AuthenticationPrincipal user: Jwt? + ): HttpEntity> { + + val publishStatuses = status.mapNotNull { + val status = PublishStatus.forApiValue(it) + if (user == null && (status == PublishStatus.Deleted || status == PublishStatus.Draft)) null else status + }.toSet() + val sortEnum: SortOrder = sortOrderCompanion.forValueOrDefault(sort) + val pageable = OffsetPageable(from, size, sortEnum.sort) + val searchHits = richSkillEsRepo.byApiSearch( + apiSearch, + publishStatuses, + pageable, + StringUtils.EMPTY + ) + val countAllFiltered: Long = searchHits.totalHits + val responseHeaders = HttpHeaders() + responseHeaders.add("X-Total-Count", countAllFiltered.toString()) + + uriComponentsBuilder + .path(SKILLS_FILTER) + .queryParam(RoutePaths.QueryParams.FROM, from) + .queryParam(RoutePaths.QueryParams.SIZE, size) + .queryParam(RoutePaths.QueryParams.SORT, sort) + .queryParam(RoutePaths.QueryParams.STATUS, status.joinToString(",").toLowerCase()) + + PaginatedLinks( + pageable, + searchHits.totalHits.toInt(), + uriComponentsBuilder + ).addToHeaders(responseHeaders) + + return ResponseEntity.status(200).headers(responseHeaders) + .body(searchHits.map { it.content }.toList()) + } @PostMapping(RoutePaths.SKILLS_CREATE, produces = [MediaType.APPLICATION_JSON_VALUE]) @ResponseBody diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillDoc.kt b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillDoc.kt index 90dfdd045..0601f22c9 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillDoc.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillDoc.kt @@ -115,7 +115,7 @@ data class RichSkillDoc( InnerField(suffix = "keyword", type = Keyword) ] ) - @get:JsonIgnore + @get:JsonProperty("standards") val standards: List = listOf(), @MultiField( diff --git a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepo.kt b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepo.kt index f02df9d60..3c880e2ac 100644 --- a/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepo.kt +++ b/api/src/main/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepo.kt @@ -2,6 +2,7 @@ package edu.wgu.osmt.richskill import edu.wgu.osmt.PaginationDefaults import edu.wgu.osmt.api.model.ApiAdvancedSearch +import edu.wgu.osmt.api.model.ApiFilteredSearch import edu.wgu.osmt.api.model.ApiSearch import edu.wgu.osmt.api.model.ApiSimilaritySearch import edu.wgu.osmt.config.INDEX_RICHSKILL_DOC @@ -14,6 +15,7 @@ import edu.wgu.osmt.nullIfEmpty import org.apache.lucene.search.join.ScoreMode import org.elasticsearch.index.query.* import org.elasticsearch.index.query.QueryBuilders.* +import org.elasticsearch.script.Script import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Configuration import org.springframework.data.domain.Page @@ -31,6 +33,7 @@ const val collectionsUuid = "collections.uuid" interface CustomRichSkillQueries : FindsAllByPublishStatus { fun generateBoolQueriesFromApiSearch(bq: BoolQueryBuilder, advancedQuery: ApiAdvancedSearch) + fun generateBoolQueriesFromApiSearchWithFilters(bq: BoolQueryBuilder, filteredQuery: ApiFilteredSearch, publishStatus: Set) fun richSkillPropertiesMultiMatch(query: String): BoolQueryBuilder fun byApiSearch( apiSearch: ApiSearch, @@ -68,6 +71,24 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear ) } + private fun buildNestedQueries(path: String?=null, queryParams: List) : BoolQueryBuilder { + val disjunctionQuery = disMaxQuery() + val queries = ArrayList() + + queryParams.let { + it.map { param -> + queries.add( + prefixQuery("$path.keyword", param) + ) + } + + } + disjunctionQuery.innerQueries().addAll(queries) + + return boolQuery().must(existsQuery("$path.keyword")).must(disjunctionQuery) + } + + // Query clauses for Rich Skill properties override fun generateBoolQueriesFromApiSearch(bq: BoolQueryBuilder, advancedQuery: ApiAdvancedSearch) { with(advancedQuery) { @@ -77,7 +98,7 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear if (it.contains("\"")) { bq.must(simpleQueryStringQuery(it).field("${RichSkillDoc::name.name}.raw").defaultOperator(Operator.AND)) } else { - bq.must(QueryBuilders.matchBoolPrefixQuery(RichSkillDoc::name.name, it)) + bq.must(matchBoolPrefixQuery(RichSkillDoc::name.name, it)) } } category.nullIfEmpty()?.let { @@ -91,7 +112,7 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear if (it.contains("\"")) { bq.must(simpleQueryStringQuery(it).field("${RichSkillDoc::author.name}.raw").defaultOperator(Operator.AND)) } else { - bq.must(QueryBuilders.matchBoolPrefixQuery(RichSkillDoc::author.name, it)) + bq.must(matchBoolPrefixQuery(RichSkillDoc::author.name, it)) } } skillStatement.nullIfEmpty()?.let { @@ -100,7 +121,7 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear simpleQueryStringQuery(it).field("${RichSkillDoc::statement.name}.raw").defaultOperator(Operator.AND) ) } else { - bq.must(QueryBuilders.matchBoolPrefixQuery(RichSkillDoc::statement.name, it)) + bq.must(matchBoolPrefixQuery(RichSkillDoc::statement.name, it)) } } keywords?.map { @@ -110,7 +131,7 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear .defaultOperator(Operator.AND) ) } else { - bq.must(QueryBuilders.matchBoolPrefixQuery(RichSkillDoc::searchingKeywords.name, it)) + bq.must(matchBoolPrefixQuery(RichSkillDoc::searchingKeywords.name, it)) } } @@ -173,6 +194,59 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear } } + override fun generateBoolQueriesFromApiSearchWithFilters(bq: BoolQueryBuilder, filteredQuery: ApiFilteredSearch, publishStatus: Set) { + bq.must( + termsQuery( + RichSkillDoc::publishStatus.name, + publishStatus.map { ps -> ps.toString() } + ) + ) + with(filteredQuery) { + categories?. let { + bq.must(buildNestedQueries(RichSkillDoc::category.name, it)) + } + keywords?. let { + it.mapNotNull { + bq.must(generateTermsSetQueryBuilder(RichSkillDoc::searchingKeywords.name, keywords)) + } + } + standards?. let { + it.mapNotNull { + bq.must(generateTermsSetQueryBuilder(RichSkillDoc::standards.name, standards)) + } + } + certifications?. let { + it.mapNotNull { + bq.must(generateTermsSetQueryBuilder(RichSkillDoc::certifications.name, certifications)) + } + } + alignments?. let { + it.mapNotNull { + bq.must(generateTermsSetQueryBuilder(RichSkillDoc::alignments.name, alignments)) + } + } + employers?. let { + it.mapNotNull { + bq.must(generateTermsSetQueryBuilder(RichSkillDoc::employers.name, employers)) + } + } + authors?. let { + bq.must(buildNestedQueries(RichSkillDoc::author.name, it)) + } + occupations?.let { + it.mapNotNull { value -> + bq.must( + occupationQueries(value) + ) + } + } + } + } + + private fun generateTermsSetQueryBuilder(fieldName: String, list: List): TermsSetQueryBuilder { + return TermsSetQueryBuilder("$fieldName.keyword", list).setMinimumShouldMatchScript(Script(list.size.toString())) + } + override fun richSkillPropertiesMultiMatch(query: String): BoolQueryBuilder { val isComplex = query.contains("\"") @@ -246,18 +320,27 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear val bq = boolQuery() nsq.withQuery(bq) - nsq.withFilter(BoolQueryBuilder().must( - termsQuery( - RichSkillDoc::publishStatus.name, - publishStatus.map { ps -> ps.toString() } + nsq.withFilter( + BoolQueryBuilder().must( + termsQuery( + RichSkillDoc::publishStatus.name, + publishStatus.map { ps -> ps.toString() } + ) ) - )) + ) + + apiSearch.filtered?.let { generateBoolQueriesFromApiSearchWithFilters(bq, it, publishStatus) } // treat the presence of query property to mean multi field search with that term if (!apiSearch.query.isNullOrBlank()) { if (collectionId.isNullOrBlank()) { - bq.should(richSkillPropertiesMultiMatch(apiSearch.query)) + if(apiSearch.filtered != null){ + bq.must(richSkillPropertiesMultiMatch(apiSearch.query)) + } + else { + bq.should(richSkillPropertiesMultiMatch(apiSearch.query)) + } bq.should(occupationQueries(apiSearch.query)) bq.should( nestedQuery( @@ -268,14 +351,29 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear ) ) } else { - bq.must( - nestedQuery( - RichSkillDoc::collections.name, - boolQuery().must(matchQuery(collectionsUuid, collectionId)), - ScoreMode.Avg + if(apiSearch.filtered != null) { + bq.must( + nestedQuery( + RichSkillDoc::collections.name, + boolQuery().must(matchQuery(collectionsUuid, collectionId).operator(Operator.AND)), + ScoreMode.Avg + ) ) + } + else { + bq.must( + nestedQuery( + RichSkillDoc::collections.name, + boolQuery().must(matchQuery(collectionsUuid, collectionId).operator(Operator.OR)), + ScoreMode.Avg + ) + ) + } + bq.must( + BoolQueryBuilder().should(richSkillPropertiesMultiMatch(apiSearch.query)) + .should(occupationQueries(apiSearch.query)) ) - bq.must(BoolQueryBuilder().should(richSkillPropertiesMultiMatch(apiSearch.query)).should(occupationQueries(apiSearch.query))) + } } else if (apiSearch.advanced != null) { generateBoolQueriesFromApiSearch(bq, apiSearch.advanced) @@ -299,7 +397,9 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear ) ) } - } else { + } + + else { var apiSearchUuids = apiSearch.uuids?.filterNotNull()?.filter { x: String? -> x != "" } if (!apiSearchUuids.isNullOrEmpty()) { @@ -324,6 +424,7 @@ class CustomRichSkillQueriesImpl @Autowired constructor(override val elasticSear } } + return nsq } diff --git a/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordEsRepoTest.kt b/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordEsRepoTest.kt index 6a702a9e3..83b67c6e5 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordEsRepoTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/keyword/KeywordEsRepoTest.kt @@ -46,6 +46,7 @@ class KeywordEsRepoTest @Autowired constructor( val result2 = keywordEsRepo.typeAheadSearch("yEl", KeywordTypeEnum.Keyword) val result3 = keywordEsRepo.typeAheadSearch("yell", KeywordTypeEnum.Keyword) val result4 = keywordEsRepo.typeAheadSearch("yellow", KeywordTypeEnum.Keyword) + val result5 = keywordEsRepo.typeAheadSearch("", KeywordTypeEnum.Keyword) assertThat(results.searchHits.count()).isEqualTo(2) @@ -53,5 +54,6 @@ class KeywordEsRepoTest @Autowired constructor( assertThat(result3.searchHits.count()).isEqualTo(2) assertThat(result4.searchHits.count()).isEqualTo(1) assertThat(result4.searchHits.first().content.value).isEqualTo("Yellow") + assertThat(result5.searchHits).hasSize(56) } } diff --git a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt index a4e2f12f7..f5324a086 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillControllerTest.kt @@ -5,6 +5,7 @@ import edu.wgu.osmt.HasDatabaseReset import edu.wgu.osmt.HasElasticsearchReset import edu.wgu.osmt.RoutePaths.EXPORT_LIBRARY import edu.wgu.osmt.SpringTest +import edu.wgu.osmt.api.model.ApiFilteredSearch import edu.wgu.osmt.api.model.ApiSearch import edu.wgu.osmt.collection.CollectionEsRepo import edu.wgu.osmt.config.AppConfig @@ -94,6 +95,52 @@ internal class RichSkillControllerTest @Autowired constructor( assertThat(result.body?.size).isEqualTo(size) } + @Test + fun testAllPaginatedWithNoFilters(){ + // Arrange + val size = 50 + val listOfSkills = mockData.getRichSkillDocs() + richSkillEsRepo.saveAll(listOfSkills) + + // Act + val result = richSkillController.allPaginatedWithFilters( + UriComponentsBuilder.newInstance(), + size, + 0, + arrayOf("draft","published"), + ApiSearch(), + "", + nullJwt + ) + + // Assert + assertThat(result.body?.size).isEqualTo(size) + } + + @Test + fun testAllPaginatedWithFilters(){ + // Arrange + val size = 50 + val listOfSkills = mockData.getRichSkillDocs() + richSkillEsRepo.saveAll(listOfSkills) + val filter: ApiFilteredSearch = ApiFilteredSearch(categories = listOf("Academic Accommodation Plans")) + + // Act + val result = richSkillController.allPaginatedWithFilters( + UriComponentsBuilder.newInstance(), + size, + 0, + arrayOf("draft","published"), + ApiSearch(filtered = filter), + "", + nullJwt + ) + + // Assert + assertThat(result.body?.size).isLessThan(size) + assertThat(result.body?.first()!!.category).isEqualTo("Academic Accommodation Plans") + } + @Test fun testByUUID(){ // Arrange diff --git a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt index 6442c5526..f4bc1b3a5 100644 --- a/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt +++ b/api/src/test/kotlin/edu/wgu/osmt/richskill/RichSkillEsRepoTest.kt @@ -6,6 +6,7 @@ import edu.wgu.osmt.SpringTest import edu.wgu.osmt.TestObjectHelpers import edu.wgu.osmt.TestObjectHelpers.keywordsGenerator import edu.wgu.osmt.api.model.ApiAdvancedSearch +import edu.wgu.osmt.api.model.ApiFilteredSearch import edu.wgu.osmt.api.model.ApiNamedReference import edu.wgu.osmt.api.model.ApiSearch import edu.wgu.osmt.api.model.ApiSimilaritySearch @@ -603,6 +604,509 @@ class RichSkillEsRepoTest @Autowired constructor( assertThat(alignmentsResult.searchHits.first().content.uuid).isEqualTo(skillByAlignments.uuid) } + @Test + fun `search with categories filter should apply to filtered search`() { + // Arrange + val skillWithCategory1 = TestObjectHelpers.randomRichSkillDoc().copy(category = "category1") + val skillWithCategory2 = TestObjectHelpers.randomRichSkillDoc().copy(category = "category2") + val skillWithCategory3 = TestObjectHelpers.randomRichSkillDoc().copy(category = "category3") + + richSkillEsRepo.saveAll(listOf(skillWithCategory1,skillWithCategory2,skillWithCategory3)) + + // Act + val filteredSearchResult = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + categories = listOf("category1", "category3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult.searchHits.first().content.uuid).isEqualTo(skillWithCategory1.uuid) + assertThat(filteredSearchResult.searchHits[1].content.uuid).isEqualTo(skillWithCategory3.uuid) + } + @Test + fun `search with keywords filter should apply to filtered search with AND operator between keywords`() { + // Arrange + val skillWithKeywords1and2 = TestObjectHelpers.randomRichSkillDoc().copy(searchingKeywords = listOf("keyword1", "keyword2")) + val skillWithKeywords2and3 = TestObjectHelpers.randomRichSkillDoc().copy(searchingKeywords = listOf("keyword2", "keyword3")) + val skillWithKeywords3and4 = TestObjectHelpers.randomRichSkillDoc().copy(searchingKeywords = listOf("keyword3", "keyword4")) + + richSkillEsRepo.saveAll(listOf(skillWithKeywords1and2,skillWithKeywords2and3,skillWithKeywords3and4)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + keywords = listOf("keyword1", "keyword2") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + keywords = listOf("keyword2", "keyword3") + ) + ) + ) + val filteredSearchResult3 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + keywords = listOf("keyword3", "keyword4") + ) + ) + ) + val filteredSearchResult4 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + keywords = listOf("keyword1", "keyword3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skillWithKeywords1and2.uuid) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithKeywords2and3.uuid) + assertThat(filteredSearchResult3.searchHits.first().content.uuid).isEqualTo(skillWithKeywords3and4.uuid) + assertThat(filteredSearchResult4).isEmpty() + } + @Test + fun `search with standards filter should apply to filtered search with AND operator between standards`() { + // Arrange + val skillWithStandards1and2 = TestObjectHelpers.randomRichSkillDoc().copy(standards = listOf("standard1", "standard2")) + val skillWithStandards2and3 = TestObjectHelpers.randomRichSkillDoc().copy(standards = listOf("standard2", "standard3")) + val skillWithStandards3and4 = TestObjectHelpers.randomRichSkillDoc().copy(standards = listOf("standard3", "standard4")) + + richSkillEsRepo.saveAll(listOf(skillWithStandards1and2,skillWithStandards2and3,skillWithStandards3and4)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + standards = listOf("standard1", "standard2") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + standards = listOf("standard2", "standard3") + ) + ) + ) + val filteredSearchResult3 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + standards = listOf("standard3", "standard4") + ) + ) + ) + val filteredSearchResult4 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + standards = listOf("standard1", "standard3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skillWithStandards1and2.uuid) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithStandards2and3.uuid) + assertThat(filteredSearchResult3.searchHits.first().content.uuid).isEqualTo(skillWithStandards3and4.uuid) + assertThat(filteredSearchResult4).isEmpty() + } + @Test + fun `Search with certifications filter should apply to filtered search with AND operator between certifications`() { + // Arrange + val skillWithCertifications1and2 = TestObjectHelpers.randomRichSkillDoc().copy(certifications = listOf("certification1", "certification2")) + val skillWithCertifications2and3 = TestObjectHelpers.randomRichSkillDoc().copy(certifications = listOf("certification2", "certification3")) + val skillWithCertifications3and4 = TestObjectHelpers.randomRichSkillDoc().copy(certifications = listOf("certification3", "certification4")) + + richSkillEsRepo.saveAll(listOf(skillWithCertifications1and2,skillWithCertifications2and3,skillWithCertifications3and4)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + certifications = listOf("certification1", "certification2") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + certifications = listOf("certification2", "certification3") + ) + ) + ) + val filteredSearchResult3 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + certifications = listOf("certification3", "certification4") + ) + ) + ) + val filteredSearchResult4 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + certifications = listOf("certification1", "certification3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skillWithCertifications1and2.uuid) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithCertifications2and3.uuid) + assertThat(filteredSearchResult3.searchHits.first().content.uuid).isEqualTo(skillWithCertifications3and4.uuid) + assertThat(filteredSearchResult4).isEmpty() + } + @Test + fun `Search with alignments filter should apply to filtered search with AND operator between alignments`() { + // Arrange + val skillWithAlignments1and2 = TestObjectHelpers.randomRichSkillDoc().copy(alignments = listOf("alignment1", "alignment2")) + val skillWithAlignments2and3 = TestObjectHelpers.randomRichSkillDoc().copy(alignments = listOf("alignment2", "alignment3")) + val skillWithAlignments3and4 = TestObjectHelpers.randomRichSkillDoc().copy(alignments = listOf("alignment3", "alignment4")) + + richSkillEsRepo.saveAll(listOf(skillWithAlignments1and2,skillWithAlignments2and3,skillWithAlignments3and4)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + alignments = listOf("alignment1", "alignment2") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + alignments = listOf("alignment2", "alignment3") + ) + ) + ) + val filteredSearchResult3 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + alignments = listOf("alignment3", "alignment4") + ) + ) + ) + val filteredSearchResult4 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + alignments = listOf("alignment1", "alignment3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skillWithAlignments1and2.uuid) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithAlignments2and3.uuid) + assertThat(filteredSearchResult3.searchHits.first().content.uuid).isEqualTo(skillWithAlignments3and4.uuid) + assertThat(filteredSearchResult4).isEmpty() + } + @Test + fun `Search with employers filter should apply to filtered search with AND operator between employers`() { + // Arrange + val skillWithEmployers1and2 = TestObjectHelpers.randomRichSkillDoc().copy(employers = listOf("employer1", "employer2")) + val skillWithEmployers2and3 = TestObjectHelpers.randomRichSkillDoc().copy(employers = listOf("employer2", "employer3")) + val skillWithEmployers3and4 = TestObjectHelpers.randomRichSkillDoc().copy(employers = listOf("employer3", "employer4")) + + richSkillEsRepo.saveAll(listOf(skillWithEmployers1and2,skillWithEmployers2and3,skillWithEmployers3and4)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + employers = listOf("employer1", "employer2") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + employers = listOf("employer2", "employer3") + ) + ) + ) + val filteredSearchResult3 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + employers = listOf("employer3", "employer4") + ) + ) + ) + val filteredSearchResult4 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + employers = listOf("employer1", "employer3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skillWithEmployers1and2.uuid) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithEmployers2and3.uuid) + assertThat(filteredSearchResult3.searchHits.first().content.uuid).isEqualTo(skillWithEmployers3and4.uuid) + assertThat(filteredSearchResult4).isEmpty() + } + @Test + fun `search with occupations filter should apply to filtered search`() { + // Arrange + val jobCodes1and2 = listOf( + TestObjectHelpers.randomJobCode().copy(code = "10-1111.11", id = TestObjectHelpers.elasticIdCounter), + TestObjectHelpers.randomJobCode().copy(code = "10-2222.22", id = TestObjectHelpers.elasticIdCounter), + ) + val jobCodes2and3 = listOf( + TestObjectHelpers.randomJobCode().copy(code = "10-2222.22", id = TestObjectHelpers.elasticIdCounter), + TestObjectHelpers.randomJobCode().copy(code = "10-3333.33", id = TestObjectHelpers.elasticIdCounter), + ) + val jobCodes3and4 = listOf( + TestObjectHelpers.randomJobCode().copy(code = "10-3333.33", id = TestObjectHelpers.elasticIdCounter), + TestObjectHelpers.randomJobCode().copy(code = "10-4444.44", id = TestObjectHelpers.elasticIdCounter), + ) + + val skillWithOccupations1and2 = TestObjectHelpers.randomRichSkillDoc() + .copy(jobCodes = jobCodes1and2) + val skillWithOccupations2and3 = TestObjectHelpers.randomRichSkillDoc() + .copy(jobCodes = jobCodes2and3) + val skillWithOccupations3and4 = TestObjectHelpers.randomRichSkillDoc() + .copy(jobCodes = jobCodes3and4) + + richSkillEsRepo.saveAll(listOf(skillWithOccupations1and2, skillWithOccupations2and3, skillWithOccupations3and4)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + advanced = ApiAdvancedSearch( + occupations = listOf( + "10-11", + "10-22" + ) + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + advanced = ApiAdvancedSearch( + occupations = listOf( + "10-22", + "10-33" + ) + ) + ) + ) + val filteredSearchResult3 = richSkillEsRepo.byApiSearch( + ApiSearch( + advanced = ApiAdvancedSearch( + occupations = listOf( + "10-33", + "10-44" + ) + ) + ) + ) + val filteredSearchResult4 = richSkillEsRepo.byApiSearch( + ApiSearch( + advanced = ApiAdvancedSearch( + occupations = listOf( + "10-11", + "10-33" + ) + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skillWithOccupations1and2.uuid) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithOccupations2and3.uuid) + assertThat(filteredSearchResult3.searchHits.first().content.uuid).isEqualTo(skillWithOccupations3and4.uuid) + assertThat(filteredSearchResult4).isEmpty() + + } + @Test + fun `search with authors filter should apply to filtered search`() { + // Arrange + val skillWithAuthor1 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author1") + val skillWithAuthor2 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author2") + val skillWithAuthor3 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author3") + + richSkillEsRepo.saveAll(listOf(skillWithAuthor1,skillWithAuthor2,skillWithAuthor3)) + + // Act + val filteredSearchResult = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + authors = listOf("author1", "author3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult.searchHits.first().content.uuid).isEqualTo(skillWithAuthor1.uuid) + assertThat(filteredSearchResult.searchHits[1].content.uuid).isEqualTo(skillWithAuthor3.uuid) + } + @Test + fun `search with authors & categories filter should apply to filtered search with AND operator between fields, and OR operator between values`() { + // Arrange + val skillWithAuthor1AndCategory1 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author1", category = "category1") + val skillWithAuthor2AndCategory2 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author2", category = "category2") + val skillWithAuthor1AndCategory3 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author1", category = "category3") + val skillWithAuthor2AndCategory3 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author2", category = "category3") + val skillWithAuthor3AndCategory3 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author3", category = "category3") + + richSkillEsRepo.saveAll(listOf(skillWithAuthor1AndCategory1,skillWithAuthor2AndCategory2,skillWithAuthor3AndCategory3, + skillWithAuthor1AndCategory3,skillWithAuthor2AndCategory3)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + authors = listOf("author1", "author2"), + categories = listOf("category1", "category2") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + authors = listOf("author1", "author2", "author3"), + categories = listOf("category1", "category2", "category3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits).hasSize(2) + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skillWithAuthor1AndCategory1.uuid) + assertThat(filteredSearchResult1.searchHits[1].content.uuid).isEqualTo(skillWithAuthor2AndCategory2.uuid) + assertThat(filteredSearchResult2.searchHits).hasSize(5) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithAuthor1AndCategory1.uuid) + assertThat(filteredSearchResult2.searchHits[4].content.uuid).isEqualTo(skillWithAuthor2AndCategory3.uuid) + } + @Test + fun `search with authors & categories & keywords filter should apply to filtered search with AND operator between fields, and OR operator between categories and authors and AND between keywords`() { + // Arrange + val skillWithAuthor1AndCategory1 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author1", category = "category1", + searchingKeywords = listOf("keyword1")) + val skillWithAuthor2AndCategory2 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author2", category = "category2", + searchingKeywords = listOf("keyword2")) + val skillWithAuthor1AndCategory3 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author1", category = "category3", + searchingKeywords = listOf("keyword1", "keyword3")) + val skillWithAuthor2AndCategory3 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author2", category = "category3", + searchingKeywords = listOf("keyword2", "keyword3")) + val skillWithAuthor3AndCategory3 = TestObjectHelpers.randomRichSkillDoc().copy(author = "author3", category = "category3", + searchingKeywords = listOf("keyword1", "keyword2", "keyword3")) + + richSkillEsRepo.saveAll(listOf(skillWithAuthor1AndCategory1,skillWithAuthor2AndCategory2,skillWithAuthor3AndCategory3, + skillWithAuthor1AndCategory3,skillWithAuthor2AndCategory3)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + authors = listOf("author1", "author2"), + categories = listOf("category1", "category2"), + keywords = listOf("keyword1", "keyword2") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + authors = listOf("author1", "author2", "author3"), + categories = listOf("category1", "category2", "category3"), + keywords = listOf("keyword1", "keyword2", "keyword3") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits).isEmpty() + assertThat(filteredSearchResult2.searchHits).hasSize(1) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skillWithAuthor3AndCategory3.uuid) + } + @Test + fun `search with categories, keywords & standards filter should apply to filtered search with AND operator between fields, and OR operator between categories and AND operator between keywords and standards`() { + // Arrange + val skill1 = TestObjectHelpers.randomRichSkillDoc().copy(category = "category1", + searchingKeywords = listOf("keyword1"), + standards = listOf("standard1") + ) + val skill2 = TestObjectHelpers.randomRichSkillDoc().copy(category = "category2", + searchingKeywords = listOf("keyword1", "keyword2"), + standards = listOf("standard1", "standard2") + ) + val skill3 = TestObjectHelpers.randomRichSkillDoc().copy(category = "category3", + searchingKeywords = listOf("keyword1", "keyword2", "keyword3"), + standards = listOf("standard1", "standard2", "standard3") + ) + val skill4 = TestObjectHelpers.randomRichSkillDoc().copy(category = "category4", + searchingKeywords = listOf("keyword1", "keyword2", "keyword3", "keyword4"), + standards = listOf("standard1", "standard2", "standard3", "standard4") + ) + + + richSkillEsRepo.saveAll(listOf(skill1,skill2,skill3,skill4)) + + // Act + val filteredSearchResult1 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + categories = listOf("category1", "category2"), + keywords = listOf("keyword1", "keyword2"), + standards = listOf("standard1") + ) + ) + ) + val filteredSearchResult2 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + categories = listOf("category1", "category2", "category3"), + keywords = listOf("keyword1", "keyword2", "keyword3"), + standards = listOf("standard1", "standard2") + ) + ) + ) + val filteredSearchResult3 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + categories = listOf("category1", "category2", "category3"), + keywords = listOf("keyword1", "keyword2", "keyword3"), + standards = listOf("standard1", "standard2", "standard3") + ) + ) + ) + val filteredSearchResult4 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + categories = listOf("category1", "category2", "category3"), + keywords = listOf("keyword1", "keyword2", "keyword3"), + standards = listOf("standard1", "standard2", "standard3", "standard4") + ) + ) + ) + val filteredSearchResult5 = richSkillEsRepo.byApiSearch( + ApiSearch( + filtered = ApiFilteredSearch( + categories = listOf("category1", "category2", "category3", "category4"), + keywords = listOf("keyword1"), + standards = listOf("standard1") + ) + ) + ) + + // Assert + assertThat(filteredSearchResult1.searchHits).hasSize(1) + assertThat(filteredSearchResult1.searchHits.first().content.uuid).isEqualTo(skill2.uuid) + assertThat(filteredSearchResult2.searchHits).hasSize(1) + assertThat(filteredSearchResult2.searchHits.first().content.uuid).isEqualTo(skill3.uuid) + assertThat(filteredSearchResult3.searchHits).hasSize(1) + assertThat(filteredSearchResult3.searchHits.first().content.uuid).isEqualTo(skill3.uuid) + assertThat(filteredSearchResult4.searchHits).isEmpty() + assertThat(filteredSearchResult5.searchHits).hasSize(4) + } @Test fun testFindSimilar() { diff --git a/docs/int/osmt-v2.x-openapi3.yaml b/docs/int/osmt-v2.x-openapi3.yaml index 3fa3f78cb..945d1cd82 100644 --- a/docs/int/osmt-v2.x-openapi3.yaml +++ b/docs/int/osmt-v2.x-openapi3.yaml @@ -139,6 +139,64 @@ paths: items: type: boolean + /api/skills/filter: + post: + tags: + - Skills + summary: Advanced filtered search for skills + description: Return list of skills that match the provided query + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Search' + parameters: + - in: query + name: size + description: number of skills to return per page + schema: + type: number + default: 50 + - in: query + name: from + description: zero-indexed offset from beginning of records + schema: + type: number + default: 0 + - in: query + name: status + schema: + default: + - Unpublished + - Published + type: array + items: + $ref: '#/components/schemas/PublishStatus' + - in: query + name: sort + schema: + $ref: '#/components/schemas/SortOrder' + responses: + '202': + description: Ok + headers: + Link: + $ref: '#/components/headers/Link' + X-Total-Count: + $ref: '#/components/headers/XTotalCount' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SkillDoc' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResult' /api/skills: get: @@ -1340,6 +1398,8 @@ components: type: string advanced: $ref: '#/components/schemas/AdvancedSearch' + filtered: + $ref: '#/components/schemas/ApiAdvancedFilteredSearch' uuids: type: array items: @@ -1400,6 +1460,46 @@ components: - skill.asc - skill.desc + ApiAdvancedFilteredSearch: + type: object + properties: + categories: + type: array + items: + type: string + keywords: + type: array + items: + type: string + standards: + type: array + items: + type: string + certifications: + type: array + items: + type: string + alignments: + type: array + items: + type: string + jobcodes: + type: array + items: + type: string + employers: + type: array + items: + type: string + authors: + type: array + items: + type: string + occupations: + type: array + items: + type: string + TaskResult: type: object properties: diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index d02fc5c15..42ddb2e1a 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -100,6 +100,7 @@ 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" +import {SharedModule} from "@shared/shared.module" export function initializeApp( appConfig: AppConfig, @@ -220,7 +221,8 @@ export function initializeApp( AppRoutingModule, HttpClientModule, ReactiveFormsModule, - CommonModule + CommonModule, + SharedModule ], providers: [ EnvironmentService, diff --git a/ui/src/app/collection/detail/manage-collection.component.html b/ui/src/app/collection/detail/manage-collection.component.html index 25d8ce053..cb051a084 100644 --- a/ui/src/app/collection/detail/manage-collection.component.html +++ b/ui/src/app/collection/detail/manage-collection.component.html @@ -47,8 +47,11 @@ 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 25a0fb105..e17e5f7f2 100644 --- a/ui/src/app/collection/detail/manage-collection.component.spec.ts +++ b/ui/src/app/collection/detail/manage-collection.component.spec.ts @@ -34,6 +34,7 @@ import {AuthService} from "../../auth/auth-service"; import * as FileSaver from "file-saver" import * as Auth from "../../auth/auth-roles" import {CollectionsLibraryComponent} from "../../table/collections-library.component" +import {FormControl, FormGroup} from "@angular/forms" @Component({ @@ -232,7 +233,6 @@ describe("ManageCollectionComponent", () => { while (!component.results) {} // Assert - expect(component.apiSearch).toBeFalsy() expect(component.from).toBeFalsy() expect(component.results).toBeTruthy() expect(result).toBeFalsy() diff --git a/ui/src/app/collection/detail/manage-collection.component.ts b/ui/src/app/collection/detail/manage-collection.component.ts index 09ba0c1c3..c3cabe73b 100644 --- a/ui/src/app/collection/detail/manage-collection.component.ts +++ b/ui/src/app/collection/detail/manage-collection.component.ts @@ -41,6 +41,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On unarchiveIcon = SvgHelper.path(SvgIcon.UNARCHIVE) addIcon = SvgHelper.path(SvgIcon.ADD) searchIcon = SvgHelper.path(SvgIcon.SEARCH) + showAdvancedFilteredSearch = true selectedFilters: Set = new Set([PublishStatus.Draft, PublishStatus.Published, PublishStatus.Archived]) @@ -100,7 +101,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On if (this.collection === undefined) { return } - + this.apiSearch = new ApiSearch({filtered: this.selectedKeywords, query: this.searchQuery}) this.resultsLoaded = this.collectionService.getCollectionSkills( this.collection.uuid, this.size, @@ -126,7 +127,7 @@ export class ManageCollectionComponent extends SkillsListComponent implements On } public get searchQuery(): string { - return this.searchForm.get("search")?.value.trim() ?? "" + return this.searchForm.get("search")?.value?.trim() ?? "" } clearSearch(): boolean { this.searchForm.reset() diff --git a/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts b/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts index 06d5af037..8b5868b3e 100644 --- a/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts +++ b/ui/src/app/form/form-field-search-select/mulit-select/form-field-search-multi-select.component.ts @@ -52,7 +52,7 @@ export class FormFieldSearchMultiSelectComponent extends AbstractFormFieldSearch this.emitCurrentSelection() } - private emitCurrentSelection(): void { + protected emitCurrentSelection(): void { this.currentSelection.emit(this.internalSelectedResults) } diff --git a/ui/src/app/models/filter-dropdown.model.ts b/ui/src/app/models/filter-dropdown.model.ts new file mode 100644 index 000000000..57b1f6353 --- /dev/null +++ b/ui/src/app/models/filter-dropdown.model.ts @@ -0,0 +1,13 @@ +import {ApiNamedReference} from "../richskill/ApiSkill" +import {ApiJobCode} from "../job-codes/Jobcode" + +export interface FilterDropdown { + categories: ApiNamedReference[] + keywords: ApiNamedReference[] + standards: ApiNamedReference[] + alignments: ApiNamedReference[] + certifications: ApiNamedReference[] + occupations: ApiJobCode[] + employers: ApiNamedReference[] + authors: ApiNamedReference[] +} 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 4bd92b184..c2c3a8d45 100644 --- a/ui/src/app/richskill/library/rich-skills-library.component.ts +++ b/ui/src/app/richskill/library/rich-skills-library.component.ts @@ -2,7 +2,7 @@ import {Component, OnInit} from "@angular/core" import {RichSkillService} from "../service/rich-skill.service" import {SkillsListComponent} from "../list/skills-list.component" import {ToastService} from "../../toast/toast.service" -import {PaginatedSkills} from "../service/rich-skill-search.service" +import {ApiSearch, PaginatedSkills} from "../service/rich-skill-search.service" import {Router} from "@angular/router" import {determineFilters} from "../../PublishStatus" import {Title} from "@angular/platform-browser" @@ -16,6 +16,7 @@ import {CollectionService} from "../../collection/service/collection.service" export class RichSkillsLibraryComponent extends SkillsListComponent implements OnInit { title = "RSD Library" + showAdvancedFilteredSearch = true constructor( protected router: Router, @@ -38,8 +39,10 @@ export class RichSkillsLibraryComponent extends SkillsListComponent implements O this.setResults(new PaginatedSkills([], 0)) return } - - this.resultsLoaded = this.richSkillService.getSkills(this.size, this.from, determineFilters(this.selectedFilters), this.columnSort) + const apiSearch = new ApiSearch({filtered: this.selectedKeywords}) + this.resultsLoaded = this.richSkillService.getSkillsFiltered( + this.size, this.from, apiSearch, this.selectedFilters, this.columnSort + ) this.resultsLoaded.subscribe((results) => { this.setResults(results) }) diff --git a/ui/src/app/richskill/list/skills-list.component.html b/ui/src/app/richskill/list/skills-list.component.html index b7a868aa3..6a98fd0c9 100644 --- a/ui/src/app/richskill/list/skills-list.component.html +++ b/ui/src/app/richskill/list/skills-list.component.html @@ -26,6 +26,9 @@

diff --git a/ui/src/app/richskill/list/skills-list.component.ts b/ui/src/app/richskill/list/skills-list.component.ts index cc5e1756f..8c1f1066f 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, ApiSkillListUpdate, 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"; @@ -17,6 +17,7 @@ import {ButtonAction} from "../../auth/auth-roles"; import {CollectionService} from "../../collection/service/collection.service" import {ApiCollection} from "../../collection/ApiCollection" import {CollectionPipe} from "../../pipes" +import {FilterDropdown} from "../../models/filter-dropdown.model" @Component({ selector: "app-skills-list", @@ -27,6 +28,7 @@ export class SkillsListComponent extends QuickLinksHelper { from = 0 size = 50 collection?: ApiCollection + showAdvancedFilteredSearch = false @ViewChild("titleHeading") titleElement!: ElementRef @ViewChild(TableActionBarComponent) tableActionBar!: TableActionBarComponent @@ -36,6 +38,16 @@ export class SkillsListComponent extends QuickLinksHelper { results: PaginatedSkills | undefined selectedFilters: Set = new Set([PublishStatus.Draft, PublishStatus.Published]) + keywords: FilterDropdown = { + categories: [], + certifications: [], + employers: [], + alignments: [], + keywords: [], + occupations: [], + standards: [], + authors: [] + } selectedSkills?: ApiSkillSummary[] skillsSaved?: Observable @@ -426,4 +438,21 @@ export class SkillsListComponent extends QuickLinksHelper { collectionOrWorkspace(includesMy: boolean): string { return new CollectionPipe().transform(this.collection?.status, includesMy) } + + keywordsChange(keywords: FilterDropdown): void { + this.keywords = keywords + this.loadNextPage() + } + + get selectedKeywords(): any { + const a: any = {} + const b: any = this.keywords + for (const key in this.keywords) { + if (b[key].length > 0) { + a[key] = b[key].map((i: any) => i.name ?? i.code) + } + } + return a + } + } diff --git a/ui/src/app/richskill/service/rich-skill-search.service.ts b/ui/src/app/richskill/service/rich-skill-search.service.ts index 1a9527c3c..2974050b6 100644 --- a/ui/src/app/richskill/service/rich-skill-search.service.ts +++ b/ui/src/app/richskill/service/rich-skill-search.service.ts @@ -1,21 +1,25 @@ import {INamedReference} from "../ApiSkill" import {ApiCollectionSummary, ApiSkillSummary} from "../ApiSkillSummary" +import {PublishStatus} from "../../PublishStatus" export interface ISearch { query?: string advanced?: ApiAdvancedSearch + filtered?: ApiAdvancedFilteredSearch uuids?: string[] } export class ApiSearch implements ISearch { query?: string advanced?: ApiAdvancedSearch + filtered?: ApiAdvancedFilteredSearch uuids?: string[] - constructor({query, advanced, uuids}: ISearch) { + constructor({query, advanced, uuids, filtered}: ISearch) { this.query = query this.advanced = advanced this.uuids = uuids + this.filtered = filtered } advancedMatchingQuery(): string[] { @@ -26,6 +30,19 @@ export class ApiSearch implements ISearch { } } +export interface ApiAdvancedFilteredSearch { + standards?: string[] + authors?: string[] + occupations?: string[] + certifications?: string[] + jobcodes?: string[] + categories?: string[] + employers?: string[] + keywords?: string[] + alignments?: string[] + statement?: string +} + export class ApiAdvancedSearch { skillName?: string collectionName?: string 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 575bd1e98..f3d1e7f73 100644 --- a/ui/src/app/richskill/service/rich-skill.service.spec.ts +++ b/ui/src/app/richskill/service/rich-skill.service.spec.ts @@ -76,17 +76,17 @@ describe("RichSkillService", () => { expect(testService).toBeTruthy() }) - it("getSkills should return", () => { + it("getSkills filter should return", () => { // Arrange RouterData.commands = [] AuthServiceData.isDown = false - const path = "api/skills?sort=name.asc&status=draft&size=3&from=0" + const path = "api/skills/filter?sort=name.asc&status=draft&size=3&from=0" const testData: PaginatedSkills = createMockPaginatedSkills(3, 10) const statuses = new Set([ PublishStatus.Draft ]) // Act // noinspection LocalVariableNamingConventionJS - const result$ = testService.getSkills(testData.skills.length, 0, statuses, ApiSortOrder.NameAsc) + const result$ = testService.getSkillsFiltered(testData.skills.length, 0, new ApiSearch({filtered: {}}), statuses, ApiSortOrder.NameAsc) // Assert result$ @@ -97,7 +97,7 @@ describe("RichSkillService", () => { }) const req = httpTestingController.expectOne(AppConfig.settings.baseApiUrl + "/" + path) - expect(req.request.method).toEqual("GET") + expect(req.request.method).toEqual("POST") req.flush(testData.skills, { headers: { "x-total-count": "" + testData.totalCount} }) diff --git a/ui/src/app/richskill/service/rich-skill.service.ts b/ui/src/app/richskill/service/rich-skill.service.ts index 1f43ab985..33c378e16 100644 --- a/ui/src/app/richskill/service/rich-skill.service.ts +++ b/ui/src/app/richskill/service/rich-skill.service.ts @@ -1,5 +1,5 @@ import {Injectable} from "@angular/core" -import {HttpClient, HttpHeaders, HttpResponse} from "@angular/common/http" +import {HttpClient, HttpHeaders, HttpParams, HttpResponse} from "@angular/common/http" import {Observable, of, throwError} from "rxjs" import {ApiAuditLog, ApiSkill, ApiSortOrder, IAuditLog, ISkill} from "../ApiSkill" import {delay, map, retryWhen, share, switchMap} from "rxjs/operators" @@ -26,17 +26,19 @@ export class RichSkillService extends AbstractService { private serviceUrl = "api/skills" - getSkills( + getSkillsFiltered( size: number = 50, from: number = 0, + apiSearch: ApiSearch, filterByStatuses: Set | undefined, sort: ApiSortOrder | undefined, ): Observable { const params = this.buildTableParams(size, from, filterByStatuses, sort) - return this.get({ - path: `${this.serviceUrl}`, + return this.post({ + path: `${this.serviceUrl}/filter`, params, + body: apiSearch }) .pipe(share()) .pipe(map(({body, headers}) => { diff --git a/ui/src/app/search/advanced-search/advanced-search.component.spec.ts b/ui/src/app/search/advanced-search/advanced-search.component.spec.ts index 2c0e124e1..d143a4488 100644 --- a/ui/src/app/search/advanced-search/advanced-search.component.spec.ts +++ b/ui/src/app/search/advanced-search/advanced-search.component.spec.ts @@ -82,7 +82,7 @@ describe("AdvancedSearchComponent", () => { it("handleSearchSkills should return", () => { // Arrange - SearchServiceData.latestSearch = new ApiSearch({}) // Clear previous search + SearchServiceData.latestSearch = new ApiSearch({filtered: {}}) // Clear previous search const { name, author, @@ -111,11 +111,12 @@ describe("AdvancedSearchComponent", () => { alignments: prepareNamedReferences(alignments), collectionName } - const expected = new ApiSearch({ advanced }) + const expected = new ApiSearch({ advanced, filtered: {} }) // Act component.handleSearchSkills() - + console.log(SearchServiceData.latestSearch) + console.log(expected) // Assert expect(SearchServiceData.latestSearch).toEqual(expected) }) diff --git a/ui/src/app/search/rich-skill-search-results.component.spec.ts b/ui/src/app/search/rich-skill-search-results.component.spec.ts index 4b1a8ef0c..84f50f41b 100644 --- a/ui/src/app/search/rich-skill-search-results.component.spec.ts +++ b/ui/src/app/search/rich-skill-search-results.component.spec.ts @@ -130,7 +130,7 @@ describe("RichSkillSearchResultsComponent", () => { const advanced = new ApiAdvancedSearch() advanced.skillName = "test skill" advanced.author = "test author" - const apiSearch = new ApiSearch({ advanced }) + const apiSearch = new ApiSearch({ advanced, filtered: {} }) component.matchingQuery = undefined const expected = [ "test skill", "test author" ]; 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 07bd66f31..b6adec816 100644 --- a/ui/src/app/search/rich-skill-search-results.component.ts +++ b/ui/src/app/search/rich-skill-search-results.component.ts @@ -23,6 +23,7 @@ import {CollectionService} from "../collection/service/collection.service" export class RichSkillSearchResultsComponent extends SkillsListComponent implements OnInit { apiSearch: ApiSearch | undefined + showAdvancedFilteredSearch = true title = "Search Results" @@ -81,7 +82,14 @@ export class RichSkillSearchResultsComponent extends SkillsListComponent impleme } if (this.apiSearch !== undefined) { - this.resultsLoaded = this.richSkillService.searchSkills(this.apiSearch, this.size, this.from, determineFilters(this.selectedFilters), this.columnSort) + this.apiSearch.filtered = this.selectedKeywords + this.resultsLoaded = this.richSkillService.getSkillsFiltered( + this.size, + this.from, + this.apiSearch, + determineFilters(this.selectedFilters), + this.columnSort + ) this.resultsLoaded.subscribe(results => this.setResults(results)) } } diff --git a/ui/src/app/shared/filter-chips/filter-chips.component.html b/ui/src/app/shared/filter-chips/filter-chips.component.html new file mode 100644 index 000000000..32829ec5e --- /dev/null +++ b/ui/src/app/shared/filter-chips/filter-chips.component.html @@ -0,0 +1,7 @@ +
+
{{name | titlecase}}
+
+ {{resultName(keyword)}} + × +
+
diff --git a/ui/src/app/shared/filter-chips/filter-chips.component.scss b/ui/src/app/shared/filter-chips/filter-chips.component.scss new file mode 100644 index 000000000..b1825be00 --- /dev/null +++ b/ui/src/app/shared/filter-chips/filter-chips.component.scss @@ -0,0 +1,44 @@ +.header { + font-size: 14px; +} + +.chip-wrapper { + display: flex; + flex-wrap: wrap; + border: solid gray; + padding: 10px 5px 5px 5px; + min-width: 120px; + width: 100%; +} + +.chip-wrapper .header { + position:absolute; + margin-top:-25px; + margin-left:10px; + color:white; + background: #1f1f1f; + padding:2px 10px; +} + +.chip { + width: fit-content; + padding: 0 10px; + margin: 5px 1px; + height: fit-content; + font-size: 12px; + border-radius: 25px; + background-color: #333333; +} + +.close-button { + padding-left: 10px; + color: #888; + font-weight: bold; + float: right; + font-size: 20px; + cursor: pointer; +} + +.close-button:hover { + color: #000; +} diff --git a/ui/src/app/shared/filter-chips/filter-chips.component.spec.ts b/ui/src/app/shared/filter-chips/filter-chips.component.spec.ts new file mode 100644 index 000000000..7cac4ed2a --- /dev/null +++ b/ui/src/app/shared/filter-chips/filter-chips.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing" + +import { FilterChipsComponent } from "./filter-chips.component" +import {FormControl} from "@angular/forms" +import {ApiNamedReference} from "../../richskill/ApiSkill" + +describe("FilterChipsComponent", () => { + let component: FilterChipsComponent + let fixture: ComponentFixture + const apiNameReferenced1 = new ApiNamedReference({id: "1", name: "value1"}) + const apiNameReferenced2 = new ApiNamedReference({id: "2", name: "value2"}) + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FilterChipsComponent ] + }) + .compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FilterChipsComponent) + component = fixture.componentInstance + component.control = new FormControl([]) + fixture.detectChanges() + }) + + it("should create", () => { + expect(component).toBeTruthy() + }) + + it("remove chip should remove and element from chips", () => { + component.control?.patchValue([ + apiNameReferenced1, + apiNameReferenced2 + ]) + component.onRemoveChip(apiNameReferenced1) + expect(component.control?.value.length).toBe(1) + }) +}) diff --git a/ui/src/app/shared/filter-chips/filter-chips.component.ts b/ui/src/app/shared/filter-chips/filter-chips.component.ts new file mode 100644 index 000000000..86f8a4427 --- /dev/null +++ b/ui/src/app/shared/filter-chips/filter-chips.component.ts @@ -0,0 +1,31 @@ +import {Component, EventEmitter, Input, Output} from "@angular/core" +import {FormControl} from "@angular/forms" +import {ApiJobCode} from "../../job-codes/Jobcode" +import {ApiNamedReference} from "../../richskill/ApiSkill" +import {FilterSearchComponent} from "@shared/filter-search/filter-search.component" + +@Component({ + selector: "app-filter-chips", + templateUrl: "./filter-chips.component.html", + styleUrls: ["./filter-chips.component.scss"] +}) +export class FilterChipsComponent extends FilterSearchComponent { + + @Input() + name?: string + @Input() + control?: FormControl + @Output() + remove: EventEmitter = new EventEmitter() + + onRemoveChip(chip: ApiJobCode | ApiNamedReference): void { + const values = this.control?.value + const index = this.control?.value?.findIndex((i: ApiJobCode | ApiNamedReference) => this.areResultsEqual(chip, i)) ?? 0 + if (index >= 0) { + values.splice(index, 1) + this.control?.patchValue(values) + this.remove.emit(true) + } + } + +} diff --git a/ui/src/app/shared/filter-drop-down/filter-dropdown.component.html b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.html new file mode 100644 index 000000000..ba72e1712 --- /dev/null +++ b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.html @@ -0,0 +1,67 @@ + diff --git a/ui/src/app/shared/filter-drop-down/filter-dropdown.component.scss b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.scss new file mode 100644 index 000000000..eef1b958c --- /dev/null +++ b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.scss @@ -0,0 +1,65 @@ +.apply-filter-container { + display: flex; + justify-content: center; +} + +.apply-filter-button { + background: white; + color: #002F50; +} + +.apply-filter-button:hover { + color: white; + background: #002F50; +} + +.apply-filter-button:focus { + color: white; + background: #002F50; +} + +/**drop down**/ +.drop-button { + display: flex; + color: white; + font-size: 16px; + border: none; +} + +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-content { + margin-top: -300px; + margin-left: -200px; + padding: 20px; + background: #333333; + display: none; + position: absolute; + min-width: 400px; + z-index: 1; +} + +.dropdown .dropdown-content.dropdown-content-visible { + display: block; + margin-bottom: 30px; +} + +hr { + height: 1px; + background: black; + margin-bottom: 20px; +} + +h3 { + font-size: 30px; + text-align: center; + margin-bottom: 0; +} + +.close-container { + display: flex; + justify-content: end; +} diff --git a/ui/src/app/shared/filter-drop-down/filter-dropdown.component.spec.ts b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.spec.ts new file mode 100644 index 000000000..0ef03a50c --- /dev/null +++ b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing" + +import { FilterDropdownComponent } from "./filter-dropdown.component" +import {FilterControlsComponent} from "../../table/filter-controls/filter-controls.component" +import {ReactiveFormsModule} from "@angular/forms" + +describe("FilterComponent", () => { + let component: FilterDropdownComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + FilterDropdownComponent, + FilterControlsComponent + ], + imports: [ + ReactiveFormsModule + ] + }) + .compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FilterDropdownComponent) + component = fixture.componentInstance + component.filterFg = TestBed.createComponent(FilterControlsComponent).componentInstance["configureFilterFg"]() + fixture.detectChanges() + }) + + it("should create", () => { + expect(component).toBeTruthy() + }) + + it("showInput should change", () => { + const showInput = component.showInputs + component.onApplyFilter() + expect(component.showInputs !== showInput).toBeTrue() + }) + + it("on apply filter should call emit", () => { + const spy = spyOn(component.applyFilter, "emit") + component.onApplyFilter() + expect(spy).toHaveBeenCalled() + }) +}) diff --git a/ui/src/app/shared/filter-drop-down/filter-dropdown.component.ts b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.ts new file mode 100644 index 000000000..657e9cae7 --- /dev/null +++ b/ui/src/app/shared/filter-drop-down/filter-dropdown.component.ts @@ -0,0 +1,32 @@ +import {Component, EventEmitter, HostListener, Input, OnInit, Output} from "@angular/core" +import { KeywordType } from "../../richskill/ApiSkill" +import { FormBuilder, FormGroup } from "@angular/forms" +import {FilterDropdown} from "../../models/filter-dropdown.model" +import {SvgHelper, SvgIcon} from "../../core/SvgHelper" + +@Component({ + selector: "app-filter-dropdown", + templateUrl: "./filter-dropdown.component.html", + styleUrls: ["./filter-dropdown.component.scss"] +}) +export class FilterDropdownComponent { + + @Output() + applyFilter = new EventEmitter() + showInputs = false + @Input() + filterFg?: FormGroup + keywordType = KeywordType + iconDismiss = SvgHelper.path(SvgIcon.DISMISS) + + @HostListener("document:keydown.escape", ["$event"]) + onKeydownHandler(event: KeyboardEvent): void { + this.showInputs = false + } + + onApplyFilter(): void { + this.showInputs = !this.showInputs + this.applyFilter.emit(this.filterFg?.value) + } + +} diff --git a/ui/src/app/shared/filter-search/filter-search.component.spec.ts b/ui/src/app/shared/filter-search/filter-search.component.spec.ts new file mode 100644 index 000000000..bdc74836b --- /dev/null +++ b/ui/src/app/shared/filter-search/filter-search.component.spec.ts @@ -0,0 +1,31 @@ +import { FilterSearchComponent } from "./filter-search.component" +import {ApiNamedReference} from "../../richskill/ApiSkill" +import {ApiJobCode} from "../../job-codes/Jobcode" + +describe("FilterSearchComponent", () => { + + const apiNameReferenced1 = new ApiNamedReference({id: "1", name: "value1"}) + const apiNameReferenced2 = new ApiNamedReference({id: "2", name: "value2"}) + const filterSearch = new FilterSearchComponent() + + + it("are results equals should be true", () => { + const areEqual = filterSearch.areResultsEqual(apiNameReferenced1, apiNameReferenced1) + expect(areEqual).toBeTrue() + }) + + it("are results equals should be false", () => { + const areEqual = new FilterSearchComponent().areResultsEqual(apiNameReferenced1, apiNameReferenced2) + expect(areEqual).toBeFalse() + }) + + it("name should be correct for api named reference", () => { + const name = filterSearch.resultName(apiNameReferenced1) + expect(name).toEqual("value1") + }) + + it("name should be correct for api job code", () => { + const name = filterSearch.resultName(new ApiJobCode({code: "code1", targetNodeName: "value1"})) + expect(name).toEqual("code1 value1") + }) +}) diff --git a/ui/src/app/shared/filter-search/filter-search.component.ts b/ui/src/app/shared/filter-search/filter-search.component.ts new file mode 100644 index 000000000..83a8a36a8 --- /dev/null +++ b/ui/src/app/shared/filter-search/filter-search.component.ts @@ -0,0 +1,20 @@ +import {ApiJobCode} from "../../job-codes/Jobcode" +import {ApiNamedReference} from "../../richskill/ApiSkill" + + +export class FilterSearchComponent { + + areResultsEqual(i: ApiJobCode | ApiNamedReference, result: ApiJobCode | ApiNamedReference): boolean { + if (i instanceof ApiNamedReference && result instanceof ApiNamedReference) { + return i.name === result.name + } else if (i instanceof ApiJobCode && result instanceof ApiJobCode) { + return i.code === result.code + } + return false + } + + resultName(result: any): string { + return result.name ?? result.code + " " + result.targetNodeName + } + +} diff --git a/ui/src/app/shared/index.ts b/ui/src/app/shared/index.ts new file mode 100644 index 000000000..69ab6847f --- /dev/null +++ b/ui/src/app/shared/index.ts @@ -0,0 +1,3 @@ +export * from "./filter-chips/filter-chips.component" +export * from "./filter-drop-down/filter-dropdown.component" +export * from "./search-multi-select/search-multi-select.component" diff --git a/ui/src/app/shared/search-multi-select/search-multi-select.component.html b/ui/src/app/shared/search-multi-select/search-multi-select.component.html new file mode 100644 index 000000000..87d0c85e9 --- /dev/null +++ b/ui/src/app/shared/search-multi-select/search-multi-select.component.html @@ -0,0 +1,96 @@ +
+ {{name | titlecase}} + +
+
+
+
+ + +
+
+
+

+ Loading… + +

+
+
+
+ + + +
+
+ + +
+
Showing search results:
+
+ + {{resultName(result)}} + + +
+
+ + + +
+

+ No Results +

+
+
+
+
+ +
+
+ {{resultName(result)}} + +
+ +
+
+
+
+
diff --git a/ui/src/app/shared/search-multi-select/search-multi-select.component.scss b/ui/src/app/shared/search-multi-select/search-multi-select.component.scss new file mode 100644 index 000000000..0dd59777f --- /dev/null +++ b/ui/src/app/shared/search-multi-select/search-multi-select.component.scss @@ -0,0 +1,83 @@ +.expand-panel { + margin-top: 40px; + cursor: pointer; + display: flex; + justify-content: space-between; +} + +input[type=checkbox] { + margin-right: 20px; + position: relative; + cursor: pointer; +} + +input[type=checkbox]:before { + content: ""; + display: block; + position: absolute; + width: 15px; + height: 15px; + top: 0; + left: 0; + background-color:#e9e9e9; +} + +input[type=checkbox]:checked:before { + content: ""; + display: block; + position: absolute; + width: 20px; + height: 20px; + top: 0; + left: 0; + background-color:#1E80EF; +} + +input[type=checkbox]:checked:after { + content: ""; + display: block; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + position: absolute; + top: 2px; + left: 6px; +} + +input::placeholder { + font-size: 14px; +} + +.result-text { + font-size: 14px; +} + +.result-container { + display: flex; + justify-content: space-between; +} + +.result-container:nth-child(odd) { + background: #434647; +} + +.m-field { + display: none; +} + +.visible { + display: block; +} + +hr { + background: white; + border: solid 1px; +} + +strong { + font-size: 14px; +} diff --git a/ui/src/app/shared/search-multi-select/search-multi-select.component.spec.ts b/ui/src/app/shared/search-multi-select/search-multi-select.component.spec.ts new file mode 100644 index 000000000..dc238e922 --- /dev/null +++ b/ui/src/app/shared/search-multi-select/search-multi-select.component.spec.ts @@ -0,0 +1,96 @@ +import {ComponentFixture, TestBed} from "@angular/core/testing" + +import {SearchMultiSelectComponent} from "./search-multi-select.component" +import {KeywordSearchService} from "../../richskill/service/keyword-search.service" +import {AuthService} from "../../auth/auth-service" +import {AuthServiceStub} from "../../../../test/resource/mock-stubs" +import {RouterTestingModule} from "@angular/router/testing" +import {FormControl, ReactiveFormsModule} from "@angular/forms" +import {AppConfig} from "../../app.config" +import {createMockApiNamedReference, createMockJobcode} from "../../../../test/resource/mock-data" +import {of} from "rxjs" +import {ApiNamedReference, KeywordType} from "../../richskill/ApiSkill" +import {HttpClientTestingModule} from "@angular/common/http/testing" + +describe("SearchMultiSelectComponent", () => { + let component: SearchMultiSelectComponent + let fixture: ComponentFixture + const apiNameReferenced1 = new ApiNamedReference({id: "1", name: "value1"}) + const apiNameReferenced2 = new ApiNamedReference({id: "2", name: "value2"}) + const apiNameReferenced3 = new ApiNamedReference({id: "3", name: "value3"}) + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [SearchMultiSelectComponent], + providers: [ + AppConfig, + KeywordSearchService, + {provide: AuthService, useClass: AuthServiceStub}, + ], + imports: [ + HttpClientTestingModule, + RouterTestingModule, + ReactiveFormsModule + ], + }).compileComponents() + + const appConfig = TestBed.inject(AppConfig) + AppConfig.settings = appConfig.defaultConfig() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(SearchMultiSelectComponent) + component = fixture.componentInstance + component.control = new FormControl("") + fixture.detectChanges() + }) + + it("should create", () => { + expect(component).toBeTruthy() + }) + + it("clear field should clean input control", () => { + component.inputFc.patchValue("value") + component.clearField() + expect(component.inputFc.value.length).toBe(0) + }) + + it("is result selected should be true", () => { + component.control?.patchValue([apiNameReferenced1, apiNameReferenced2]) + const isSelected = component.isResultSelected(apiNameReferenced1) + expect(isSelected).toBeTrue() + }) + + it("is result selected should be false", () => { + component.control?.patchValue([apiNameReferenced1, apiNameReferenced2]) + const isSelected = component.isResultSelected(apiNameReferenced3) + expect(isSelected).toBeFalse() + }) + + it("getKeywords should call search keywords", () => { + const service = TestBed.inject(KeywordSearchService) + const spy = spyOn(service, "searchKeywords").and.returnValue(of([createMockApiNamedReference("1", "value")])) + component.keywordType = KeywordType.Keyword + component["getKeywords"]("value") + expect(spy).toHaveBeenCalled() + }) + + it("getKeywords should call search job codes", () => { + const service = TestBed.inject(KeywordSearchService) + const spy = spyOn(service, "searchJobcodes").and.returnValue(of([createMockJobcode()])) + component["getKeywords"]("value") + expect(spy).toHaveBeenCalled() + }) + + it("select result should add value in internal result", () => { + component.control?.patchValue([apiNameReferenced1]) + component.selectResult(apiNameReferenced2) + expect(component.control?.value?.length).toBe(2) + }) + + it("select result should remove value in internal result", () => { + component.control?.patchValue([apiNameReferenced1, apiNameReferenced2]) + component.selectResult(apiNameReferenced1) + expect(component.control?.value?.length).toBe(1) + }) +}) diff --git a/ui/src/app/shared/search-multi-select/search-multi-select.component.ts b/ui/src/app/shared/search-multi-select/search-multi-select.component.ts new file mode 100644 index 000000000..962488471 --- /dev/null +++ b/ui/src/app/shared/search-multi-select/search-multi-select.component.ts @@ -0,0 +1,69 @@ +import {Component, Input, OnInit} from "@angular/core" +import {KeywordSearchService} from "../../richskill/service/keyword-search.service" +import {SvgHelper, SvgIcon} from "../../core/SvgHelper" +import {FormControl} from "@angular/forms" +import {ApiNamedReference, KeywordType} from "../../richskill/ApiSkill" +import {ApiJobCode} from "../../job-codes/Jobcode" +import {FilterSearchComponent} from "@shared/filter-search/filter-search.component" + +@Component({ + selector: "app-search-multi-select", + templateUrl: "./search-multi-select.component.html", + styleUrls: ["./search-multi-select.component.scss"] +}) +export class SearchMultiSelectComponent extends FilterSearchComponent implements OnInit { + + @Input() + name?: string + showInput = false + iconSearch = SvgHelper.path(SvgIcon.SEARCH) + inputFc = new FormControl("") + results!: ApiNamedReference[] | ApiJobCode[] | undefined + @Input() + keywordType?: KeywordType + @Input() + control?: FormControl + currentlyLoading = false + iconDismiss = SvgHelper.path(SvgIcon.DISMISS) + + constructor(protected searchService: KeywordSearchService) { + super() + } + + ngOnInit(): void { + this.getKeywords("") + this.inputFc.valueChanges.subscribe(value => this.getKeywords(value ?? "")) + } + + selectResult(result: ApiJobCode | ApiNamedReference): void { + const isResultSelected = this.isResultSelected(result) + if (!isResultSelected) { + this.control?.value.push(result) + } else { + const newValues = this.control?.value.filter((r: ApiJobCode | ApiNamedReference) => !this.areResultsEqual(r, result)) + this.control?.patchValue(newValues) + } + } + + private getKeywords(text: string): void { + this.currentlyLoading = true + this.keywordType ? this.searchService.searchKeywords(this.keywordType, text) + .subscribe(searchResults => { + this.results = searchResults.filter(r => !!r && !!r.name) + this.currentlyLoading = false + }) : this.searchService.searchJobcodes(text) + .subscribe(searchResults => { + this.results = searchResults.filter(r => !!r && !!r.code && !!r.targetNodeName) + this.currentlyLoading = false + }) + } + + isResultSelected(result: ApiJobCode | ApiNamedReference): boolean { + return this.control?.value.some((i: ApiJobCode | ApiNamedReference) => this.areResultsEqual(i, result)) + } + + clearField(): void { + this.inputFc.patchValue("") + } + +} diff --git a/ui/src/app/shared/shared.module.ts b/ui/src/app/shared/shared.module.ts new file mode 100644 index 000000000..1b4bb4368 --- /dev/null +++ b/ui/src/app/shared/shared.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from "@angular/core" +import { CommonModule } from "@angular/common" +import { FormsModule, ReactiveFormsModule } from "@angular/forms" +import { + FilterChipsComponent, + FilterDropdownComponent, + SearchMultiSelectComponent +} from "@shared/." + +@NgModule({ + declarations: [ + FilterDropdownComponent, + SearchMultiSelectComponent, + FilterChipsComponent + ], + exports: [ + FilterDropdownComponent, + SearchMultiSelectComponent, + FilterChipsComponent + ], + imports: [ + CommonModule, + ReactiveFormsModule, + FormsModule + ] +}) +export class SharedModule { } diff --git a/ui/src/app/table/filter-controls/filter-controls.component.html b/ui/src/app/table/filter-controls/filter-controls.component.html index 19c5a807a..3b89a626d 100644 --- a/ui/src/app/table/filter-controls/filter-controls.component.html +++ b/ui/src/app/table/filter-controls/filter-controls.component.html @@ -1,23 +1,78 @@

Filters

-
- - - +
+
+ + + +
+
+ + +
- +
+
+ + + + + + + + + + + + + + + +
diff --git a/ui/src/app/table/filter-controls/filter-controls.component.scss b/ui/src/app/table/filter-controls/filter-controls.component.scss new file mode 100644 index 000000000..d22916ed9 --- /dev/null +++ b/ui/src/app/table/filter-controls/filter-controls.component.scss @@ -0,0 +1,19 @@ +.filter-section { + display: flex; + justify-content: space-between; +} + +app-filter-chips { + display: block; + width: fit-content; + margin-right: 20px; + margin-top: 20px; +} + + +.chips-container { + display: flex; + flex-wrap: wrap; + margin-bottom: 20px; + // flex-direction: column; +} diff --git a/ui/src/app/table/filter-controls/filter-controls.component.spec.ts b/ui/src/app/table/filter-controls/filter-controls.component.spec.ts new file mode 100644 index 000000000..b27658d81 --- /dev/null +++ b/ui/src/app/table/filter-controls/filter-controls.component.spec.ts @@ -0,0 +1,33 @@ +import {FilterControlsComponent} from "./filter-controls.component" +import {ComponentFixture, TestBed} from "@angular/core/testing" +import {FormBuilder} from "@angular/forms" + +describe("FilterControlsComponent", () => { + let component: FilterControlsComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FilterControlsComponent], + imports: [], + providers: [ + FormBuilder + ] + }).compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(FilterControlsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it("configure filter should works", () => { + component["configureFilterFg"]() + const value = component.filterFg.value + const properties = ["categories", "keywords", "standards", "certifications", "occupations", "employers"] + expect(value).toBeTruthy() + expect(properties.every(p => p in value)).toBeTrue() + }) + +}) diff --git a/ui/src/app/table/filter-controls/filter-controls.component.ts b/ui/src/app/table/filter-controls/filter-controls.component.ts index eff952e35..4e6dab6f2 100644 --- a/ui/src/app/table/filter-controls/filter-controls.component.ts +++ b/ui/src/app/table/filter-controls/filter-controls.component.ts @@ -1,20 +1,36 @@ -import {Component, EventEmitter, Input, OnInit, Output} from "@angular/core" +import {Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges} from "@angular/core" import {PublishStatus} from "../../PublishStatus"; +import {FilterDropdown} from "../../models/filter-dropdown.model" +import {FormBuilder, FormGroup} from "@angular/forms" @Component({ selector: "app-filter-controls", - templateUrl: "./filter-controls.component.html" + templateUrl: "./filter-controls.component.html", + styleUrls: ["./filter-controls.component.scss"] }) -export class FilterControlsComponent implements OnInit { +export class FilterControlsComponent implements OnInit, OnChanges { @Input() selectedFilters: Set = new Set() + @Output() keywordsChanged: EventEmitter = new EventEmitter() @Output() filtersChanged: EventEmitter> = new EventEmitter>() + @Input() + keywords?: FilterDropdown + filterFg: FormGroup + @Input() + showAdvancedFilteredSearch?: boolean - constructor() { + constructor( + protected formBuilder: FormBuilder + ) { + this.filterFg = this.configureFilterFg() } ngOnInit(): void { } + ngOnChanges(changes: SimpleChanges): void { + this.filterFg.patchValue(this.keywords ?? {}) + } + onFilterChange(status: PublishStatus, isChecked: boolean): void { if (isChecked) { this.selectedFilters.add(status) @@ -39,4 +55,23 @@ export class FilterControlsComponent implements OnInit { isStatusChecked(status: PublishStatus): boolean | undefined { return this.selectedFilters.has(status) ? true : undefined } + + applyFilter(event: FilterDropdown): void { + this.keywords = event + this.keywordsChanged.emit(this.keywords) + } + + private configureFilterFg(): FormGroup { + return this.formBuilder.group({ + categories: [], + keywords: [], + standards: [], + alignments: [], + certifications: [], + occupations: [], + employers: [], + authors: [] + }) + } + } diff --git a/ui/src/assets/images/down-arrow.svg b/ui/src/assets/images/down-arrow.svg new file mode 100644 index 000000000..556856d2f --- /dev/null +++ b/ui/src/assets/images/down-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/src/assets/images/up-arrow.svg b/ui/src/assets/images/up-arrow.svg new file mode 100644 index 000000000..1b7236796 --- /dev/null +++ b/ui/src/assets/images/up-arrow.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/ui/test/resource/mock-stubs.ts b/ui/test/resource/mock-stubs.ts index b0c05d835..f2a04ad2d 100644 --- a/ui/test/resource/mock-stubs.ts +++ b/ui/test/resource/mock-stubs.ts @@ -109,8 +109,8 @@ export class SearchServiceStub { // tslint:disable-next-line:variable-name advancedSkillSearch(_advanced: ApiAdvancedSearch): void { const advanced = _advanced as ApiAdvancedSearch - SearchServiceData.latestSearch = new ApiSearch({ advanced }) - this.setLatestSearch(new ApiSearch({advanced})) + SearchServiceData.latestSearch = new ApiSearch({ advanced, filtered: {} }) + this.setLatestSearch(new ApiSearch({advanced, filtered: {}})) } simpleCollectionSearch(query: string): void { @@ -381,6 +381,16 @@ export class RichSkillServiceStub { getResultExportedLibrary(): Observable { return of("") } + + getSkillsFiltered( + size: number = 50, + from: number = 0, + apiSearch: ApiSearch, + filterByStatuses: Set | undefined, + sort: ApiSortOrder | undefined, + ): Observable { + return of(createMockPaginatedSkills()) + } } export let KeywordSearchServiceData = { diff --git a/ui/tsconfig.base.json b/ui/tsconfig.base.json index d06a13c13..9eefb45c5 100644 --- a/ui/tsconfig.base.json +++ b/ui/tsconfig.base.json @@ -14,6 +14,11 @@ "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, + "paths": { + "@shared/*": [ + "src/app/shared/*" + ] + }, "target": "es2015", "module": "es2020", "lib": [