From 90412be0117114afbe2aec5363ff5ebdc8f72afa Mon Sep 17 00:00:00 2001 From: Alexander Frolov Date: Mon, 22 Aug 2022 18:51:21 +0300 Subject: [PATCH] ContestView results improvements (#1081) * ContestView results improvements ### What's done: * Renamed `PARTICIPANTS` ContestView tab to `SUMMARY` * Renamed `RESULTS` ContestView tab to `SUBMISSIONS` * Improved `SUBMISSIONS` tab * Added `ContestExecutionView` * Added several endpoints on backend for new tab and view (#1035) Co-authored-by: Peter Trifanov --- save-backend/backend-api-docs.json | 266 +++++++++++++++++- .../controllers/ExecutionController.kt | 21 +- .../LnkContestProjectController.kt | 110 +++++++- .../LnkContestExecutionRepository.kt | 27 ++ .../service/LnkContestExecutionService.kt | 32 +++ .../save/entities/ContestResult.kt | 14 +- .../save/execution/ExecutionDto.kt | 22 +- .../save/execution/ExecutionType.kt | 7 +- .../save/entities/LnkContestExecution.kt | 16 +- .../com/saveourtool/save/frontend/App.kt | 13 + ...cutionStatistics.kt => ExecutionLabels.kt} | 202 ++++++++++--- .../frontend/components/basic/InputForms.kt | 6 +- .../frontend/components/basic/SelectForm.kt | 2 +- .../basic/contests/ContestResultsMenu.kt | 48 ---- .../basic/contests/ContestSubmissionsMenu.kt | 120 ++++++++ ...ticipantsMenu.kt => ContestSummaryMenu.kt} | 11 +- .../components/views/ContestExecutionView.kt | 250 ++++++++++++++++ .../frontend/components/views/ContestView.kt | 51 ++-- .../components/views/ExecutionView.kt | 82 +----- .../externals/chart/PieChartBuilder.kt | 12 + .../main/resources/scss/utilities/_text.scss | 4 - .../basic/ExecutionStatisticsValuesTest.kt | 4 +- 22 files changed, 1108 insertions(+), 212 deletions(-) rename save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/{ExecutionStatistics.kt => ExecutionLabels.kt} (61%) delete mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestResultsMenu.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSubmissionsMenu.kt rename save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/{ContestParticipantsMenu.kt => ContestSummaryMenu.kt} (89%) create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestExecutionView.kt diff --git a/save-backend/backend-api-docs.json b/save-backend/backend-api-docs.json index a130082b8b..14f0560af1 100644 --- a/save-backend/backend-api-docs.json +++ b/save-backend/backend-api-docs.json @@ -5389,6 +5389,246 @@ ] } }, + "/api/v1/contests/{contestName}/my-results": { + "get": { + "tags": [ + "contests" + ], + "summary": "Get your best results in contest.", + "description": "Get list of best results of your projects in a given contest.", + "operationId": "getBestResultsInUserProjects", + "parameters": [ + { + "name": "contestName", + "in": "path", + "description": "name of a contest", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Authorization-Source", + "in": "header", + "required": true, + "example": "basic" + } + ], + "responses": { + "200": { + "description": "Successfully fetched your best results.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContestResult" + } + } + } + } + }, + "404": { + "description": "Either given project or given contest was not found.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContestResult" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContestResult" + } + } + } + } + } + }, + "security": [ + { + "basic": [] + } + ] + } + }, + "/api/v1/contests/{contestName}/executions/{organizationName}/{projectName}": { + "get": { + "tags": [ + "contests" + ], + "summary": "Get project executions in contest.", + "description": "Get list of execution of a project with given name in contest with given name.", + "operationId": "getContestExecutionsForProject", + "parameters": [ + { + "name": "contestName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "organizationName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectName", + "in": "path", + "description": "name of a project", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "404": { + "description": "Either contest is not found or project is not found or execution is not found.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + }, + "200": { + "description": "Successfully fetched latest project execution in contest.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + } + }, + "security": [ + { + "basic": [] + } + ] + } + }, + "/api/v1/contests/{contestName}/executions/{organizationName}/{projectName}/latest": { + "get": { + "tags": [ + "contests" + ], + "summary": "Get latest project execution in contest.", + "description": "Get latest execution of a project with given name in contest with given name.", + "operationId": "getLatestExecutionOfProjectInContest", + "parameters": [ + { + "name": "organizationName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectName", + "in": "path", + "description": "name of a project", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "contestName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Authorization-Source", + "in": "header", + "required": true, + "example": "basic" + } + ], + "responses": { + "404": { + "description": "Either contest is not found or project is not found or execution is not found.", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + }, + "200": { + "description": "Successfully fetched latest project execution in contest.", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + }, + "security": [ + { + "basic": [] + } + ] + } + }, "/api/v1/contests/{contestName}/enroll": { "get": { "tags": [ @@ -7219,6 +7459,7 @@ "type": { "type": "string", "enum": [ + "CONTEST", "GIT", "STANDARD" ] @@ -7396,6 +7637,7 @@ "type": { "type": "string", "enum": [ + "CONTEST", "GIT", "STANDARD" ] @@ -7452,8 +7694,11 @@ "ContestResult": { "required": [ "contestName", + "hasFailedTest", "organizationName", - "projectName" + "projectName", + "sdk", + "submissionStatus" ], "type": "object", "properties": { @@ -7469,6 +7714,25 @@ "score": { "type": "number", "format": "double" + }, + "submissionTime": { + "type": "string", + "format": "date-time" + }, + "submissionStatus": { + "type": "string", + "enum": [ + "ERROR", + "FINISHED", + "PENDING", + "RUNNING" + ] + }, + "sdk": { + "type": "string" + }, + "hasFailedTest": { + "type": "boolean" } } }, diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ExecutionController.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ExecutionController.kt index b223b51cbb..1628c57107 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ExecutionController.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ExecutionController.kt @@ -16,6 +16,7 @@ import com.saveourtool.save.execution.ExecutionDto import com.saveourtool.save.execution.ExecutionUpdateDto import com.saveourtool.save.permission.Permission import com.saveourtool.save.utils.orNotFound +import com.saveourtool.save.utils.switchIfEmptyToNotFound import com.saveourtool.save.v1 import org.slf4j.LoggerFactory @@ -29,6 +30,7 @@ import reactor.core.publisher.Flux import reactor.core.publisher.GroupedFlux import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.switchIfEmpty +import reactor.kotlin.core.publisher.toMono import java.util.concurrent.atomic.AtomicBoolean @@ -97,15 +99,22 @@ class ExecutionController(private val executionService: ExecutionService, * @param authentication * @param organizationName * @return list of execution dtos - * @throws NoSuchElementException */ @GetMapping(path = ["/api/$v1/executionDtoList"]) - fun getExecutionByProject(@RequestParam name: String, @RequestParam organizationName: String, authentication: Authentication): Mono> { - val organization = organizationService.findByName(organizationName) ?: throw NoSuchElementException("Organization with name [$organizationName] was not found.") - return projectService.findWithPermissionByNameAndOrganization(authentication, name, organization.name, Permission.READ).map { - executionService.getExecutionDtoByNameAndOrganization(name, organization).reversed() + fun getExecutionByProject( + @RequestParam name: String, + @RequestParam organizationName: String, + authentication: Authentication, + ): Mono> = organizationService.findByName(organizationName) + .toMono() + .switchIfEmptyToNotFound { + "Organization with name [$organizationName] was not found." + } + .flatMap { organization -> + projectService.findWithPermissionByNameAndOrganization(authentication, name, organization.name, Permission.READ).map { + executionService.getExecutionDtoByNameAndOrganization(name, organization).reversed() + } } - } /** * Get latest (by start time an) execution by project name and organization diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/LnkContestProjectController.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/LnkContestProjectController.kt index 415a48765d..63dde489c8 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/LnkContestProjectController.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/LnkContestProjectController.kt @@ -12,9 +12,12 @@ import com.saveourtool.save.backend.configs.ApiSwaggerSupport import com.saveourtool.save.backend.configs.RequiresAuthorizationSourceHeader import com.saveourtool.save.backend.service.* import com.saveourtool.save.backend.utils.AuthenticationDetails +import com.saveourtool.save.backend.utils.blockingToFlux import com.saveourtool.save.entities.ContestResult import com.saveourtool.save.entities.LnkContestProject +import com.saveourtool.save.execution.ExecutionDto import com.saveourtool.save.permission.Permission +import com.saveourtool.save.utils.switchIfEmptyToNotFound import com.saveourtool.save.utils.switchIfEmptyToResponseException import com.saveourtool.save.v1 import io.swagger.v3.oas.annotations.Operation @@ -24,6 +27,7 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tags +import org.springframework.data.domain.PageRequest import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -34,6 +38,7 @@ import org.springframework.web.server.ResponseStatusException import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.switchIfEmpty +import reactor.kotlin.core.publisher.toMono import reactor.kotlin.core.util.function.component1 import reactor.kotlin.core.util.function.component2 @@ -45,7 +50,7 @@ import reactor.kotlin.core.util.function.component2 Tag(name = "contests"), ) @RestController -@RequestMapping("/api/$v1/contests/") +@RequestMapping("/api/$v1/contests") class LnkContestProjectController( private val lnkContestProjectService: LnkContestProjectService, private val lnkContestExecutionService: LnkContestExecutionService, @@ -153,6 +158,77 @@ class LnkContestProjectController( } } + @GetMapping("/{contestName}/executions/{organizationName}/{projectName}") + @PreAuthorize("isAuthenticated()") + @Operation( + method = "GET", + summary = "Get project executions in contest.", + description = "Get list of execution of a project with given name in contest with given name.", + ) + @Parameters( + Parameter(name = "contestName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "organizationName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "projectName", `in` = ParameterIn.PATH, description = "name of a project", required = true), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched latest project execution in contest.") + @ApiResponse(responseCode = "404", description = "Either contest is not found or project is not found or execution is not found.") + fun getContestExecutionsForProject( + @PathVariable contestName: String, + @PathVariable organizationName: String, + @PathVariable projectName: String, + authentication: Authentication, + ): Flux = getContestAndProject(contestName, organizationName, projectName) + .flatMapIterable { (contest, project) -> + lnkContestExecutionService.getPageExecutionsByContestAndProject( + contest, + project, + PageRequest.ofSize(MAX_AMOUNT) + ) + } + .map { + it.execution.toDto() + } + + @GetMapping("/{contestName}/executions/{organizationName}/{projectName}/latest") + @RequiresAuthorizationSourceHeader + @PreAuthorize("isAuthenticated()") + @Operation( + method = "GET", + summary = "Get latest project execution in contest.", + description = "Get latest execution of a project with given name in contest with given name.", + ) + @Parameters( + Parameter(name = "contestName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "organizationName", `in` = ParameterIn.PATH, description = "name of an organization", required = true), + Parameter(name = "projectName", `in` = ParameterIn.PATH, description = "name of a project", required = true), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched latest project execution in contest.") + @ApiResponse(responseCode = "404", description = "Either contest is not found or project is not found or execution is not found.") + fun getLatestExecutionOfProjectInContest( + @PathVariable organizationName: String, + @PathVariable projectName: String, + @PathVariable contestName: String, + authentication: Authentication + ): Mono = getContestAndProject(contestName, organizationName, projectName) + .flatMap { (contest, project) -> + lnkContestExecutionService.getLatestExecutionByContestAndProject(contest, project).toMono() + } + .switchIfEmptyToNotFound { + "No executions found for project $organizationName/$projectName in contest $contestName." + } + .map { + it.execution.toDto() + } + + private fun getContestAndProject(contestName: String, organizationName: String, projectName: String) = Mono.justOrEmpty(contestService.findByName(contestName)) + .switchIfEmptyToNotFound { + "Could not find contest with name $contestName." + } + .zipWith(projectService.findByNameAndOrganizationName(projectName, organizationName).toMono()) + .switchIfEmptyToNotFound { + "Could not find project with name $organizationName/$projectName." + } + @GetMapping("/{contestName}/enroll") @PreAuthorize("isAuthenticated()") @RequiresAuthorizationSourceHeader @@ -207,6 +283,38 @@ class LnkContestProjectController( } } + @GetMapping("/{contestName}/my-results") + @PreAuthorize("isAuthenticated()") + @RequiresAuthorizationSourceHeader + @Operation( + method = "GET", + summary = "Get your best results in contest.", + description = "Get list of best results of your projects in a given contest.", + ) + @Parameters( + Parameter(name = "contestName", `in` = ParameterIn.PATH, description = "name of a contest", required = true), + ) + @ApiResponse(responseCode = "200", description = "Successfully fetched your best results.") + @ApiResponse(responseCode = "404", description = "Either given project or given contest was not found.") + fun getBestResultsInUserProjects( + @PathVariable contestName: String, + authentication: Authentication, + ): Flux = Mono.justOrEmpty(contestService.findByName(contestName)) + .switchIfEmptyToNotFound { + "Contest with name $contestName was not found." + } + .map { contest -> + contest to lnkUserProjectService.getNonDeletedProjectsByUserId((authentication.details as AuthenticationDetails).id).map { it.requiredId() } + } + .flatMapMany { (contest, projectIds) -> + blockingToFlux { + lnkContestExecutionService.getLatestExecutionByContestAndProjectIds(contest, projectIds) + } + } + .map { + it.toContestResult() + } + companion object { const val MAX_AMOUNT = 512 } diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/repository/LnkContestExecutionRepository.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/repository/LnkContestExecutionRepository.kt index 8990402235..3cd729dd50 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/repository/LnkContestExecutionRepository.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/repository/LnkContestExecutionRepository.kt @@ -19,4 +19,31 @@ interface LnkContestExecutionRepository : BaseEntityRepository + + /** + * Get N executions of a [Project] in [Contest] + * + * @param project + * @param contest + * @param pageable + * @return page of N records with executions sorted by starting time + */ + @Suppress("IDENTIFIER_LENGTH") + fun findByExecutionProjectAndContestOrderByExecutionStartTimeDesc(project: Project, contest: Contest, pageable: Pageable): Page + + /** + * @param contest + * @param project + * @return [LnkContestExecution] if found, null otherwise + */ + @Suppress("IDENTIFIER_LENGTH") + fun findFirstByContestAndExecutionProjectOrderByExecutionStartTimeDesc(contest: Contest, project: Project): LnkContestExecution? + + /** + * @param contest + * @param projectIds + * @return list of [LnkContestExecution]s + */ + @Suppress("IDENTIFIER_LENGTH") + fun findByContestAndExecutionProjectIdInOrderByExecutionStartTimeDesc(contest: Contest, projectIds: List): List } diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/service/LnkContestExecutionService.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/service/LnkContestExecutionService.kt index 8f627d1a31..5c5f83e211 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/service/LnkContestExecutionService.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/service/LnkContestExecutionService.kt @@ -2,6 +2,7 @@ package com.saveourtool.save.backend.service import com.saveourtool.save.backend.repository.LnkContestExecutionRepository import com.saveourtool.save.entities.* +import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @@ -22,4 +23,35 @@ class LnkContestExecutionService( .content .singleOrNull() ?.score + + /** + * @param contest + * @param project + * @param pageRequest + * @return list of [LnkContestExecution]s by [Contest] and [Project] + */ + fun getPageExecutionsByContestAndProject( + contest: Contest, + project: Project, + pageRequest: PageRequest, + ): List = lnkContestExecutionRepository + .findByExecutionProjectAndContestOrderByExecutionStartTimeDesc(project, contest, pageRequest) + .content + + /** + * @param contest + * @param project + * @return latest [LnkContestExecution] by [Contest] and [Project] + */ + fun getLatestExecutionByContestAndProject(contest: Contest, project: Project) = lnkContestExecutionRepository + .findFirstByContestAndExecutionProjectOrderByExecutionStartTimeDesc(contest, project) + + /** + * @param contest + * @param projectIds + * @return list of latest [LnkContestExecution] of [Project]s with [Project.id] in [projectIds] in [Contest] + */ + fun getLatestExecutionByContestAndProjectIds(contest: Contest, projectIds: List) = lnkContestExecutionRepository + .findByContestAndExecutionProjectIdInOrderByExecutionStartTimeDesc(contest, projectIds) + .distinctBy { it.execution.project } } diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/entities/ContestResult.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/entities/ContestResult.kt index 47717599ba..251c4e0891 100644 --- a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/entities/ContestResult.kt +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/entities/ContestResult.kt @@ -1,5 +1,8 @@ package com.saveourtool.save.entities +import com.saveourtool.save.execution.ExecutionStatus +import com.saveourtool.save.utils.LocalDateTime +import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable /** @@ -8,11 +11,20 @@ import kotlinx.serialization.Serializable * @property organizationName name of an organization in which given project is * @property score that project got in contest * @property contestName + * @property submissionTime + * @property submissionStatus + * @property sdk + * @property hasFailedTest */ @Serializable data class ContestResult( val projectName: String, val organizationName: String, val contestName: String, - val score: Double?, + val score: Double? = null, + @Contextual + val submissionTime: LocalDateTime? = null, + val submissionStatus: ExecutionStatus = ExecutionStatus.PENDING, + val sdk: String = "", + val hasFailedTest: Boolean = false, ) diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionDto.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionDto.kt index 9740cbc866..7f5fc8fb47 100644 --- a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionDto.kt +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionDto.kt @@ -37,4 +37,24 @@ data class ExecutionDto( val matchedChecks: Long, val expectedChecks: Long, val unexpectedChecks: Long, -) +) { + companion object { + val empty = ExecutionDto( + id = -1, + status = ExecutionStatus.PENDING, + type = ExecutionType.STANDARD, + version = null, + startTime = -1, + endTime = null, + allTests = 0, + runningTests = 0, + passedTests = 0, + failedTests = 0, + skippedTests = 0, + unmatchedChecks = 0, + matchedChecks = 0, + expectedChecks = 0, + unexpectedChecks = 0, + ) + } +} diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionType.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionType.kt index 02c8f93806..52a9f4e0c0 100644 --- a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionType.kt +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/execution/ExecutionType.kt @@ -8,7 +8,12 @@ import kotlinx.serialization.Serializable @Serializable enum class ExecutionType { /** - * project from git + * Project on contest + */ + CONTEST, + + /** + * Project from git */ GIT, diff --git a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/LnkContestExecution.kt b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/LnkContestExecution.kt index e228531a1e..6eb3141277 100644 --- a/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/LnkContestExecution.kt +++ b/save-cloud-common/src/jvmMain/kotlin/com/saveourtool/save/entities/LnkContestExecution.kt @@ -23,4 +23,18 @@ class LnkContestExecution( var contest: Contest, var score: Double -) : BaseEntity() +) : BaseEntity() { + /** + * @return [ContestResult] from [LnkContestExecution] + */ + fun toContestResult() = ContestResult( + execution.project.name, + execution.project.organization.name, + contest.name, + score, + execution.startTime, + execution.status, + execution.sdk, + execution.failedTests != 0L, + ) +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt index b149c85371..b819b1b381 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt @@ -94,6 +94,14 @@ class App : ComponentWithScope() { currentContestName = params["contestName"] } } + private val contestExecutionView: FC = withRouter { _, params -> + ContestExecutionView::class.react { + currentUserInfo = state.userInfo + contestName = params["contestName"]!! + organizationName = params["organizationName"]!! + projectName = params["projectName"]!! + } + } private val organizationView: FC = withRouter { _, params -> OrganizationView::class.react { organizationName = params["owner"]!! @@ -184,6 +192,11 @@ class App : ComponentWithScope() { element = contestView.create() } + Route { + path = "/${FrontendRoutes.CONTESTS.path}/:contestName/:organizationName/:projectName" + element = contestExecutionView.create() + } + Route { path = "/${state.userInfo?.name}/${FrontendRoutes.SETTINGS_PROFILE.path}" element = state.userInfo?.name?.let { diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionStatistics.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionLabels.kt similarity index 61% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionStatistics.kt rename to save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionLabels.kt index 2e74774435..41073e8deb 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionStatistics.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionLabels.kt @@ -9,15 +9,19 @@ package com.saveourtool.save.frontend.components.basic import com.saveourtool.save.execution.ExecutionDto import com.saveourtool.save.execution.ExecutionStatus +import com.saveourtool.save.frontend.externals.fontawesome.faRedo +import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon import csstype.ClassName import csstype.Width -import react.FC -import react.Props +import org.w3c.dom.HTMLAnchorElement +import react.ChildrenBuilder import react.dom.aria.AriaRole import react.dom.aria.ariaValueMax import react.dom.aria.ariaValueMin import react.dom.aria.ariaValueNow +import react.dom.events.MouseEvent +import react.dom.html.ReactHTML.a import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.h1 import react.dom.html.ReactHTML.i @@ -25,26 +29,6 @@ import react.dom.html.ReactHTML.img import kotlinx.js.jso -/** - * A component which displays statistics about an execution from its props - */ -val executionStatistics = executionStatistics() - -/** - * A component which displays a GIF if tests not found - */ -val executionTestsNotFound = executionTestsNotFound() - -/** - * [Props] for execution statistics component - */ -external interface ExecutionStatisticsProps : Props { - /** - * And instance of [ExecutionDto], which should be passed from parent component - */ - var executionDto: ExecutionDto? -} - /** * Class contains all execution statistics values for rending * @@ -92,14 +76,17 @@ internal class ExecutionStatisticsValues(executionDto: ExecutionDto?) { val recallRate: String init { - val isInProgress = executionDto?.run { status == ExecutionStatus.RUNNING || status == ExecutionStatus.PENDING } ?: true + val isRunning = executionDto?.run { status == ExecutionStatus.RUNNING || status == ExecutionStatus.PENDING } ?: false val isSuccess = executionDto?.run { passedTests == allTests } ?: false - this.style = if (isInProgress) { - "info" + val hasFailingTests = executionDto?.run { failedTests != 0L } ?: false + this.style = if (hasFailingTests) { + "danger" } else if (isSuccess) { "success" + } else if (isRunning) { + "info" } else { - "danger" + "secondary" } this.allTests = executionDto?.allTests?.toString() ?: "0" this.passedTests = executionDto?.passedTests?.toString() ?: "0" @@ -124,13 +111,118 @@ internal class ExecutionStatisticsValues(executionDto: ExecutionDto?) { } /** - * A component which displays a GIF if tests not found + * Function that renders Project version label, execution statistics label, pass rate label and rerun button. + * Rerun button is rendered only if [onRerunExecution] is provided. + * + * @param executionDto + * @param classes [ClassName]s that will be applied to highest div + * @param innerClasses [ClassName]s that will be applied to each label + * @param height height of label + * @param onRerunExecution + */ +fun ChildrenBuilder.displayExecutionInfoHeader( + executionDto: ExecutionDto?, + classes: String = "", + innerClasses: String = "col flex-wrap m-2", + height: String = "h-100", + onRerunExecution: ((MouseEvent) -> Unit)? = null, +) { + val relativeWidth = onRerunExecution?.let { "min-vw-25" } ?: "min-vw-33" + div { + className = ClassName(classes) + displayProjectVersion(executionDto, "$relativeWidth $innerClasses", height) + displayPassRate(executionDto, "$relativeWidth $innerClasses", height) + displayStatistics(executionDto, "$relativeWidth $innerClasses", height) + displayRerunExecutionButton(executionDto, "$relativeWidth $innerClasses", height, onRerunExecution) + } +} + +/** + * Function that renders Rerun execution button + * + * @param executionDto + * @param classes [ClassName]s that will be applied to highest div + * @param height height of label + * @param onRerunExecution onClick callback + */ +fun ChildrenBuilder.displayRerunExecutionButton( + executionDto: ExecutionDto?, + classes: String = "", + height: String = "h-100", + onRerunExecution: ((MouseEvent) -> Unit)?, +) { + onRerunExecution?.let { + val borderColor = when { + executionDto == null -> "secondary" + executionDto.status == ExecutionStatus.ERROR || executionDto.failedTests != 0L -> "danger" + executionDto.status == ExecutionStatus.RUNNING || executionDto.status == ExecutionStatus.PENDING -> "info" + executionDto.status == ExecutionStatus.FINISHED -> "success" + else -> "secondary" + } + div { + className = ClassName(classes) + div { + className = ClassName("card border-left-$borderColor shadow $height py-2") + div { + className = ClassName("card-body d-flex justify-content-start align-items-center") + div { + className = ClassName("row no-gutters mx-auto justify-content-start") + a { + href = "" + +"Rerun execution" + fontAwesomeIcon(icon = faRedo, classes = "ml-2") + onClick = onRerunExecution + } + } + } + } + } + } +} + +/** + * Function that renders label with project version * - * @return a functional react component + * @param executionDto + * @param classes [ClassName]s that will be applied to highest div + * @param height height of label */ -private fun executionTestsNotFound() = FC { props -> - val count = props.executionDto?.allTests - val status = props.executionDto?.status +fun ChildrenBuilder.displayProjectVersion( + executionDto: ExecutionDto?, + classes: String = "", + height: String = "h-100", +) { + val statusColor = when { + executionDto == null -> "bg-secondary" + executionDto.status == ExecutionStatus.ERROR || executionDto.failedTests != 0L -> "bg-danger" + executionDto.status == ExecutionStatus.RUNNING || executionDto.status == ExecutionStatus.PENDING -> "bg-info" + executionDto.status == ExecutionStatus.FINISHED -> "bg-success" + else -> "bg-secondary" + } + div { + className = ClassName(classes) + div { + className = ClassName("card $statusColor text-white $height shadow py-2") + div { + className = ClassName("card-body") + +(executionDto?.status?.name ?: "N/A") + div { + className = ClassName("text-white-50 small") + +"Project version: ${executionDto?.version ?: "N/A"}" + } + } + } + } +} + +/** + * A function that displays a GIF if tests not found + * + * @param executionDto + */ +fun ChildrenBuilder.displayTestNotFound(executionDto: ExecutionDto?) { + val count = executionDto?.allTests + val status = executionDto?.status if (count == 0L && status != ExecutionStatus.PENDING) { div { className = ClassName("d-flex justify-content-center") @@ -156,19 +248,24 @@ private fun executionTestsNotFound() = FC { props -> } } -@Suppress( - "MAGIC_NUMBER", - "TOO_LONG_FUNCTION", - "LongMethod", - "ComplexMethod" -) -private fun executionStatistics() = FC { props -> - val values = ExecutionStatisticsValues(props.executionDto) - +/** + * Function that renders pass rate label + * + * @param executionDto + * @param classes [ClassName]s that will be applied to highest div + * @param height height of label + */ +@Suppress("MAGIC_NUMBER", "TOO_LONG_FUNCTION") +fun ChildrenBuilder.displayPassRate( + executionDto: ExecutionDto?, + classes: String = "", + height: String = "h-100", +) { + val values = ExecutionStatisticsValues(executionDto) div { - className = ClassName("col-xl-3 col-md-6 mb-4") + className = ClassName(classes) div { - className = ClassName("card border-left-info shadow h-100 py-2") + className = ClassName("card border-left-${values.style} shadow $height py-2") div { className = ClassName("card-body") div { @@ -216,14 +313,29 @@ private fun executionStatistics() = FC { props -> } } } +} +/** + * Function that renders execution statistics label + * + * @param executionDto + * @param classes [ClassName]s that will be applied to highest div + * @param height height of label + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +fun ChildrenBuilder.displayStatistics( + executionDto: ExecutionDto?, + classes: String = "", + height: String = "h-100", +) { + val values = ExecutionStatisticsValues(executionDto) div { - className = ClassName("col-xl-4 col-md-6 mb-4") + className = ClassName(classes) div { - className = ClassName("card border-left-${values.style} shadow h-100 py-2") + className = ClassName("card border-left-${values.style} shadow $height py-2") div { className = ClassName("card-body") - div() { + div { className = ClassName("row no-gutters align-items-center") div { className = ClassName("col mr-2") diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/InputForms.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/InputForms.kt index b276f4a7d4..6f88c1b017 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/InputForms.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/InputForms.kt @@ -167,7 +167,7 @@ internal fun ChildrenBuilder.inputTextFormRequired( htmlFor = form.name +name span { - className = ClassName("text-red text-left") + className = ClassName("text-danger text-left") +"*" } } @@ -306,7 +306,7 @@ internal fun ChildrenBuilder.inputTextDisabled( +name if (isRequired) { span { - className = ClassName("text-red text-left") + className = ClassName("text-danger text-left") +"*" } } @@ -375,7 +375,7 @@ internal fun ChildrenBuilder.inputDateFormRequired( htmlFor = form.name +text span { - className = ClassName("text-red text-left") + className = ClassName("text-danger text-left") +"*" } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/SelectForm.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/SelectForm.kt index 665c94049f..b3cb8fe95d 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/SelectForm.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/SelectForm.kt @@ -96,7 +96,7 @@ fun selectFormRequired() = FC> { props -> } +props.formName span { - className = ClassName("text-red") + className = ClassName("text-danger") id = "${props.formType.name}Span" +"*" } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestResultsMenu.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestResultsMenu.kt deleted file mode 100644 index 03fba00c7b..0000000000 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestResultsMenu.kt +++ /dev/null @@ -1,48 +0,0 @@ -@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") - -package com.saveourtool.save.frontend.components.basic.contests - -import csstype.* -import react.* -import react.dom.html.ReactHTML.div -import react.dom.html.ReactHTML.h6 - -import kotlinx.js.jso - -/** - * RESULTS tab in ContestView - */ -val contestResultsMenu = contestResultsMenu() - -/** - * ContestResultsMenu component props - */ -external interface ContestResultsMenuProps : Props { - /** - * Name of a current contest - */ - var contestName: String -} - -@Suppress( - "TOO_LONG_FUNCTION", - "LongMethod", - "MAGIC_NUMBER", - "AVOID_NULL_CHECKS" -) -private fun contestResultsMenu( -) = FC { - div { - className = ClassName("mb-3") - style = jso { - justifyContent = JustifyContent.center - display = Display.flex - flexDirection = FlexDirection.column - alignItems = AlignItems.center - } - h6 { - className = ClassName("text-center") - +"You didn't submit your tool yet." - } - } -} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSubmissionsMenu.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSubmissionsMenu.kt new file mode 100644 index 0000000000..c2cfb1088c --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSubmissionsMenu.kt @@ -0,0 +1,120 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") + +package com.saveourtool.save.frontend.components.basic.contests + +import com.saveourtool.save.entities.ContestResult +import com.saveourtool.save.execution.ExecutionStatus +import com.saveourtool.save.frontend.components.tables.tableComponent +import com.saveourtool.save.frontend.utils.* +import csstype.* +import react.* +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div + +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.td +import react.table.columns + +/** + * SUBMISSIONS tab in ContestView + */ +val contestSubmissionsMenu = contestSubmissionsMenu() + +@Suppress("MAGIC_NUMBER") +private val myProjectsTable = tableComponent( + columns = columns { + column(id = "project_name", header = "Project Name", { this }) { cellProps -> + Fragment.create { + td { + a { + cellProps.value.let { + href = "#/contests/${it.contestName}/${it.organizationName}/${it.projectName}" + +"${it.organizationName}/${it.projectName}" + } + } + } + } + } + column(id = "sdk", header = "SDK", { this }) { cellProps -> + Fragment.create { + td { + +cellProps.value.sdk + } + } + } + column(id = "submission_time", header = "Last submission time", { this }) { cellProps -> + Fragment.create { + td { + +(cellProps.value.submissionTime?.toString()?.replace("T", " ") ?: "No data") + } + } + } + column(id = "status", header = "Last submission status", { this }) { cellProps -> + Fragment.create { + td { + cellProps.value.let { displayStatus(it.submissionStatus, it.hasFailedTest, it.score) } + } + } + } + }, + initialPageSize = 10, + useServerPaging = false, + usePageSelection = false, +) + +/** + * ContestSubmissionsMenu component [Props] + */ +external interface ContestSubmissionsMenuProps : Props { + /** + * Name of a current contest + */ + var contestName: String +} + +private fun ChildrenBuilder.displayStatus(status: ExecutionStatus, hasFailedTests: Boolean, score: Double?) { + span { + className = when (status) { + ExecutionStatus.PENDING -> ClassName("") + ExecutionStatus.RUNNING -> ClassName("") + ExecutionStatus.ERROR -> ClassName("text-danger") + ExecutionStatus.FINISHED -> if (hasFailedTests) { + ClassName("text-danger") + } else { + ClassName("text-success") + } + } + +"${status.name} " + } + displayScore(status, score) +} + +private fun ChildrenBuilder.displayScore(status: ExecutionStatus, score: Double?) { + if (status == ExecutionStatus.FINISHED) { + span { + +"${score?.let { ("$it/100") }}" + } + } +} + +private fun contestSubmissionsMenu( +) = FC { props -> + div { + className = ClassName("d-flex justify-content-center") + div { + className = ClassName("col-8") + myProjectsTable { + tableHeader = "My Submissions" + getData = { _, _ -> + get( + url = "$apiUrl/contests/${props.contestName}/my-results", + headers = jsonHeaders, + ::loadingHandler, + ) + .decodeFromJsonString>() + } + getPageCount = null + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestParticipantsMenu.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSummaryMenu.kt similarity index 89% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestParticipantsMenu.kt rename to save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSummaryMenu.kt index b95de4cb29..b71a3f55f7 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestParticipantsMenu.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSummaryMenu.kt @@ -16,14 +16,14 @@ import react.dom.html.ReactHTML.h6 import kotlinx.js.jso /** - * PARTICIPANTS tab in ContestView + * SUMMARY tab in ContestView */ -val contestParticipantsMenu = contestParticipantsMenu() +val contestSummaryMenu = contestSummaryMenu() /** - * ContestParticipantsMenu component props + * ContestSummaryMenu component [Props] */ -external interface ContestParticipantsMenuProps : Props { +external interface ContestSummaryMenuProps : Props { /** * Name of a current contest */ @@ -39,7 +39,7 @@ external interface ContestParticipantsMenuProps : Props { "MAGIC_NUMBER", "AVOID_NULL_CHECKS" ) -private fun contestParticipantsMenu() = FC { props -> +private fun contestSummaryMenu() = FC { props -> val (results, setResults) = useState>(emptyList()) useRequest(isDeferred = false) { val projectResults = get( @@ -55,7 +55,6 @@ private fun contestParticipantsMenu() = FC { props .sortedByDescending { it.score } setResults(projectResults) }() - div { className = ClassName("mb-3") style = jso { diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestExecutionView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestExecutionView.kt new file mode 100644 index 0000000000..81824b0927 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestExecutionView.kt @@ -0,0 +1,250 @@ +/** + * View for tests execution history + */ + +package com.saveourtool.save.frontend.components.views + +import com.saveourtool.save.execution.ExecutionDto +import com.saveourtool.save.execution.ExecutionStatus +import com.saveourtool.save.frontend.components.RequestStatusContext +import com.saveourtool.save.frontend.components.basic.* +import com.saveourtool.save.frontend.components.requestStatusContext +import com.saveourtool.save.frontend.components.tables.tableComponent +import com.saveourtool.save.frontend.externals.chart.DataPieChart +import com.saveourtool.save.frontend.externals.chart.PieChartColors +import com.saveourtool.save.frontend.externals.chart.pieChart +import com.saveourtool.save.frontend.externals.fontawesome.faCheck +import com.saveourtool.save.frontend.externals.fontawesome.faExclamationTriangle +import com.saveourtool.save.frontend.externals.fontawesome.faSpinner +import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.themes.Colors +import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.info.UserInfo + +import csstype.* +import react.* +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.td +import react.dom.html.ReactHTML.tr +import react.table.columns +import react.table.useExpanded +import react.table.usePagination +import react.table.useSortBy + +import kotlinx.datetime.Instant +import kotlinx.js.get +import kotlinx.js.jso + +/** + * [Props] for [ContestExecutionView] + */ +external interface ContestExecutionViewProps : PropsWithChildren { + /** + * Info about current user + */ + var currentUserInfo: UserInfo? + + /** + * Name of a contest + */ + var contestName: String + + /** + * Name of an organization + */ + var organizationName: String + + /** + * Name of a project + */ + var projectName: String +} + +/** + * A table to display execution results of a project in a contest. + */ +@JsExport +@OptIn(ExperimentalJsExport::class) +class ContestExecutionView : AbstractView(false) { + @Suppress("MAGIC_NUMBER") + private val executionsTable = tableComponent( + columns = columns { + column("result", "", { status }) { cellProps -> + val result = when (cellProps.row.original.status) { + ExecutionStatus.ERROR -> ResultColorAndIcon("text-danger", faExclamationTriangle) + ExecutionStatus.PENDING -> ResultColorAndIcon("text-success", faSpinner) + ExecutionStatus.RUNNING -> ResultColorAndIcon("text-success", faSpinner) + ExecutionStatus.FINISHED -> if (cellProps.row.original.failedTests != 0L) { + ResultColorAndIcon("text-danger", faExclamationTriangle) + } else { + ResultColorAndIcon("text-success", faCheck) + } + } + Fragment.create { + td { + fontAwesomeIcon(result.resIcon, classes = result.resColor) + } + } + } + column("status", "Status", { this }) { cellProps -> + Fragment.create { + td { + style = jso { + textDecoration = "underline".unsafeCast() + color = "blue".unsafeCast() + cursor = "pointer".unsafeCast() + } + onClick = { + cellProps.row.toggleRowExpanded() + } + + +"${cellProps.value.status}" + } + } + } + column("startDate", "Start time", { startTime }) { cellProps -> + Fragment.create { + td { + a { + +(formattingDate(cellProps.value) ?: "Starting") + } + } + } + } + column("endDate", "End time", { endTime }) { cellProps -> + Fragment.create { + td { + a { + +(formattingDate(cellProps.value) ?: "Starting") + } + } + } + } + column("running", "Running", { runningTests }) { cellProps -> + Fragment.create { + td { + a { + +"${cellProps.value}" + } + } + } + } + column("passed", "Passed", { passedTests }) { cellProps -> + Fragment.create { + td { + a { + +"${cellProps.value}" + } + } + } + } + column("failed", "Failed", { failedTests }) { cellProps -> + Fragment.create { + td { + a { + +"${cellProps.value}" + } + } + } + } + column("skipped", "Skipped", { skippedTests }) { cellProps -> + Fragment.create { + td { + a { + +"${cellProps.value}" + } + } + } + } + }, + getRowProps = { row -> + val color = when (row.original.status) { + ExecutionStatus.ERROR -> Colors.RED + ExecutionStatus.PENDING -> Colors.GREY + ExecutionStatus.RUNNING -> if (row.original.failedTests != 0L) Colors.DARK_RED else Colors.GREY + ExecutionStatus.FINISHED -> if (row.original.failedTests != 0L) Colors.DARK_RED else Colors.GREEN + } + jso { + style = jso { + background = color.value.unsafeCast() + } + } + }, + renderExpandedRow = { tableInstance, row -> + tr { + td { + colSpan = tableInstance.columns.size + div { + className = ClassName("row") + displayExecutionInfoHeader(row.original, "row col-11") + div { + className = ClassName("col-1") + pieChart( + getPieChartData(row.original), + ) { pieProps -> + pieProps.animate = true + pieProps.segmentsShift = 2 + pieProps.radius = 47 + } + } + } + } + } + }, + plugins = arrayOf( + useSortBy, + useExpanded, + usePagination + ) + ) + + private fun getPieChartData(execution: ExecutionDto) = execution + .run { + arrayOf( + DataPieChart("Running tests", runningTests.toInt(), PieChartColors.GREY.hex), + DataPieChart("Failed tests", failedTests.toInt(), PieChartColors.RED.hex), + DataPieChart("Passed tests", passedTests.toInt(), PieChartColors.GREEN.hex), + ) + } + + @Suppress( + "TOO_LONG_FUNCTION", + "ForbiddenComment", + "LongMethod", + ) + override fun ChildrenBuilder.render() { + executionsTable { + tableHeader = "Executions details" + getData = { _, _ -> + get( + url = "$apiUrl/contests/${props.contestName}/executions/${props.organizationName}/${props.projectName}", + headers = jsonHeaders, + loadingHandler = ::loadingHandler + ) + .unsafeMap { + it.decodeFromJsonString>() + } + } + getPageCount = null + } + } + + private fun formattingDate(date: Long?) = date?.let { + Instant.fromEpochSeconds(date, 0) + .toString() + .replace("[TZ]".toRegex(), " ") + } + + /** + * @property resColor + * @property resIcon + */ + private data class ResultColorAndIcon(val resColor: String, val resIcon: dynamic) + + companion object : RStatics>(ContestExecutionView::class) { + init { + contextType = requestStatusContext + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt index 73d5105684..d157aa9cb2 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt @@ -5,8 +5,8 @@ package com.saveourtool.save.frontend.components.views import com.saveourtool.save.entities.ContestDto import com.saveourtool.save.frontend.components.RequestStatusContext import com.saveourtool.save.frontend.components.basic.contests.contestInfoMenu -import com.saveourtool.save.frontend.components.basic.contests.contestParticipantsMenu -import com.saveourtool.save.frontend.components.basic.contests.contestResultsMenu +import com.saveourtool.save.frontend.components.basic.contests.contestSubmissionsMenu +import com.saveourtool.save.frontend.components.basic.contests.contestSummaryMenu import com.saveourtool.save.frontend.components.requestStatusContext import com.saveourtool.save.frontend.utils.* import com.saveourtool.save.frontend.utils.classLoadingHandler @@ -27,8 +27,8 @@ import react.dom.html.ReactHTML.p */ enum class ContestMenuBar { INFO, - PARTICIPANTS, - RESULTS, + SUBMISSIONS, + SUMMARY, ; } @@ -72,20 +72,20 @@ class ContestView : AbstractView(false) { when (state.selectedMenu) { ContestMenuBar.INFO -> renderInfo() - ContestMenuBar.RESULTS -> renderResults() - ContestMenuBar.PARTICIPANTS -> renderParticipants() + ContestMenuBar.SUBMISSIONS -> renderSubmissions() + ContestMenuBar.SUMMARY -> renderSummary() else -> throw NotImplementedError() } } - private fun ChildrenBuilder.renderResults() { - contestResultsMenu { + private fun ChildrenBuilder.renderSubmissions() { + contestSubmissionsMenu { contestName = props.currentContestName ?: "UNDEFINED" } } - private fun ChildrenBuilder.renderParticipants() { - contestParticipantsMenu { + private fun ChildrenBuilder.renderSummary() { + contestSummaryMenu { contestName = props.currentContestName ?: "UNDEFINED" } } @@ -101,24 +101,29 @@ class ContestView : AbstractView(false) { className = ClassName("row align-items-center justify-content-center") nav { className = ClassName("nav nav-tabs mb-4") - ContestMenuBar.values().forEachIndexed { i, contestMenu -> - li { - className = ClassName("nav-item") - val classVal = - if ((i == 0 && state.selectedMenu == null) || state.selectedMenu == contestMenu) " active font-weight-bold" else "" - p { - className = ClassName("nav-link $classVal text-gray-800") - onClick = { - if (state.selectedMenu != contestMenu) { - setState { - selectedMenu = contestMenu + ContestMenuBar.values() + .forEachIndexed { i, contestMenu -> + li { + className = ClassName("nav-item") + val classVal = + if ((i == 0 && state.selectedMenu == null) || state.selectedMenu == contestMenu) { + " active font-weight-bold" + } else { + "" + } + p { + className = ClassName("nav-link $classVal text-gray-800") + onClick = { + if (state.selectedMenu != contestMenu) { + setState { + selectedMenu = contestMenu + } } } + +contestMenu.name } - +contestMenu.name } } - } } } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ExecutionView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ExecutionView.kt index 153f7c5bc2..264cedf83c 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ExecutionView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ExecutionView.kt @@ -9,7 +9,6 @@ import com.saveourtool.save.core.logging.describe import com.saveourtool.save.domain.TestResultDebugInfo import com.saveourtool.save.domain.TestResultStatus import com.saveourtool.save.execution.ExecutionDto -import com.saveourtool.save.execution.ExecutionStatus import com.saveourtool.save.execution.ExecutionUpdateDto import com.saveourtool.save.execution.TestExecutionFilters import com.saveourtool.save.frontend.components.RequestStatusContext @@ -17,8 +16,6 @@ import com.saveourtool.save.frontend.components.basic.* import com.saveourtool.save.frontend.components.requestStatusContext import com.saveourtool.save.frontend.components.tables.TableProps import com.saveourtool.save.frontend.components.tables.tableComponent -import com.saveourtool.save.frontend.externals.fontawesome.faRedo -import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon import com.saveourtool.save.frontend.externals.table.useFilters import com.saveourtool.save.frontend.http.getDebugInfoFor import com.saveourtool.save.frontend.http.getExecutionInfoFor @@ -28,7 +25,6 @@ import com.saveourtool.save.frontend.utils.* import csstype.* import org.w3c.fetch.Headers import react.* -import react.dom.html.ReactHTML.a import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.td import react.dom.html.ReactHTML.th @@ -356,65 +352,20 @@ class ExecutionView : AbstractView(false) { override fun ChildrenBuilder.render() { div { div { - className = ClassName("d-flex") - val statusVal = state.executionDto?.status - val statusColor = when (statusVal) { - ExecutionStatus.ERROR -> "bg-danger" - ExecutionStatus.RUNNING, ExecutionStatus.PENDING -> "bg-info" - ExecutionStatus.FINISHED -> "bg-success" - else -> "bg-secondary" - } - - div { - className = ClassName("col-md-2 mb-4") - div { - className = ClassName("card $statusColor text-white h-100 shadow py-2") - div { - className = ClassName("card-body") - +(statusVal?.name ?: "N/A") - div { - className = ClassName("text-white-50 small") - +"Project version: ${(state.executionDto?.version ?: "N/A")}" - } - } - } - } - - executionStatistics { - executionDto = state.executionDto - } - div { - className = ClassName("col-md-3 mb-4") - div { - className = ClassName("card border-left-info shadow h-100 py-2") - div { - className = ClassName("card-body") - div { - className = ClassName("row no-gutters align-items-center mx-auto") - a { - href = "" - +"Rerun execution" - fontAwesomeIcon(icon = faRedo, classes = "ml-2") - @Suppress("TOO_MANY_LINES_IN_LAMBDA") - onClick = { event -> - scope.launch { - val response = post( - "$apiUrl/run/re-trigger?executionId=${props.executionId}", - Headers(), - body = undefined, - loadingHandler = ::classLoadingHandler, - ) - if (response.ok) { - window.alert("Rerun request successfully submitted") - window.location.reload() - } - } - event.preventDefault() - } - } - } + displayExecutionInfoHeader(state.executionDto, "row mb-2") { event -> + scope.launch { + val response = post( + "$apiUrl/run/re-trigger?executionId=${props.executionId}", + Headers(), + body = undefined, + loadingHandler = ::classLoadingHandler, + ) + if (response.ok) { + window.alert("Rerun request successfully submitted") + window.location.reload() } } + event.preventDefault() } } } @@ -425,10 +376,7 @@ class ExecutionView : AbstractView(false) { getData = { page, size -> post( url = "$apiUrl/test-executions?executionId=${props.executionId}&page=$page&size=$size&checkDebugInfo=true", - headers = Headers().apply { - set("Accept", "application/json") - set("Content-Type", "application/json") - }, + headers = jsonHeaders, body = Json.encodeToString(filters), loadingHandler = ::classLoadingHandler, ).unsafeMap { @@ -463,9 +411,7 @@ class ExecutionView : AbstractView(false) { count / pageSize + 1 } } - executionTestsNotFound { - executionDto = state.executionDto - } + displayTestNotFound(state.executionDto) } private fun getUrlWithFiltersParams(filterValue: TestExecutionFilters) = "${window.location.href.substringBefore("?")}${filterValue.toQueryParams()}" diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/chart/PieChartBuilder.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/chart/PieChartBuilder.kt index 0768fe1da2..c29cc4caa1 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/chart/PieChartBuilder.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/chart/PieChartBuilder.kt @@ -2,12 +2,24 @@ * kotlin-react builders for PieChart components */ +@file:Suppress("FILE_NAME_MATCH_CLASS") + package com.saveourtool.save.frontend.externals.chart import react.ChildrenBuilder import react.react import kotlin.random.Random +/** + * @property hex + */ +enum class PieChartColors(val hex: String) { + GREEN("#89E894"), + GREY("#CCCCC4"), + RED("#FF8989"), + ; +} + /** * @param data dataset for pie chart * @param handler handler to set up a component diff --git a/save-frontend/src/main/resources/scss/utilities/_text.scss b/save-frontend/src/main/resources/scss/utilities/_text.scss index 22a569b350..4fb78afb40 100644 --- a/save-frontend/src/main/resources/scss/utilities/_text.scss +++ b/save-frontend/src/main/resources/scss/utilities/_text.scss @@ -44,10 +44,6 @@ color: $gray-900 !important; } -.text-red { - color: $red !important; -} - .icon-circle { height: 2.5rem; width: 2.5rem; diff --git a/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionStatisticsValuesTest.kt b/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionStatisticsValuesTest.kt index 606d6806f9..19029a3de4 100644 --- a/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionStatisticsValuesTest.kt +++ b/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/components/basic/ExecutionStatisticsValuesTest.kt @@ -10,7 +10,7 @@ class ExecutionStatisticsValuesTest { @Test fun nullExecution() { val executionStatisticsValues = ExecutionStatisticsValues(null) - assertEquals("info", executionStatisticsValues.style) + assertEquals("secondary", executionStatisticsValues.style) assertEquals("0", executionStatisticsValues.allTests) assertEquals("0", executionStatisticsValues.passedTests) assertEquals("0", executionStatisticsValues.failedTests) @@ -40,7 +40,7 @@ class ExecutionStatisticsValuesTest { unexpectedChecks = 5, ) val executionStatisticsValues = ExecutionStatisticsValues(executionDto) - assertEquals("info", executionStatisticsValues.style) + assertEquals("danger", executionStatisticsValues.style) assertEquals("10", executionStatisticsValues.allTests) assertEquals("3", executionStatisticsValues.passedTests) assertEquals("1", executionStatisticsValues.failedTests)