Basic implementation of advanced search
This commit is contained in:
parent
f382a0ed48
commit
9e15e6bcc0
@ -0,0 +1,391 @@
|
|||||||
|
package com.nisemoe.nise.controller
|
||||||
|
|
||||||
|
import com.nisemoe.generated.tables.references.BEATMAPS
|
||||||
|
import com.nisemoe.generated.tables.references.SCORES
|
||||||
|
import com.nisemoe.generated.tables.references.USERS
|
||||||
|
import com.nisemoe.nise.Format
|
||||||
|
import org.jooq.Condition
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.jooq.Field
|
||||||
|
import org.jooq.OrderField
|
||||||
|
import org.jooq.impl.DSL
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class SearchController(
|
||||||
|
private val dslContext: DSLContext
|
||||||
|
) {
|
||||||
|
|
||||||
|
data class SearchResponse(
|
||||||
|
val scores: List<SearchResponseEntry>,
|
||||||
|
|
||||||
|
val totalResults: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchResponseEntry(
|
||||||
|
// User fields
|
||||||
|
val user_id: Long?,
|
||||||
|
val user_username: String?,
|
||||||
|
val user_join_date: String?,
|
||||||
|
val user_country: String?,
|
||||||
|
val user_country_rank: Long?,
|
||||||
|
val user_rank: Long?,
|
||||||
|
val user_pp_raw: Double?,
|
||||||
|
val user_accuracy: Double?,
|
||||||
|
val user_playcount: Long?,
|
||||||
|
val user_total_score: Long?,
|
||||||
|
val user_ranked_score: Long?,
|
||||||
|
val user_seconds_played: Long?,
|
||||||
|
val user_count_300: Long?,
|
||||||
|
val user_count_100: Long?,
|
||||||
|
val user_count_50: Long?,
|
||||||
|
val user_count_miss: Int?,
|
||||||
|
|
||||||
|
// Score fields
|
||||||
|
val id: Int?,
|
||||||
|
val beatmap_id: Int?,
|
||||||
|
val count_300: Int?,
|
||||||
|
val count_100: Int?,
|
||||||
|
val count_50: Int?,
|
||||||
|
val count_miss: Int?,
|
||||||
|
val date: String?,
|
||||||
|
val max_combo: Int?,
|
||||||
|
val mods: Int?,
|
||||||
|
val perfect: Boolean?,
|
||||||
|
val pp: Double?,
|
||||||
|
val rank: String?,
|
||||||
|
val replay_id: Long?,
|
||||||
|
val score: Long?,
|
||||||
|
val ur: Double?,
|
||||||
|
val frametime: Double?,
|
||||||
|
val edge_hits: Int?,
|
||||||
|
val snaps: Int?,
|
||||||
|
val adjusted_ur: Double?,
|
||||||
|
val mean_error: Double?,
|
||||||
|
val error_variance: Double?,
|
||||||
|
val error_standard_deviation: Double?,
|
||||||
|
val minimum_error: Double?,
|
||||||
|
val maximum_error: Double?,
|
||||||
|
val error_range: Double?,
|
||||||
|
val error_coefficient_of_variation: Double?,
|
||||||
|
val error_kurtosis: Double?,
|
||||||
|
val error_skewness: Double?,
|
||||||
|
val keypresses_median_adjusted: Double?,
|
||||||
|
val keypresses_standard_deviation_adjusted: Double?,
|
||||||
|
val sliderend_release_median_adjusted: Double?,
|
||||||
|
val sliderend_release_standard_deviation_adjusted: Double?,
|
||||||
|
|
||||||
|
// Beatmap fields
|
||||||
|
val beatmap_artist: String?,
|
||||||
|
val beatmap_beatmapset_id: Int?,
|
||||||
|
val beatmap_creator: String?,
|
||||||
|
val beatmap_source: String?,
|
||||||
|
val beatmap_star_rating: Double?,
|
||||||
|
val beatmap_title: String?,
|
||||||
|
val beatmap_version: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchRequest(
|
||||||
|
val queries: List<SearchQuery>,
|
||||||
|
val sorting: SearchSorting
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchSorting(
|
||||||
|
val field: String,
|
||||||
|
val order: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchQuery(
|
||||||
|
val logicalOperator: String,
|
||||||
|
val predicates: List<SearchPredicate>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchPredicate(
|
||||||
|
val field: SearchField,
|
||||||
|
val operator: String,
|
||||||
|
val value: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchField(
|
||||||
|
val name: String,
|
||||||
|
val type: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostMapping("search")
|
||||||
|
fun doSearch(
|
||||||
|
@RequestBody request: SearchRequest,
|
||||||
|
@RequestHeader("X-NISE-API") apiVersion: String
|
||||||
|
): ResponseEntity<SearchResponse> {
|
||||||
|
if (apiVersion.isBlank())
|
||||||
|
return ResponseEntity.badRequest().build()
|
||||||
|
|
||||||
|
var baseQuery = DSL.noCondition()
|
||||||
|
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
|
||||||
|
var condition = buildCondition(query.predicates[0]) // Start with the first predicate
|
||||||
|
for (i in 1 until query.predicates.size) {
|
||||||
|
val nextPredicate = query.predicates[i]
|
||||||
|
condition = when (query.logicalOperator.lowercase()) {
|
||||||
|
"and" -> condition.and(buildCondition(nextPredicate))
|
||||||
|
"or" -> condition.or(buildCondition(nextPredicate))
|
||||||
|
else -> throw IllegalArgumentException("Invalid logical operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseQuery = baseQuery.and(condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
val results = dslContext.select(
|
||||||
|
// User fields
|
||||||
|
USERS.USERNAME,
|
||||||
|
USERS.USER_ID,
|
||||||
|
USERS.JOIN_DATE,
|
||||||
|
USERS.COUNTRY,
|
||||||
|
USERS.COUNTRY_RANK,
|
||||||
|
USERS.RANK,
|
||||||
|
USERS.PP_RAW,
|
||||||
|
USERS.ACCURACY,
|
||||||
|
USERS.PLAYCOUNT,
|
||||||
|
USERS.TOTAL_SCORE,
|
||||||
|
USERS.RANKED_SCORE,
|
||||||
|
USERS.SECONDS_PLAYED,
|
||||||
|
USERS.COUNT_300,
|
||||||
|
USERS.COUNT_100,
|
||||||
|
USERS.COUNT_50,
|
||||||
|
|
||||||
|
// Scores fields
|
||||||
|
SCORES.ID,
|
||||||
|
SCORES.BEATMAP_ID,
|
||||||
|
SCORES.COUNT_300,
|
||||||
|
SCORES.COUNT_100,
|
||||||
|
SCORES.COUNT_50,
|
||||||
|
SCORES.COUNT_MISS,
|
||||||
|
SCORES.DATE,
|
||||||
|
SCORES.MAX_COMBO,
|
||||||
|
SCORES.MODS,
|
||||||
|
SCORES.PERFECT,
|
||||||
|
SCORES.PP,
|
||||||
|
SCORES.RANK,
|
||||||
|
SCORES.REPLAY_ID,
|
||||||
|
SCORES.SCORE,
|
||||||
|
SCORES.UR,
|
||||||
|
SCORES.FRAMETIME,
|
||||||
|
SCORES.EDGE_HITS,
|
||||||
|
SCORES.SNAPS,
|
||||||
|
SCORES.ADJUSTED_UR,
|
||||||
|
SCORES.MEAN_ERROR,
|
||||||
|
SCORES.ERROR_VARIANCE,
|
||||||
|
SCORES.ERROR_STANDARD_DEVIATION,
|
||||||
|
SCORES.MINIMUM_ERROR,
|
||||||
|
SCORES.MAXIMUM_ERROR,
|
||||||
|
SCORES.ERROR_RANGE,
|
||||||
|
SCORES.ERROR_COEFFICIENT_OF_VARIATION,
|
||||||
|
SCORES.ERROR_KURTOSIS,
|
||||||
|
SCORES.ERROR_SKEWNESS,
|
||||||
|
SCORES.KEYPRESSES_MEDIAN_ADJUSTED,
|
||||||
|
SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED,
|
||||||
|
SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED,
|
||||||
|
SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED,
|
||||||
|
|
||||||
|
// Beatmaps fields
|
||||||
|
BEATMAPS.ARTIST,
|
||||||
|
BEATMAPS.BEATMAPSET_ID,
|
||||||
|
BEATMAPS.CREATOR,
|
||||||
|
BEATMAPS.SOURCE,
|
||||||
|
BEATMAPS.STAR_RATING,
|
||||||
|
BEATMAPS.TITLE,
|
||||||
|
BEATMAPS.VERSION
|
||||||
|
)
|
||||||
|
.from(SCORES)
|
||||||
|
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
|
||||||
|
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
|
||||||
|
.where(baseQuery)
|
||||||
|
.apply {
|
||||||
|
if (request.sorting.field.isNotBlank())
|
||||||
|
orderBy(buildSorting(request.sorting))
|
||||||
|
}
|
||||||
|
.limit(50)
|
||||||
|
.fetch()
|
||||||
|
|
||||||
|
val response = SearchResponse(
|
||||||
|
scores = results.map {
|
||||||
|
SearchResponseEntry(
|
||||||
|
// User fields
|
||||||
|
user_id = it.get(SCORES.USER_ID),
|
||||||
|
user_username = it.get(USERS.USERNAME),
|
||||||
|
user_join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
|
||||||
|
user_country = it.get(USERS.COUNTRY),
|
||||||
|
user_country_rank = it.get(USERS.COUNTRY_RANK),
|
||||||
|
user_rank = it.get(USERS.RANK),
|
||||||
|
user_pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(),
|
||||||
|
user_accuracy = it.get(USERS.ACCURACY),
|
||||||
|
user_playcount = it.get(USERS.PLAYCOUNT),
|
||||||
|
user_total_score = it.get(USERS.TOTAL_SCORE),
|
||||||
|
user_ranked_score = it.get(USERS.RANKED_SCORE),
|
||||||
|
user_seconds_played = it.get(USERS.SECONDS_PLAYED),
|
||||||
|
user_count_300 = it.get(USERS.COUNT_300),
|
||||||
|
user_count_100 = it.get(USERS.COUNT_100),
|
||||||
|
user_count_50 = it.get(USERS.COUNT_50),
|
||||||
|
user_count_miss = it.get(SCORES.COUNT_MISS),
|
||||||
|
|
||||||
|
// Score fields
|
||||||
|
id = it.get(SCORES.ID),
|
||||||
|
beatmap_id = it.get(SCORES.BEATMAP_ID),
|
||||||
|
count_300 = it.get(SCORES.COUNT_300),
|
||||||
|
count_100 = it.get(SCORES.COUNT_100),
|
||||||
|
count_50 = it.get(SCORES.COUNT_50),
|
||||||
|
count_miss = it.get(SCORES.COUNT_MISS),
|
||||||
|
date = it.get(SCORES.DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
|
||||||
|
max_combo = it.get(SCORES.MAX_COMBO),
|
||||||
|
mods = it.get(SCORES.MODS),
|
||||||
|
perfect = it.get(SCORES.PERFECT),
|
||||||
|
pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(),
|
||||||
|
rank = it.get(SCORES.RANK),
|
||||||
|
replay_id = it.get(SCORES.REPLAY_ID),
|
||||||
|
score = it.get(SCORES.SCORE),
|
||||||
|
ur = it.get(SCORES.UR),
|
||||||
|
frametime = it.get(SCORES.FRAMETIME),
|
||||||
|
edge_hits = it.get(SCORES.EDGE_HITS),
|
||||||
|
snaps = it.get(SCORES.SNAPS),
|
||||||
|
adjusted_ur = it.get(SCORES.ADJUSTED_UR),
|
||||||
|
mean_error = it.get(SCORES.MEAN_ERROR),
|
||||||
|
error_variance = it.get(SCORES.ERROR_VARIANCE),
|
||||||
|
error_standard_deviation = it.get(SCORES.ERROR_STANDARD_DEVIATION),
|
||||||
|
minimum_error = it.get(SCORES.MINIMUM_ERROR),
|
||||||
|
maximum_error = it.get(SCORES.MAXIMUM_ERROR),
|
||||||
|
error_range = it.get(SCORES.ERROR_RANGE),
|
||||||
|
error_coefficient_of_variation = it.get(SCORES.ERROR_COEFFICIENT_OF_VARIATION),
|
||||||
|
error_kurtosis = it.get(SCORES.ERROR_KURTOSIS),
|
||||||
|
error_skewness = it.get(SCORES.ERROR_SKEWNESS),
|
||||||
|
keypresses_median_adjusted = it.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED),
|
||||||
|
keypresses_standard_deviation_adjusted = it.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED),
|
||||||
|
sliderend_release_median_adjusted = it.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED),
|
||||||
|
sliderend_release_standard_deviation_adjusted = it.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED),
|
||||||
|
|
||||||
|
// Beatmap fields
|
||||||
|
beatmap_artist = it.get(BEATMAPS.ARTIST),
|
||||||
|
beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID),
|
||||||
|
beatmap_creator = it.get(BEATMAPS.CREATOR),
|
||||||
|
beatmap_source = it.get(BEATMAPS.SOURCE),
|
||||||
|
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING),
|
||||||
|
beatmap_title = it.get(BEATMAPS.TITLE),
|
||||||
|
beatmap_version = it.get(BEATMAPS.VERSION)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSorting(sorting: SearchSorting): OrderField<*> {
|
||||||
|
val field = mapPredicateFieldToDatabaseField(sorting.field)
|
||||||
|
return when (sorting.order.lowercase()) {
|
||||||
|
"asc" -> field.asc()
|
||||||
|
"desc" -> field.desc()
|
||||||
|
else -> throw IllegalArgumentException("Invalid sorting order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCondition(predicate: SearchPredicate): Condition {
|
||||||
|
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
|
||||||
|
return when (predicate.field.type.lowercase()) {
|
||||||
|
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator, predicate.value.toDouble())
|
||||||
|
"string" -> buildStringCondition(field as Field<String>, predicate.operator, predicate.value)
|
||||||
|
else -> throw IllegalArgumentException("Invalid field type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> {
|
||||||
|
return when (predicateName.lowercase()) {
|
||||||
|
// User fields
|
||||||
|
"user_id" -> USERS.USER_ID
|
||||||
|
"user_username" -> USERS.USERNAME
|
||||||
|
"user_join_date" -> USERS.JOIN_DATE
|
||||||
|
"user_country" -> USERS.COUNTRY
|
||||||
|
"user_country_rank" -> USERS.COUNTRY_RANK
|
||||||
|
"user_rank" -> USERS.RANK
|
||||||
|
"user_pp_raw" -> USERS.PP_RAW
|
||||||
|
"user_accuracy" -> USERS.ACCURACY
|
||||||
|
"user_playcount" -> USERS.PLAYCOUNT
|
||||||
|
"user_total_score" -> USERS.TOTAL_SCORE
|
||||||
|
"user_ranked_score" -> USERS.RANKED_SCORE
|
||||||
|
"user_seconds_played" -> USERS.SECONDS_PLAYED
|
||||||
|
"user_count_300" -> USERS.COUNT_300
|
||||||
|
"user_count_100" -> USERS.COUNT_100
|
||||||
|
"user_count_50" -> USERS.COUNT_50
|
||||||
|
|
||||||
|
// Score fields
|
||||||
|
"id" -> SCORES.ID
|
||||||
|
"beatmap_id" -> SCORES.BEATMAP_ID
|
||||||
|
"count_300" -> SCORES.COUNT_300
|
||||||
|
"count_100" -> SCORES.COUNT_100
|
||||||
|
"count_50" -> SCORES.COUNT_50
|
||||||
|
"count_miss" -> SCORES.COUNT_MISS
|
||||||
|
"date" -> SCORES.DATE
|
||||||
|
"max_combo" -> SCORES.MAX_COMBO
|
||||||
|
"mods" -> SCORES.MODS
|
||||||
|
"perfect" -> SCORES.PERFECT
|
||||||
|
"pp" -> SCORES.PP
|
||||||
|
"rank" -> SCORES.RANK
|
||||||
|
"replay_id" -> SCORES.REPLAY_ID
|
||||||
|
"score" -> SCORES.SCORE
|
||||||
|
"ur" -> SCORES.UR
|
||||||
|
"frametime" -> SCORES.FRAMETIME
|
||||||
|
"edge_hits" -> SCORES.EDGE_HITS
|
||||||
|
"snaps" -> SCORES.SNAPS
|
||||||
|
"adjusted_ur" -> SCORES.ADJUSTED_UR
|
||||||
|
"mean_error" -> SCORES.MEAN_ERROR
|
||||||
|
"error_variance" -> SCORES.ERROR_VARIANCE
|
||||||
|
"error_standard_deviation" -> SCORES.ERROR_STANDARD_DEVIATION
|
||||||
|
"minimum_error" -> SCORES.MINIMUM_ERROR
|
||||||
|
"maximum_error" -> SCORES.MAXIMUM_ERROR
|
||||||
|
"error_range" -> SCORES.ERROR_RANGE
|
||||||
|
"error_coefficient_of_variation" -> SCORES.ERROR_COEFFICIENT_OF_VARIATION
|
||||||
|
"error_kurtosis" -> SCORES.ERROR_KURTOSIS
|
||||||
|
"error_skewness" -> SCORES.ERROR_SKEWNESS
|
||||||
|
"keypresses_median_adjusted" -> SCORES.KEYPRESSES_MEDIAN_ADJUSTED
|
||||||
|
"keypresses_standard_deviation_adjusted" -> SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED
|
||||||
|
"sliderend_release_median_adjusted" -> SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED
|
||||||
|
"sliderend_release_standard_deviation_adjusted" -> SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED
|
||||||
|
|
||||||
|
// Beatmap fields
|
||||||
|
"beatmap_artist" -> BEATMAPS.ARTIST
|
||||||
|
"beatmap_beatmapset_id" -> BEATMAPS.BEATMAPSET_ID
|
||||||
|
"beatmap_creator" -> BEATMAPS.CREATOR
|
||||||
|
"beatmap_source" -> BEATMAPS.SOURCE
|
||||||
|
"beatmap_star_rating" -> BEATMAPS.STAR_RATING
|
||||||
|
"beatmap_title" -> BEATMAPS.TITLE
|
||||||
|
"beatmap_version" -> BEATMAPS.VERSION
|
||||||
|
else -> throw IllegalArgumentException("Invalid field name: $predicateName")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNumberCondition(field: Field<Double>, operator: String, value: Double): Condition {
|
||||||
|
return when (operator) {
|
||||||
|
"=" -> field.eq(value)
|
||||||
|
">" -> field.gt(value)
|
||||||
|
"<" -> field.lt(value)
|
||||||
|
">=" -> field.ge(value)
|
||||||
|
"<=" -> field.le(value)
|
||||||
|
"!=" -> field.ne(value)
|
||||||
|
else -> throw IllegalArgumentException("Invalid operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStringCondition(field: Field<String>, operator: String, value: String): Condition {
|
||||||
|
return when (operator.lowercase()) {
|
||||||
|
"=" -> field.eq(value)
|
||||||
|
"contains" -> field.containsIgnoreCase(value)
|
||||||
|
"like" -> field.likeIgnoreCase(
|
||||||
|
// Escape special characters for LIKE if needed
|
||||||
|
value.replace("%", "\\%").replace("_", "\\_")
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import {ViewSimilarReplaysComponent} from "./view-similar-replays/view-similar-r
|
|||||||
import {ViewScoreComponent} from "./view-score/view-score.component";
|
import {ViewScoreComponent} from "./view-score/view-score.component";
|
||||||
import {ViewUserComponent} from "./view-user/view-user.component";
|
import {ViewUserComponent} from "./view-user/view-user.component";
|
||||||
import {ViewReplayPairComponent} from "./view-replay-pair/view-replay-pair.component";
|
import {ViewReplayPairComponent} from "./view-replay-pair/view-replay-pair.component";
|
||||||
|
import {SearchComponent} from "./search/search.component";
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{path: 'sus/:f', component: ViewSuspiciousScoresComponent, title: '/sus/'},
|
{path: 'sus/:f', component: ViewSuspiciousScoresComponent, title: '/sus/'},
|
||||||
@ -16,6 +17,7 @@ const routes: Routes = [
|
|||||||
|
|
||||||
{path: 'u/:userId', component: ViewUserComponent},
|
{path: 'u/:userId', component: ViewUserComponent},
|
||||||
{path: 's/:replayId', component: ViewScoreComponent},
|
{path: 's/:replayId', component: ViewScoreComponent},
|
||||||
|
{path: 'search', component: SearchComponent},
|
||||||
{path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent},
|
{path: 'p/:replay1Id/:replay2Id', component: ViewReplayPairComponent},
|
||||||
|
|
||||||
{path: '**', component: HomeComponent, title: '/nise.moe/'},
|
{path: '**', component: HomeComponent, title: '/nise.moe/'},
|
||||||
|
|||||||
17
nise-frontend/src/app/search/search.component.css
Normal file
17
nise-frontend/src/app/search/search.component.css
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
.search-container {
|
||||||
|
/* your styles for the container */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable-table {
|
||||||
|
overflow-x: auto; /* Enables horizontal scrolling */
|
||||||
|
width: 100%; /* Adjust as needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-border {
|
||||||
|
border: 1px solid rgba(179, 184, 195, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-border th, .table-border td {
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid rgba(179, 184, 195, 0.1);
|
||||||
|
}
|
||||||
80
nise-frontend/src/app/search/search.component.html
Normal file
80
nise-frontend/src/app/search/search.component.html
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<div class="main term">
|
||||||
|
<h1><span class="board">/k/</span> - Advanced Search</h1>
|
||||||
|
|
||||||
|
<!-- <pre>{{ queries | json }}</pre>-->
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Table columns</legend>
|
||||||
|
<ng-container *ngFor="let category of ['user', 'beatmap', 'score']">
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ category }}</legend>
|
||||||
|
<ng-container *ngFor="let field of fields">
|
||||||
|
<div *ngIf="field.category === category">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [(ngModel)]="field.active" (change)="this.saveColumnsStatusToLocalStorage()"/>
|
||||||
|
{{ field.name }} <span class="text-muted" style="margin-left: 6px">{{ field.description }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</fieldset>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="search-container mt-2" *ngIf="this.queries">
|
||||||
|
<app-query-builder [queries]="this.queries" [fields]="this.mapSchemaFieldsToFields()"></app-query-builder>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="mt-2" *ngIf="this.sortingOrder">
|
||||||
|
<legend>sorting</legend>
|
||||||
|
|
||||||
|
<select>
|
||||||
|
<option *ngFor="let field of fields" [value]="field.name" (click)="this.sortingOrder.field = field.name">{{ field.name }}</option>
|
||||||
|
</select>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="ASC" />
|
||||||
|
ASC
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="sortingOrder" [(ngModel)]="this.sortingOrder.order" value="DESC" />
|
||||||
|
DESC
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<button (click)="search()" class="mb-2 mt-2">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="response">
|
||||||
|
<div class="scrollable-table">
|
||||||
|
<table class="table-border">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th *ngFor="let column of fields" [hidden]="!column.active" class="text-center">
|
||||||
|
{{ column.short_name }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let entry of response.scores">
|
||||||
|
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
|
||||||
|
<ng-container *ngIf="column.type == 'number'">
|
||||||
|
{{ getValue(entry, column.name) | number }}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'flag'">
|
||||||
|
<span class="flag">{{ countryCodeToFlag(getValue(entry, column.name)) }}</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'grade'">
|
||||||
|
<app-osu-grade [grade]="getValue(entry, column.name)"></app-osu-grade>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="column.type == 'string'">
|
||||||
|
{{ getValue(entry, column.name) }}
|
||||||
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
214
nise-frontend/src/app/search/search.component.ts
Normal file
214
nise-frontend/src/app/search/search.component.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import {Component, OnInit} from '@angular/core';
|
||||||
|
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||||
|
import {DecimalPipe, JsonPipe, NgForOf, NgIf} from "@angular/common";
|
||||||
|
import {HttpClient} from "@angular/common/http";
|
||||||
|
import {environment} from "../../environments/environment";
|
||||||
|
import {countryCodeToFlag} from "../format";
|
||||||
|
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
||||||
|
import {Field, Query, QueryBuilderComponent} from "../../corelib/components/query-builder/query-builder.component";
|
||||||
|
|
||||||
|
interface SchemaField {
|
||||||
|
name: string;
|
||||||
|
short_name: string;
|
||||||
|
category: 'user' | 'score' | 'beatmap';
|
||||||
|
type: 'number' | 'string' | 'flag' | 'grade' | 'boolean';
|
||||||
|
active: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
scores: SearchResponseEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sorting {
|
||||||
|
field: string;
|
||||||
|
order: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponseEntry {
|
||||||
|
// User fields
|
||||||
|
user_id?: number;
|
||||||
|
user_username?: string;
|
||||||
|
user_join_date?: string;
|
||||||
|
user_country?: string;
|
||||||
|
user_country_rank?: number;
|
||||||
|
user_rank?: number;
|
||||||
|
user_pp_raw?: number;
|
||||||
|
user_accuracy?: number;
|
||||||
|
user_playcount?: number;
|
||||||
|
user_total_score?: number;
|
||||||
|
user_ranked_score?: number;
|
||||||
|
user_seconds_played?: number;
|
||||||
|
user_count_300?: number;
|
||||||
|
user_count_100?: number;
|
||||||
|
user_count_50?: number;
|
||||||
|
user_count_miss?: number;
|
||||||
|
|
||||||
|
// Score fields
|
||||||
|
replay_id?: number;
|
||||||
|
date?: string;
|
||||||
|
beatmap_id?: number;
|
||||||
|
pp?: number;
|
||||||
|
frametime?: number;
|
||||||
|
ur?: number;
|
||||||
|
|
||||||
|
// Beatmap fields
|
||||||
|
beatmap_artist?: string;
|
||||||
|
beatmap_beatmapset_id?: number;
|
||||||
|
beatmap_creator?: string;
|
||||||
|
beatmap_source?: string;
|
||||||
|
beatmap_star_rating?: number;
|
||||||
|
beatmap_title?: string;
|
||||||
|
beatmap_version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-search',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgForOf,
|
||||||
|
FormsModule,
|
||||||
|
JsonPipe,
|
||||||
|
NgIf,
|
||||||
|
DecimalPipe,
|
||||||
|
OsuGradeComponent,
|
||||||
|
QueryBuilderComponent
|
||||||
|
],
|
||||||
|
templateUrl: './search.component.html',
|
||||||
|
styleUrl: './search.component.css'
|
||||||
|
})
|
||||||
|
export class SearchComponent implements OnInit {
|
||||||
|
|
||||||
|
constructor(private httpClient: HttpClient) { }
|
||||||
|
|
||||||
|
response: SearchResponse | null = null;
|
||||||
|
|
||||||
|
fields: SchemaField[] = [
|
||||||
|
// User fields
|
||||||
|
{ name: "user_id", short_name: "ID", category: "user", type: "number", active: true, description: "unique identifier for a user" },
|
||||||
|
{ name: "user_username", short_name: "Username", category: "user", type: "string", active: true, description: "user's name" },
|
||||||
|
{ name: "user_join_date", short_name: "Join Date", category: "user", type: "string", active: true, description: "when the user joined" },
|
||||||
|
{ name: "user_country", short_name: "Country", category: "user", type: "flag", active: true, description: "user's country flag" },
|
||||||
|
{ name: "user_country_rank", short_name: "Country Rank", category: "user", type: "number", active: true, description: "ranking within user's country" },
|
||||||
|
{ name: "user_rank", short_name: "Rank", category: "user", type: "number", active: true, description: "global ranking" },
|
||||||
|
{ name: "user_pp_raw", short_name: "User PP", category: "user", type: "number", active: true, description: "performance points" },
|
||||||
|
{ name: "user_accuracy", short_name: "User Accuracy", category: "user", type: "number", active: true, description: "hit accuracy percentage" },
|
||||||
|
{ name: "user_playcount", short_name: "Playcount", category: "user", type: "number", active: true, description: "total plays" },
|
||||||
|
{ name: "user_total_score", short_name: "Total Score", category: "user", type: "number", active: true, description: "cumulative score" },
|
||||||
|
{ name: "user_ranked_score", short_name: "Ranked Score", category: "user", type: "number", active: true, description: "score from ranked maps" },
|
||||||
|
{ name: "user_seconds_played", short_name: "Play Time", category: "user", type: "number", active: true, description: "total play time in seconds" },
|
||||||
|
{ name: "user_count_300", short_name: "300s", category: "user", type: "number", active: true, description: "number of 300 hits" },
|
||||||
|
{ name: "user_count_100", short_name: "100s", category: "user", type: "number", active: true, description: "number of 100 hits" },
|
||||||
|
{ name: "user_count_50", short_name: "50s", category: "user", type: "number", active: true, description: "number of 50 hits" },
|
||||||
|
{ name: "user_count_miss", short_name: "Misses", category: "user", type: "number", active: true, description: "missed hits" },
|
||||||
|
|
||||||
|
// Score fields
|
||||||
|
{ name: "beatmap_id", short_name: "Beatmap ID", category: "score", type: "number", active: true, description: "identifies the beatmap" },
|
||||||
|
{ name: "count_300", short_name: "300s", category: "score", type: "number", active: true, description: "number of 300 hits in score" },
|
||||||
|
{ name: "count_100", short_name: "100s", category: "score", type: "number", active: true, description: "number of 100 hits in score" },
|
||||||
|
{ name: "count_50", short_name: "50s", category: "score", type: "number", active: true, description: "number of 50 hits in score" },
|
||||||
|
{ name: "count_miss", short_name: "Misses", category: "score", type: "number", active: true, description: "missed hits in score" },
|
||||||
|
{ name: "date", short_name: "Date", category: "score", type: "string", active: true, description: "when score was achieved" },
|
||||||
|
{ name: "max_combo", short_name: "Max Combo", category: "score", type: "number", active: true, description: "highest combo in score" },
|
||||||
|
{ name: "mods", short_name: "Mods", category: "score", type: "number", active: true, description: "game modifiers used" },
|
||||||
|
{ name: "perfect", short_name: "Perfect", category: "score", type: "boolean", active: true, description: "if score is a full combo" },
|
||||||
|
{ name: "pp", short_name: "Score PP", category: "score", type: "number", active: true, description: "performance points for score" },
|
||||||
|
{ name: "rank", short_name: "Rank", category: "score", type: "grade", active: true, description: "score grade" },
|
||||||
|
{ name: "replay_id", short_name: "Replay ID", category: "score", type: "number", active: true, description: "identifier for replay" },
|
||||||
|
{ name: "score", short_name: "Score", category: "score", type: "number", active: true, description: "score value" },
|
||||||
|
{ name: "ur", short_name: "UR", category: "score", type: "number", active: true, description: "unstable rate" },
|
||||||
|
{ name: "frametime", short_name: "Frame Time", category: "score", type: "number", active: true, description: "average frame time during play" },
|
||||||
|
{ name: "edge_hits", short_name: "Edge Hits", category: "score", type: "number", active: true, description: "hits at the edge of hit window" },
|
||||||
|
{ name: "snaps", short_name: "Snaps", category: "score", type: "number", active: true, description: "rapid cursor movements" },
|
||||||
|
{ name: "adjusted_ur", short_name: "Adj. UR", category: "score", type: "number", active: true, description: "adjusted unstable rate" },
|
||||||
|
{ name: "mean_error", short_name: "Mean Error", category: "score", type: "number", active: true, description: "average timing error" },
|
||||||
|
{ name: 'error_variance', short_name: 'Error Var.', category: 'score', type: 'number', active: true, description: 'variability of error in scores' },
|
||||||
|
{ name: 'error_standard_deviation', short_name: 'Error SD', category: 'score', type: 'number', active: true, description: 'standard deviation of error' },
|
||||||
|
{ name: 'minimum_error', short_name: 'Min Error', category: 'score', type: 'number', active: true, description: 'smallest error recorded' },
|
||||||
|
{ name: 'maximum_error', short_name: 'Max Error', category: 'score', type: 'number', active: true, description: 'largest error recorded' },
|
||||||
|
{ name: 'error_range', short_name: 'Error Range', category: 'score', type: 'number', active: true, description: 'range between min and max error' },
|
||||||
|
{ name: 'error_coefficient_of_variation', short_name: 'Error CV', category: 'score', type: 'number', active: true, description: 'relative variability of error' },
|
||||||
|
{ name: 'error_kurtosis', short_name: 'Kurtosis', category: 'score', type: 'number', active: true, description: 'peakedness of error distribution' },
|
||||||
|
{ name: 'error_skewness', short_name: 'Skewness', category: 'score', type: 'number', active: true, description: 'asymmetry of error distribution' },
|
||||||
|
{ name: 'keypresses_median_adjusted', short_name: 'KP Median Adj.', category: 'score', type: 'number', active: true, description: 'median of adjusted keypresses' },
|
||||||
|
{ name: 'keypresses_standard_deviation_adjusted', short_name: 'KP std. Adj.', category: 'score', type: 'number', active: true, description: 'std. dev of adjusted keypresses' },
|
||||||
|
{ name: 'sliderend_release_median_adjusted', short_name: 'Sliderend Median Adj.', category: 'score', type: 'number', active: true, description: 'median of adjusted sliderend releases' },
|
||||||
|
{ name: 'sliderend_release_standard_deviation_adjusted', short_name: 'Sliderend std. Adj.', category: 'score', type: 'number', active: true, description: 'std. dev of adjusted sliderend releases' },
|
||||||
|
|
||||||
|
// Beatmap fields
|
||||||
|
{ name: 'beatmap_artist', short_name: 'Artist', category: 'beatmap', type: 'string', active: true, description: 'artist of the beatmap' },
|
||||||
|
{ name: 'beatmap_beatmapset_id', short_name: 'Set ID', category: 'beatmap', type: 'number', active: true, description: 'id of the beatmap set' },
|
||||||
|
{ name: 'beatmap_creator', short_name: 'Creator', category: 'beatmap', type: 'string', active: true, description: 'creator of the beatmap' },
|
||||||
|
{ name: 'beatmap_source', short_name: 'Source', category: 'beatmap', type: 'string', active: true, description: 'source of the beatmap music' },
|
||||||
|
{ name: 'beatmap_star_rating', short_name: 'Stars', category: 'beatmap', type: 'number', active: true, description: '(★) difficulty rating of the beatmap' },
|
||||||
|
{ name: 'beatmap_title', short_name: 'Title', category: 'beatmap', type: 'string', active: true, description: 'title of the beatmap' },
|
||||||
|
{ name: 'beatmap_version', short_name: 'Version', category: 'beatmap', type: 'string', active: true, description: 'version or difficulty name of the beatmap' }
|
||||||
|
];
|
||||||
|
|
||||||
|
sortingOrder: Sorting | null = null;
|
||||||
|
queries: Query[] | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const storedQueries = localStorage.getItem('search_queries');
|
||||||
|
if (storedQueries) {
|
||||||
|
this.queries = JSON.parse(storedQueries);
|
||||||
|
} else {
|
||||||
|
this.queries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load active/inactive status from localStorage
|
||||||
|
const storedStatus = localStorage.getItem('columns_status');
|
||||||
|
if (storedStatus) {
|
||||||
|
const statusMap = JSON.parse(storedStatus);
|
||||||
|
this.fields.forEach(field => {
|
||||||
|
if (statusMap.hasOwnProperty(field.name)) {
|
||||||
|
field.active = statusMap[field.name];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sortingOrder = {
|
||||||
|
field: 'user_id',
|
||||||
|
order: 'ASC'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mapSchemaFieldsToFields(): Field[] {
|
||||||
|
return this.fields.map(field => {
|
||||||
|
return {
|
||||||
|
name: field.name,
|
||||||
|
type: field.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveColumnsStatusToLocalStorage(): void {
|
||||||
|
const statusMap = this.fields.reduce<{ [key: string]: boolean }>((acc, field) => {
|
||||||
|
acc[field.name] = field.active;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
localStorage.setItem('columns_status', JSON.stringify(statusMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
search(): void {
|
||||||
|
const body = {
|
||||||
|
queries: this.queries,
|
||||||
|
sorting: this.sortingOrder
|
||||||
|
}
|
||||||
|
this.httpClient.post<SearchResponse>(`${environment.apiUrl}/search`, body).subscribe(response => {
|
||||||
|
this.response = response;
|
||||||
|
});
|
||||||
|
// this.updateLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this method to the SearchComponent class
|
||||||
|
getValue(entry: SearchResponseEntry, columnName: string): any {
|
||||||
|
return entry[columnName as keyof SearchResponseEntry];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
protected readonly countryCodeToFlag = countryCodeToFlag;
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
.query {
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logical-operator-toggle button {
|
||||||
|
/* your styles for the logical operator buttons */
|
||||||
|
}
|
||||||
|
|
||||||
|
.logical-operator-toggle button.active {
|
||||||
|
/* your styles for the active state */
|
||||||
|
}
|
||||||
|
|
||||||
|
.predicate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.predicate select, .predicate input, .predicate button {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.predicate button {
|
||||||
|
/* style it to look more like a close button */
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<fieldset>
|
||||||
|
<legend>Query builder</legend>
|
||||||
|
<fieldset *ngFor="let query of queries; let i = index" class="query">
|
||||||
|
<legend>Predicate #{{ i + 1 }}</legend>
|
||||||
|
<div class="logical-operator-toggle">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="AND" />
|
||||||
|
AND
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="logicalOperator{{i}}" [(ngModel)]="query.logicalOperator" value="OR" />
|
||||||
|
OR
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngFor="let predicate of query.predicates; let j = index" class="predicate">
|
||||||
|
<select>
|
||||||
|
<option *ngFor="let field of fields" (click)="onFieldChange(predicate, field)" [selected]="field.name === predicate.field?.name">{{ field.name }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select [(ngModel)]="predicate.operator" [disabled]="!predicate.field">
|
||||||
|
<option *ngFor="let operator of getOperators(predicate.field?.type)" [value]="operator">
|
||||||
|
{{ operator }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input [(ngModel)]="predicate.value" type="text" placeholder="Value" [disabled]="!predicate.field">
|
||||||
|
<button (click)="removePredicate(i, j)">X</button>
|
||||||
|
</div>
|
||||||
|
<button (click)="addPredicate(i)">+ Rule</button>
|
||||||
|
</fieldset>
|
||||||
|
<button (click)="addQuery()">+ Predicate</button>
|
||||||
|
</fieldset>
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import {Component, Input} from '@angular/core';
|
||||||
|
import {FormsModule} from "@angular/forms";
|
||||||
|
import {NgForOf, NgIf} from "@angular/common";
|
||||||
|
|
||||||
|
export type FieldType = 'number' | 'string' | 'flag' | 'grade' | 'boolean';
|
||||||
|
export type OperatorType = '=' | '>' | '<' | 'contains' | 'like' | '>=' | '<=' | '!=';
|
||||||
|
|
||||||
|
export interface Field {
|
||||||
|
name: string;
|
||||||
|
type: FieldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Predicate {
|
||||||
|
field: Field | null;
|
||||||
|
operator: OperatorType | null;
|
||||||
|
value: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Query {
|
||||||
|
predicates: Predicate[];
|
||||||
|
logicalOperator: 'AND' | 'OR';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-query-builder',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
NgForOf,
|
||||||
|
NgIf
|
||||||
|
],
|
||||||
|
templateUrl: './query-builder.component.html',
|
||||||
|
styleUrl: './query-builder.component.css'
|
||||||
|
})
|
||||||
|
export class QueryBuilderComponent {
|
||||||
|
|
||||||
|
@Input() queries: Query[] = [];
|
||||||
|
@Input() fields: Field[] = [];
|
||||||
|
|
||||||
|
addPredicate(queryIndex: number): void {
|
||||||
|
const newPredicate: Predicate = { field: null, operator: null, value: null};
|
||||||
|
this.queries[queryIndex].predicates.push(newPredicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFieldChange(predicate: Predicate, selectedField: any): void {
|
||||||
|
predicate.field = selectedField;
|
||||||
|
predicate.operator = this.getOperators(selectedField.type)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
getOperators(fieldType: FieldType | undefined): OperatorType[] {
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'number':
|
||||||
|
return ['=', '>', '<', '>=', '<=', '!='];
|
||||||
|
case 'string':
|
||||||
|
return ['=', 'contains', 'like'];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePredicate(queryIndex: number, predicateIndex: number): void {
|
||||||
|
if(!this.queries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queries[queryIndex].predicates.splice(predicateIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
addQuery(): void {
|
||||||
|
this.queries.push({
|
||||||
|
predicates: [],
|
||||||
|
logicalOperator: 'AND'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLocalStorage(): void {
|
||||||
|
console.warn('Updating local storage');
|
||||||
|
localStorage.setItem('search_queries', JSON.stringify(this.queries));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user