Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: UI prototype using React hooks to render live data #1857

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions save-backend/backend-api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,63 @@
}
],
"paths": {
"/api/v1/a/validate": {
"get": {
"operationId": "sequential",
"parameters": [
{
"in": "header",
"name": "Accept",
"schema": {
"type": "string",
"enum": [
"application/x-ndjson"
]
}
}
],
"responses": {
"401": {
"content": {
"application/x-ndjson": {
"schema": {
"$ref": "#/components/schemas/TestSuiteValidationResult"
}
},
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/TestSuiteValidationResult"
}
}
},
"description": "Unauthorized"
},
"406": {
"content": {
"application/x-ndjson": {
"schema": {
"$ref": "#/components/schemas/TestSuiteValidationResult"
}
},
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/TestSuiteValidationResult"
}
}
},
"description": "Could not find acceptable representation."
}
},
"security": [
{
"basic": []
}
],
"tags": [
"test-suite-validation-controller"
]
}
},
"/api/v1/avatar/upload": {
"post": {
"description": "Upload an avatar for user or organization.",
Expand Down Expand Up @@ -9641,6 +9698,26 @@
}
}
},
"TestSuiteValidationResult": {
"required": [
"checkId",
"checkName",
"percentage"
],
"type": "object",
"properties": {
"checkId": {
"type": "string"
},
"checkName": {
"type": "string"
},
"percentage": {
"type": "integer",
"format": "int32"
}
}
},
"TestSuiteVersioned": {
"required": [
"description",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.http.ResponseEntity
import reactor.core.publisher.Flux
import reactor.core.publisher.ParallelFlux
import java.nio.ByteBuffer

typealias ByteBufferFluxResponse = ResponseEntity<Flux<ByteBuffer>>
internal typealias FluxResponse<T> = ResponseEntity<Flux<T>>
internal typealias ParallelFluxResponse<T> = ResponseEntity<ParallelFlux<T>>
internal typealias ByteBufferFluxResponse = FluxResponse<ByteBuffer>

/**
* An entrypoint for spring for save-backend
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.saveourtool.save.backend.controllers

import com.saveourtool.save.backend.ParallelFluxResponse
import com.saveourtool.save.backend.utils.withHttpHeaders
import com.saveourtool.save.configs.ApiSwaggerSupport
import com.saveourtool.save.test.TestSuiteValidationResult
import com.saveourtool.save.v1
import io.swagger.v3.oas.annotations.responses.ApiResponse
import org.springframework.http.HttpHeaders.ACCEPT
import org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE
import org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Flux
import reactor.core.publisher.ParallelFlux
import reactor.core.scheduler.Schedulers
import kotlin.streams.asStream
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
* Demonstrates _Server-Sent Events_ (SSE).
*/
@ApiSwaggerSupport
@RestController
@RequestMapping(path = ["/api/$v1/a"])
class TestSuiteValidationController {
/**
* @return a stream of events.
*/
@GetMapping(
path = ["/validate"],
headers = [
"$ACCEPT=$TEXT_EVENT_STREAM_VALUE",
"$ACCEPT=$APPLICATION_NDJSON_VALUE",
],
produces = [
TEXT_EVENT_STREAM_VALUE,
APPLICATION_NDJSON_VALUE,
],
)
@ApiResponse(responseCode = "406", description = "Could not find acceptable representation.")
fun sequential(): ParallelFluxResponse<TestSuiteValidationResult> =
withHttpHeaders {
overallProgress()
}

@Suppress("MAGIC_NUMBER")
private fun singleCheck(
checkId: String,
checkName: String,
duration: Duration,
): Flux<TestSuiteValidationResult> {
@Suppress("MagicNumber")
val ticks = 0..100
Fixed Show fixed Hide fixed

val delayMillis = duration.inWholeMilliseconds / (ticks.count() - 1)

return Flux.fromStream(ticks.asSequence().asStream())
.map { percentage ->
TestSuiteValidationResult(
checkId = checkId,
checkName = checkName,
percentage = percentage,
)
}
.map { item ->
Thread.sleep(delayMillis)
item
}
.subscribeOn(Schedulers.boundedElastic())
}

@Suppress("MAGIC_NUMBER")
private fun overallProgress(): ParallelFlux<TestSuiteValidationResult> {
@Suppress("ReactiveStreamsUnusedPublisher")
val checks = arrayOf(
singleCheck(
"check A",
"Searching for plug-ins with zero tests",
10.seconds,
Fixed Show fixed Hide fixed
),

singleCheck(
"check B",
"Searching for test suites with wildcard mode",
20.seconds,
Fixed Show fixed Hide fixed
),

singleCheck(
"check C",
"Ordering pizza from the nearest restaurant",
30.seconds,
Fixed Show fixed Hide fixed
),
)

return when {
checks.isEmpty() -> Flux.empty<TestSuiteValidationResult>().parallel()

else -> checks.reduce { left, right ->
left.mergeWith(right)
}
.parallel(Runtime.getRuntime().availableProcessors())
.runOn(Schedulers.parallel())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.saveourtool.save.test

import kotlinx.serialization.Serializable

/**
* @property checkId the unique check id.
* @property checkName the human-readable check name.
* @property percentage the completion percentage (`0..100`).
*/
@Serializable
data class TestSuiteValidationResult(
val checkId: String,
val checkName: String,
val percentage: Int
) {
init {
@Suppress("MAGIC_NUMBER")
require(percentage in 0..100) {
percentage.toString()
}
}

override fun toString(): String =
when (percentage) {
100 -> "Check $checkName is complete."
else -> "Check $checkName is running, $percentage% complete\u2026"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
@file:Suppress("FILE_NAME_MATCH_CLASS")

package com.saveourtool.save.frontend.components.views

import com.saveourtool.save.test.TestSuiteValidationResult
import csstype.ClassName
import csstype.WhiteSpace
import csstype.Width
import js.core.jso
import react.FC
import react.Props
import react.dom.aria.AriaRole
import react.dom.aria.ariaValueMax
import react.dom.aria.ariaValueMin
import react.dom.aria.ariaValueNow
import react.dom.html.ReactHTML.div

@Suppress(
"MagicNumber",
"MAGIC_NUMBER",
)
val testSuiteValidationResultView: FC<TestSuiteValidationResultProps> = FC { props ->
props.validationResults.forEach { item ->
div {
div {
className = ClassName("progress progress-sm mr-2")
div {
className = ClassName("progress-bar bg-info")
role = "progressbar".unsafeCast<AriaRole>()
style = jso {
width = "${item.percentage}%".unsafeCast<Width>()
}
ariaValueMin = 0.0
Fixed Show fixed Hide fixed
ariaValueNow = item.percentage.toDouble()
ariaValueMax = 100.0
Fixed Show fixed Hide fixed
}
}
div {
style = jso {
whiteSpace = "pre".unsafeCast<WhiteSpace>()
}

+item.toString()
}
}
}
}

/**
* Properties for [testSuiteValidationResultView].
*
* @see testSuiteValidationResultView
*/
external interface TestSuiteValidationResultProps : Props {
/**
* Test suite validation results.
*/
var validationResults: Collection<TestSuiteValidationResult>
}
Loading