diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/validation/FrontendCosvRoutes.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/validation/FrontendCosvRoutes.kt index 70b18d0ef6..5c6a9369f9 100644 --- a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/validation/FrontendCosvRoutes.kt +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/validation/FrontendCosvRoutes.kt @@ -16,6 +16,7 @@ enum class FrontendCosvRoutes(val path: String) { ABOUT_US("about"), BAN("ban"), COOKIE("cookie"), + CREATE_ORGANIZATION("create-organization"), ERROR_404("404"), MANAGE_ORGANIZATIONS("organizations"), NOT_FOUND("not-found"), diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/basic/CosvOrganizationType.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/basic/CosvOrganizationType.kt new file mode 100644 index 0000000000..7911b0c586 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/basic/CosvOrganizationType.kt @@ -0,0 +1,12 @@ +package com.saveourtool.save.cosv.frontend.components.basic + +import com.saveourtool.save.frontend.common.components.views.organization.OrganizationMenuBar +import com.saveourtool.save.frontend.common.components.views.organization.OrganizationType + +object CosvOrganizationType : OrganizationType { + override val listTab: Array = arrayOf( + OrganizationMenuBar.INFO, + OrganizationMenuBar.VULNERABILITIES, + OrganizationMenuBar.SETTINGS + ) +} diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/routing/BasicRouting.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/routing/BasicRouting.kt index 86a6615c9e..9520e4d191 100644 --- a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/routing/BasicRouting.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/routing/BasicRouting.kt @@ -6,11 +6,14 @@ package com.saveourtool.save.cosv.frontend.routing +import com.saveourtool.save.cosv.frontend.components.basic.CosvOrganizationType import com.saveourtool.save.cosv.frontend.components.views.vuln.* import com.saveourtool.save.cosv.frontend.components.views.vuln.toprating.topRatingView import com.saveourtool.save.cosv.frontend.components.views.vuln.vulnerabilityCollectionView import com.saveourtool.save.cosv.frontend.components.views.welcome.vulnWelcomeView import com.saveourtool.save.frontend.common.components.views.FallbackView +import com.saveourtool.save.frontend.common.components.views.organization.createOrganizationView +import com.saveourtool.save.frontend.common.components.views.organization.organizationView import com.saveourtool.save.frontend.common.components.views.registrationView import com.saveourtool.save.frontend.common.components.views.userprofile.userProfileView import com.saveourtool.save.frontend.common.components.views.usersettings.userSettingsView @@ -29,6 +32,14 @@ import react.router.* val basicRouting: FC = FC { props -> useUserStatusRedirects(props.userInfo?.status) + val organizationView = withRouter { location, params -> + organizationView { + organizationName = params["owner"]!! + currentUserInfo = props.userInfo + organizationType = CosvOrganizationType + } + } + val userProfileView = withRouter { _, params -> userProfileView { userName = params["name"]!! @@ -68,6 +79,9 @@ val basicRouting: FC = FC { props -> vulnerabilityView.create() to "$VULNERABILITY_SINGLE/:identifier", cosvSchemaView.create() to VULN_COSV_SCHEMA, topRatingView.create() to VULN_TOP_RATING, + + createOrganizationView.create() to CREATE_ORGANIZATION, + organizationView.create() to ":owner", userProfileView.create() to "$PROFILE/:name", userSettingsView.create { diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/TestSuitesDisplayer.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/TestSuitesDisplayer.kt new file mode 100644 index 0000000000..606b7a5f36 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/TestSuitesDisplayer.kt @@ -0,0 +1,100 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", + "FILE_WILDCARD_IMPORTS", + "WildcardImport", + "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", +) + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.components.basic.testsuiteselector.TestSuiteSelectorMode +import com.saveourtool.save.testsuite.TestSuiteVersioned + +import react.ChildrenBuilder +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h5 +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.small +import web.cssom.ClassName + +/** + * @param testSuites + * @param selectedTestSuites + * @param displayMode if used not inside TestSuiteSelector, should be null, otherwise should be mode of TestSuiteSelector + * @param onTestSuiteClick + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +fun ChildrenBuilder.showAvailableTestSuites( + testSuites: List, + selectedTestSuites: List, + displayMode: TestSuiteSelectorMode?, + onTestSuiteClick: (TestSuiteVersioned) -> Unit, +) { + div { + className = ClassName("list-group") + testSuites.forEach { testSuite -> + val active = if (testSuite in selectedTestSuites) { + "active" + } else { + "" + } + a { + className = ClassName("btn list-group-item list-group-item-action $active") + onClick = { + onTestSuiteClick(testSuite) + } + div { + className = ClassName("d-flex w-100 justify-content-between") + h5 { + className = ClassName("mb-1") + +(testSuite.name) + } + small { + +testSuite.language + } + } + div { + className = ClassName("clearfix mb-1") + div { + className = ClassName("float-left") + p { + +testSuite.description + } + } + div { + className = ClassName("float-right") + if (displayMode.shouldDisplayVersion()) { + small { + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + title = "Hash of commit/branch name/tag name" + +testSuite.version + } + } + } + } + div { + className = ClassName("clearfix") + small { + className = ClassName("float-left") + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + title = "Test suite tags" + +testSuite.tags + } + + small { + className = ClassName("float-right") + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + title = "Plugin type" + +testSuite.plugins + } + } + } + } + } +} + +private fun TestSuiteSelectorMode?.shouldDisplayVersion() = this != null && this != TestSuiteSelectorMode.BROWSER diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestCreationComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestCreationComponent.kt new file mode 100644 index 0000000000..ff4f7affac --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestCreationComponent.kt @@ -0,0 +1,249 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") + +package com.saveourtool.save.frontend.common.components.basic.contests + +import com.saveourtool.save.entities.contest.ContestDto +import com.saveourtool.save.frontend.common.components.basic.* +import com.saveourtool.save.frontend.common.components.basic.testsuiteselector.showContestTestSuitesSelectorModal +import com.saveourtool.save.frontend.common.components.inputform.* +import com.saveourtool.save.frontend.common.components.inputform.inputTextDisabled +import com.saveourtool.save.frontend.common.components.inputform.inputTextFormRequired +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.externals.modal.Styles +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.noopLoadingHandler +import com.saveourtool.save.validation.FrontendRoutes +import com.saveourtool.save.validation.isValidName + +import org.w3c.fetch.Response +import react.* +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import web.cssom.ClassName +import web.html.ButtonType + +import kotlin.js.json +import kotlinx.datetime.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Component that allows to create new contests + */ +val contestCreationComponent = contestCreationComponent() + +private val contestCreationCard = cardComponent() + +/** + * Contest creation component props + */ +external interface ContestCreationComponentProps : Props { + /** + * Name of current organization + */ + var organizationName: String + + /** + * Callback invoked on successful contest creation + */ + var onSaveSuccess: (String) -> Unit + + /** + * Callback invoked on error while contest creation + */ + var onSaveError: (Response) -> Unit +} + +/** + * @param organizationName name of an organization to which a new contest will be linked + * @param isOpen flag that indicates if the modal is open + * @param onSuccess callback invoked on successful contest creation + * @param onFailure callback invoked on error when creating contest + * @param onClose callback invoked on close button press + */ +fun ChildrenBuilder.showContestCreationModal( + organizationName: String, + isOpen: Boolean, + onSuccess: (String) -> Unit, + onFailure: (Response) -> Unit, + onClose: () -> Unit, +) { + modal { props -> + props.isOpen = isOpen + props.style = Styles( + content = json( + "top" to "15%", + "left" to "30%", + "right" to "30%", + "bottom" to "auto", + "position" to "absolute", + "overflow" to "hide" + ).unsafeCast() + ) + contestCreationComponent { + this.organizationName = organizationName + onSaveSuccess = onSuccess + onSaveError = onFailure + } + div { + className = ClassName("d-flex justify-content-center mt-4") + buttonBuilder("Cancel", "secondary", isOutline = true) { + onClose() + } + } + } +} + +/** + * @param startTime + * @param endTime + */ +fun isDateRangeValid(startTime: LocalDateTime?, endTime: LocalDateTime?) = if (startTime != null && endTime != null) { + startTime < endTime +} else { + true +} + +private fun isButtonDisabled(contestDto: ContestDto) = contestDto.endTime == null || contestDto.startTime == null || !isDateRangeValid(contestDto.startTime, contestDto.endTime) || + !contestDto.name.isValidName() || contestDto.testSuites.isEmpty() + +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", + "MAGIC_NUMBER", + "AVOID_NULL_CHECKS" +) +private fun contestCreationComponent() = FC { props -> + val (contestDto, setContestDto) = useState(ContestDto.empty.copy(organizationName = props.organizationName)) + + val (conflictErrorMessage, setConflictErrorMessage) = useState(null) + + val onSaveButtonPressed = useDeferredRequest { + val response = post( + "$apiUrl/${FrontendRoutes.CONTESTS}/create", + jsonHeaders, + Json.encodeToString(contestDto), + ::noopLoadingHandler, + ::responseHandlerWithValidation + ) + if (response.ok) { + props.onSaveSuccess("/${FrontendRoutes.CONTESTS}/${contestDto.name}") + } else if (response.isConflict()) { + setConflictErrorMessage(response.unpackMessage()) + } else { + props.onSaveError(response) + } + } + + val testSuitesSelectorWindowOpenness = useWindowOpenness() + div { + className = ClassName("card") + contestCreationCard { + showContestTestSuitesSelectorModal( + contestDto.organizationName, + contestDto.testSuites, + testSuitesSelectorWindowOpenness, + useState(emptyList()), + ) { testSuites -> + setContestDto(contestDto.copy(testSuites = testSuites)) + } + div { + className = ClassName("") + form { + className = ClassName("needs-validation") + // ==== Contest Name + div { + className = ClassName("mt-2") + inputTextFormRequired { + form = InputTypes.CONTEST_NAME + textValue = contestDto.name + validInput = contestDto.name.isNotEmpty() && contestDto.name.isValidName() && conflictErrorMessage == null + classes = "col-12 pl-2 pr-2" + name = "Contest name" + conflictMessage = conflictErrorMessage + onChangeFun = { + setContestDto(contestDto.copy(name = it.target.value)) + setConflictErrorMessage(null) + } + } + } + // ==== Organization Name selection + div { + className = ClassName("mt-2") + inputTextDisabled( + InputTypes.CONTEST_SUPER_ORGANIZATION_NAME, + "col-12 pl-2 pr-2", + "Super organization name", + contestDto.organizationName, + ) + } + // ==== Contest dates + div { + className = ClassName("mt-2 d-flex justify-content-between") + inputDateFormRequired( + InputTypes.CONTEST_START_TIME, + isDateRangeValid(contestDto.startTime, contestDto.endTime), + "col-6 pl-2", + "Starting time", + ) { + setContestDto(contestDto.copy(startTime = it.target.value.dateStringToLocalDateTime())) + } + inputDateFormRequired( + InputTypes.CONTEST_END_TIME, + isDateRangeValid(contestDto.startTime, contestDto.endTime), + "col-6 pr-2", + "Ending time", + ) { + setContestDto(contestDto.copy(endTime = it.target.value.dateStringToLocalDateTime(LocalTime(23, 59, 59)))) + } + } + // ==== Contest test suites + div { + className = ClassName("mt-2") + inputTextFormRequired { + form = InputTypes.CONTEST_TEST_SUITE_IDS + conflictMessage = null + textValue = contestDto.testSuites.map { it.name } + .sorted() + .joinToString(", ") + validInput = true + classes = "col-12 pl-2 pr-2 text-center" + name = "Test Suites:" + onClickFun = testSuitesSelectorWindowOpenness.openWindowAction() + } + } + // ==== Contest description + div { + className = ClassName("mt-2") + inputTextFormOptional { + form = InputTypes.CONTEST_DESCRIPTION + textValue = contestDto.description + classes = "col-12 pl-2 pr-2" + name = "Contest description" + onChangeFun = { + setContestDto(contestDto.copy(description = it.target.value)) + } + } + } + } + } + div { + className = ClassName("mt-3 d-flex justify-content-center") + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary") + disabled = isButtonDisabled(contestDto) || conflictErrorMessage != null + onClick = { onSaveButtonPressed() } + +"Create contest" + } + } + conflictErrorMessage?.let { + div { + className = ClassName("invalid-feedback d-block text-center") + +it + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestInfoMenu.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestInfoMenu.kt new file mode 100644 index 0000000000..ec6dd5a661 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestInfoMenu.kt @@ -0,0 +1,77 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") + +package com.saveourtool.save.frontend.common.components.basic.contests + +import com.saveourtool.save.entities.contest.ContestDto +import com.saveourtool.save.frontend.common.components.basic.cardComponent +import com.saveourtool.save.frontend.common.components.basic.markdown +import com.saveourtool.save.frontend.common.utils.* + +import react.* +import react.dom.html.ReactHTML.div +import web.cssom.ClassName + +private val columnCard = cardComponent(hasBg = true, isPaddingBottomNull = true) + +/** + * INFO tab in ContestView + */ +val contestInfoMenu = contestInfoMenu() + +/** + * ContestInfoMenu functional component props + */ +external interface ContestInfoMenuProps : Props { + /** + * Current contest name + */ + var contestName: String? +} + +/** + * @return ReactElement + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +private fun contestInfoMenu() = FC { props -> + var contest by useState(null) + useRequest { + val contestDto = get( + "$apiUrl/contests/${props.contestName}", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .unsafeMap { + it.decodeFromJsonString() + } + contest = contestDto + } + + div { + className = ClassName("d-flex justify-content-center") + div { + className = ClassName("col-8") + div { + className = ClassName("text-xs text-center font-weight-bold text-primary text-uppercase mb-3") + +"Description" + } + div { + className = ClassName("text-center") + columnCard { + markdown(contest?.description ?: "No description provided **yet**") + } + } + } + } + + div { + className = ClassName("mt-4 mb-3") + div { + className = ClassName("text-xs text-center font-weight-bold text-primary text-uppercase mb-3") + +"Public tests" + } + publicTestComponent { + this.contestTestSuites = contest?.testSuites ?: emptyList() + this.contestName = props.contestName ?: "" + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestSubmissionsMenu.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestSubmissionsMenu.kt new file mode 100644 index 0000000000..708c71d2d9 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestSubmissionsMenu.kt @@ -0,0 +1,127 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") + +package com.saveourtool.save.frontend.common.components.basic.contests + +import com.saveourtool.save.entities.contest.ContestResult +import com.saveourtool.save.execution.ExecutionStatus +import com.saveourtool.save.frontend.common.components.tables.TableProps +import com.saveourtool.save.frontend.common.components.tables.columns +import com.saveourtool.save.frontend.common.components.tables.tableComponent +import com.saveourtool.save.frontend.common.components.tables.value +import com.saveourtool.save.frontend.common.utils.* + +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.td +import react.router.dom.Link +import web.cssom.* + +/** + * SUBMISSIONS tab in ContestView + */ +val contestSubmissionsMenu = contestSubmissionsMenu() + +@Suppress( + "MAGIC_NUMBER", + "TYPE_ALIAS", +) +private val myProjectsTable: FC> = tableComponent( + columns = { + columns { + column(id = "project_name", header = "Project Name", { this }) { cellContext -> + Fragment.create { + td { + Link { + cellContext.value.let { + to = "/contests/${it.contestName}/${it.organizationName}/${it.projectName}" + +"${it.organizationName}/${it.projectName}" + } + } + } + } + } + column(id = "sdk", header = "SDK", { this }) { cellCtx -> + Fragment.create { + td { + +cellCtx.value.sdk + } + } + } + column(id = "submission_time", header = "Last submission time", { this }) { cellCtx -> + Fragment.create { + td { + +(cellCtx.value.submissionTime?.toString()?.replace("T", " ") ?: "No data") + } + } + } + column(id = "status", header = "Last submission status", { this }) { cellCtx -> + Fragment.create { + td { + cellCtx.value.let { displayStatus(it.submissionStatus, it.hasFailedTest, it.score) } + } + } + } + } + }, + initialPageSize = 10, + useServerPaging = 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.INITIALIZATION, ExecutionStatus.PENDING -> ClassName("") + ExecutionStatus.RUNNING -> ClassName("") + ExecutionStatus.ERROR -> ClassName("text-danger") + ExecutionStatus.OBSOLETE -> ClassName("text-secondary") + 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.toFixed(2)}/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-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestSummaryMenu.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestSummaryMenu.kt new file mode 100644 index 0000000000..fd3b51cf0a --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/ContestSummaryMenu.kt @@ -0,0 +1,128 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") + +package com.saveourtool.save.frontend.common.components.basic.contests + +import com.saveourtool.save.entities.contest.ContestResult +import com.saveourtool.save.frontend.common.utils.* + +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h6 +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.ul +import react.router.dom.Link +import web.cssom.* + +/** + * SUMMARY tab in ContestView + */ +val contestSummaryMenu = contestSummaryMenu() + +/** + * ContestSummaryMenu component [Props] + */ +external interface ContestSummaryMenuProps : Props { + /** + * Name of a current contest + */ + var contestName: String +} + +private fun ChildrenBuilder.displayTopProjects(sortedResults: List) { + ul { + className = ClassName("col-10 mb-2 list-group") + displayResult( + "Top position", + "Project", + "Organization", + "Score", + null + ) + sortedResults.forEachIndexed { index, contestResult -> + displayResult( + "${index + 1}. ", + contestResult.projectName, + contestResult.organizationName, + contestResult.score?.toFixedStr(2) ?: "-", + "/${contestResult.organizationName}/${contestResult.projectName}" + ) + } + } +} + +private fun ChildrenBuilder.displayResult( + topPositionLabel: String, + projectName: String, + organizationName: String, + score: String, + linkToProject: String?, +) { + li { + val disabled = linkToProject?.let { "" } ?: "disabled bg-light" + className = ClassName("list-group-item $disabled") + linkToProject?.let { + Link { + to = it + className = ClassName("stretched-link") + } + } + div { + className = ClassName("d-flex justify-content-between") + div { + className = ClassName("row col") + div { + className = ClassName("mr-1 col text-left") + +topPositionLabel + } + div { + className = ClassName("ml-1 col text-center") + +projectName + } + } + div { + className = ClassName("col text-center") + +organizationName + } + div { + className = ClassName("col-1 text-right") + +score + } + } + } +} + +/** + * @return ReactElement + */ +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", + "MAGIC_NUMBER", + "AVOID_NULL_CHECKS" +) +private fun contestSummaryMenu() = FC { props -> + val (sortedResults, setSortedResults) = useState>(emptyList()) + useRequest { + val results = get( + url = "$apiUrl/contests/${props.contestName}/scores", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .unsafeMap { + it.decodeFromJsonString>() + } + .sortedByDescending { it.score } + setSortedResults(results) + } + div { + className = ClassName("mb-3 row justify-content-center align-items-center") + if (sortedResults.isEmpty()) { + h6 { + className = ClassName("text-center") + +"There are no participants yet. You can be the first one to participate in it!" + } + } else { + displayTopProjects(sortedResults) + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/PublicTestCardComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/PublicTestCardComponent.kt new file mode 100644 index 0000000000..b87f9ea073 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/contests/PublicTestCardComponent.kt @@ -0,0 +1,144 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") + +package com.saveourtool.save.frontend.common.components.basic.contests + +import com.saveourtool.save.frontend.common.components.basic.* +import com.saveourtool.save.frontend.common.externals.markdown.reactMarkdown +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.test.TestFilesContent +import com.saveourtool.save.testsuite.TestSuiteVersioned + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h6 +import web.cssom.ClassName + +/** + * Component that allows to see public tests + */ +val publicTestComponent = publicTestComponent() + +private val backgroundCard = cardComponent(hasBg = true, isPaddingBottomNull = true) + +private val publicTestCard = cardComponent(hasBg = true, isBordered = true, isPaddingBottomNull = true) + +/** + * Contest creation component props + */ +external interface PublicTestComponentProps : Props { + /** + * Name of current contest + */ + var contestName: String + + /** + * List of test suites attached to current contest + */ + var contestTestSuites: List +} + +private fun ChildrenBuilder.displayTestLines(header: String, lines: List, language: String? = null) = div { + div { + className = ClassName("text-xs text-center font-weight-bold text-primary text-uppercase mb-3") + +header + } + val reactMarkdownOptions: dynamic = jso { + this.children = wrapTestLines(lines, language) + this.rehypePlugins = arrayOf(::rehypeHighlightPlugin) + } + publicTestCard { + +reactMarkdown(reactMarkdownOptions) + } +} + +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", + "MAGIC_NUMBER", + "AVOID_NULL_CHECKS" +) +private fun publicTestComponent() = FC { props -> + val (selectedTestSuite, setSelectedTestSuite) = useState(null) + val (publicTest, setPublicTest) = useState(null) + + useRequest(dependencies = arrayOf(selectedTestSuite)) { + selectedTestSuite?.let { selectedTestSuite -> + val response = get( + "$apiUrl/contests/${props.contestName}/public-test?testSuiteId=${selectedTestSuite.id}", + jsonHeaders, + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + if (response.ok) { + val testFilesContent: TestFilesContent = response.decodeFromJsonString() + setPublicTest(testFilesContent) + } else { + setPublicTest(TestFilesContent.empty) + } + } + } + + if (props.contestTestSuites.isEmpty()) { + h6 { + className = ClassName("text-center") + +"No public tests are provided yet." + } + } else { + div { + className = ClassName("d-flex justify-content-center") + // ========== Test Suite Selector ========== + div { + className = ClassName("col-6") + showAvailableTestSuites( + props.contestTestSuites, + selectedTestSuite?.let { listOf(it) } ?: emptyList(), + null, + ) { testSuite -> + if (testSuite == selectedTestSuite) { + setSelectedTestSuite(null) + setPublicTest(null) + } else { + setSelectedTestSuite(testSuite) + } + } + } + + // ========== Public test card ========== + div { + className = ClassName("col-6") + publicTest?.let { publicTest -> + div { + if (publicTest.testLines.isEmpty()) { + div { + className = ClassName("text-center") + +"Public tests are not provided for this test suite" + } + } else { + backgroundCard { + div { + className = ClassName("ml-2 mr-2") + div { + className = ClassName("mt-3 mb-3") + displayTestLines("Test", publicTest.testLines, publicTest.language) + } + publicTest.expectedLines?.let { + div { + className = ClassName("mt-3 mb-2") + displayTestLines("Expected", it, publicTest.language) + } + } + } + } + } + } + } + } + } + } +} + +private fun wrapTestLines(testLines: List, language: String?) = """ + |```${ language ?: "" } + |${testLines.joinToString("\n")} + |```""".trimMargin() diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelector.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelector.kt new file mode 100644 index 0000000000..58c486ea91 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelector.kt @@ -0,0 +1,298 @@ +/** + * Component for selecting test suites + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.testsuiteselector + +import com.saveourtool.save.frontend.common.components.modal.largeTransparentModalStyle +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.utils.WindowOpenness +import com.saveourtool.save.frontend.common.utils.buttonWithIcon +import com.saveourtool.save.frontend.common.utils.useTooltip +import com.saveourtool.save.testsuite.TestSuiteVersioned + +import react.* +import react.dom.aria.ariaLabel +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h5 +import web.cssom.ClassName +import web.html.ButtonType + +val testSuiteSelector = testSuiteSelector() + +/** + * [Props] for [testSuiteSelector] component + */ +external interface TestSuiteSelectorProps : Props { + /** + * Lambda invoked when test suites were successfully set + */ + var onTestSuiteUpdate: (List) -> Unit + + /** + * List of test suite ids that should be preselected + */ + var preselectedTestSuites: List + + /** + * Specific organization name which reduces list of test suites source. + * If null, all the test suites are shown + */ + var specificOrganizationName: String? + + /** + * Mode that defines what kind of test suites will be shown + */ + var selectorPurpose: TestSuiteSelectorPurpose + + /** + * Name of a current organization (by which test suite selection is happening) + */ + var currentOrganizationName: String +} + +/** + * Enum that represents different modes of [testSuiteSelector] + */ +enum class TestSuiteSelectorMode { + BROWSER, + MANAGER, + SEARCH, + ; +} + +/** + * Enum that defines what type of test suites should be shown + */ +enum class TestSuiteSelectorPurpose { + CONTEST, + PRIVATE, + PUBLIC, + ; +} + +/** + * Browse all the available test suites. + * + * @param currentOrganizationName + * @param initTestSuites initial value + * @param windowOpenness state to control openness of window + * @param testSuitesInSelectorState state for intermediate result in selector + * @param setSelectedTestSuiteIds consumer for result + */ +@Suppress("TYPE_ALIAS") +fun ChildrenBuilder.showPublicTestSuitesSelectorModal( + currentOrganizationName: String, + initTestSuites: List, + windowOpenness: WindowOpenness, + testSuitesInSelectorState: StateInstance>, + setSelectedTestSuiteIds: (List) -> Unit, +) { + showTestSuitesSelectorModal(currentOrganizationName, + TestSuiteSelectorPurpose.PUBLIC, initTestSuites, windowOpenness, testSuitesInSelectorState, setSelectedTestSuiteIds) +} + +/** + * Browse test suites of a given organization. + * + * @param currentOrganizationName + * @param initTestSuites initial value + * @param windowOpenness state to control openness of window + * @param testSuitesInSelectorState state for intermediate result in selector + * @param setSelectedTestSuites consumer for result + */ +@Suppress("TYPE_ALIAS") +fun ChildrenBuilder.showPrivateTestSuitesSelectorModal( + currentOrganizationName: String, + initTestSuites: List, + windowOpenness: WindowOpenness, + testSuitesInSelectorState: StateInstance>, + setSelectedTestSuites: (List) -> Unit, +) { + showTestSuitesSelectorModal(currentOrganizationName, + TestSuiteSelectorPurpose.PRIVATE, initTestSuites, windowOpenness, testSuitesInSelectorState, + setSelectedTestSuites) +} + +/** + * Browse test suites for a contest. + * + * @param currentOrganizationName + * @param initTestSuites initial value + * @param windowOpenness state to control openness of window + * @param testSuitesInSelectorState state for intermediate result in selector + * @param setSelectedTestSuites consumer for result + */ +@Suppress("TYPE_ALIAS") +fun ChildrenBuilder.showContestTestSuitesSelectorModal( + currentOrganizationName: String, + initTestSuites: List, + windowOpenness: WindowOpenness, + testSuitesInSelectorState: StateInstance>, + setSelectedTestSuites: (List) -> Unit, +) { + showTestSuitesSelectorModal(currentOrganizationName, + TestSuiteSelectorPurpose.CONTEST, initTestSuites, windowOpenness, testSuitesInSelectorState, + setSelectedTestSuites) +} + +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList", "TYPE_ALIAS") +private fun ChildrenBuilder.showTestSuitesSelectorModal( + currentOrganizationName: String, + selectorPurpose: TestSuiteSelectorPurpose, + initTestSuites: List, + windowOpenness: WindowOpenness, + testSuitesInSelectorState: StateInstance>, + setSelectedTestSuites: (List) -> Unit, +) { + var currentlySelectedTestSuites by testSuitesInSelectorState + val onSubmit: () -> Unit = { + setSelectedTestSuites(currentlySelectedTestSuites) + windowOpenness.closeWindow() + } + val onTestSuiteIdUpdate: (List) -> Unit = { + currentlySelectedTestSuites = it + } + val onCancel: () -> Unit = { + currentlySelectedTestSuites = initTestSuites + windowOpenness.closeWindow() + } + showTestSuitesSelectorModal(windowOpenness.isOpen(), currentOrganizationName, selectorPurpose, initTestSuites, currentlySelectedTestSuites, onSubmit, onTestSuiteIdUpdate, + onCancel) +} + +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", + "TOO_MANY_PARAMETERS", + "LongParameterList" +) +private fun ChildrenBuilder.showTestSuitesSelectorModal( + isOpen: Boolean, + currentOrganizationName: String, + selectorPurpose: TestSuiteSelectorPurpose, + preselectedTestSuites: List, + currentlySelectedTestSuites: List, + onSubmit: () -> Unit, + onTestSuitesUpdate: (List) -> Unit, + onCancel: () -> Unit, +) { + modal { props -> + props.isOpen = isOpen + props.style = largeTransparentModalStyle + div { + className = ClassName("modal-dialog modal-lg modal-dialog-scrollable") + div { + className = ClassName("modal-content") + div { + className = ClassName("modal-header") + h5 { + className = ClassName("modal-title mb-0") + +"Test suite selector" + } + button { + type = ButtonType.button + className = ClassName("close") + asDynamic()["data-dismiss"] = "modal" + ariaLabel = "Close" + fontAwesomeIcon(icon = faTimesCircle) + onClick = { + onCancel() + } + } + } + + div { + className = ClassName("modal-body") + testSuiteSelector { + this.onTestSuiteUpdate = onTestSuitesUpdate + this.preselectedTestSuites = preselectedTestSuites + this.selectorPurpose = selectorPurpose + this.currentOrganizationName = currentOrganizationName + } + } + + div { + className = ClassName("modal-footer") + div { + className = ClassName("d-flex justify-content-center") + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary mt-4") + +"Apply" + disabled = currentlySelectedTestSuites.isEmpty() + onClick = { + onSubmit() + } + } + } + div { + className = ClassName("d-flex justify-content-center") + button { + type = ButtonType.button + className = ClassName("btn btn-secondary mt-4") + +"Cancel" + onClick = { + onCancel() + } + } + } + } + } + } + } +} + +private fun testSuiteSelector() = FC { props -> + val (currentMode, setCurrentMode) = useState(if (props.preselectedTestSuites.isEmpty()) { + TestSuiteSelectorMode.BROWSER + } else { + TestSuiteSelectorMode.MANAGER + }) + div { + className = ClassName("d-flex align-self-center justify-content-around mb-2") + buttonWithIcon(faAlignJustify, currentMode == TestSuiteSelectorMode.MANAGER, "Manage linked test suites") { + setCurrentMode( + TestSuiteSelectorMode.MANAGER + ) + } + buttonWithIcon(faPlus, currentMode == TestSuiteSelectorMode.BROWSER, "Browse public test suites") { + setCurrentMode( + TestSuiteSelectorMode.BROWSER + ) + } + buttonWithIcon(faSearch, currentMode == TestSuiteSelectorMode.SEARCH, "Search by name or tag") { + setCurrentMode( + TestSuiteSelectorMode.SEARCH + ) + } + } + + useTooltip() + + when (currentMode) { + TestSuiteSelectorMode.MANAGER -> testSuiteSelectorManagerMode { + this.onTestSuitesUpdate = props.onTestSuiteUpdate + this.preselectedTestSuites = props.preselectedTestSuites + this.selectorPurpose = props.selectorPurpose + this.currentOrganizationName = props.currentOrganizationName + } + TestSuiteSelectorMode.BROWSER -> testSuiteSelectorBrowserMode { + this.onTestSuitesUpdate = props.onTestSuiteUpdate + this.preselectedTestSuites = props.preselectedTestSuites + this.specificOrganizationName = props.specificOrganizationName + this.selectorPurpose = props.selectorPurpose + this.currentOrganizationName = props.currentOrganizationName + } + TestSuiteSelectorMode.SEARCH -> testSuiteSelectorSearchMode { + this.onTestSuitesUpdate = props.onTestSuiteUpdate + this.preselectedTestSuites = props.preselectedTestSuites + this.selectorPurpose = props.selectorPurpose + this.currentOrganizationName = props.currentOrganizationName + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorBrowserMode.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorBrowserMode.kt new file mode 100644 index 0000000000..26dcb5be2a --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorBrowserMode.kt @@ -0,0 +1,350 @@ +/** + * Component for selecting test suites in browser mode + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.testsuiteselector + +import com.saveourtool.save.frontend.common.components.basic.showAvailableTestSuites +import com.saveourtool.save.frontend.common.externals.fontawesome.faCheckDouble +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.noopResponseHandler +import com.saveourtool.save.testsuite.TestSuiteVersioned + +import react.* +import react.dom.aria.AriaRole +import react.dom.aria.ariaLabel +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.nav +import react.dom.html.ReactHTML.ol +import react.dom.html.ReactHTML.ul +import web.cssom.ClassName +import web.html.ButtonType + +val testSuiteSelectorBrowserMode = testSuiteSelectorBrowserMode() + +/** + * [Props] for [testSuiteSelectorBrowserMode] component + */ +external interface TestSuiteSelectorBrowserModeProps : Props { + /** + * Lambda invoked when test suites were successfully set + */ + var onTestSuitesUpdate: (List) -> Unit + + /** + * List of test suites that should be preselected + */ + var preselectedTestSuites: List + + /** + * Specific organization name which reduces list of test suites source. + * If null, all the test suites are shown + */ + var specificOrganizationName: String? + + /** + * Mode that defines what kind of test suites will be shown + */ + var selectorPurpose: TestSuiteSelectorPurpose + + /** + * Name of an organization by the name of which test suites are being managed. + */ + var currentOrganizationName: String +} + +@Suppress( + "TOO_MANY_PARAMETERS", + "TOO_LONG_FUNCTION", + "LongParameterList", + "LongMethod", + "ComplexMethod", +) +private fun ChildrenBuilder.showBreadcrumb( + selectedOrganization: String?, + selectedTestSuiteSource: String?, + selectedTestSuiteVersion: String?, + shouldDisplayVersion: Boolean, + onOrganizationsClick: () -> Unit, + onSelectedOrganizationClick: () -> Unit, + onSelectedTestSuiteSourceClick: () -> Unit, +) { + nav { + ariaLabel = "breadcrumb" + ol { + className = ClassName("breadcrumb") + li { + className = ClassName("breadcrumb-item") + a { + role = "button".unsafeCast() + className = selectedOrganization?.let { ClassName("btn-link") } + onClick = { + selectedOrganization?.let { + onOrganizationsClick() + } + } + +"organizations" + } + } + selectedOrganization?.let { + li { + val isActive = selectedTestSuiteSource?.let { "" } ?: "active" + className = ClassName("breadcrumb-item $isActive") + a { + role = "button".unsafeCast() + className = selectedTestSuiteSource?.let { ClassName("btn-link") } + onClick = { + selectedTestSuiteSource?.let { + onSelectedOrganizationClick() + } + } + +selectedOrganization + } + } + } + selectedTestSuiteSource?.let { + li { + val isActive = selectedTestSuiteVersion?.let { "" } ?: "active" + className = ClassName("breadcrumb-item $isActive") + a { + role = "button".unsafeCast() + className = selectedTestSuiteVersion?.let { ClassName("btn-link") } + onClick = { + if (shouldDisplayVersion) { + selectedTestSuiteVersion?.let { + onSelectedTestSuiteSourceClick() + } + } + } + +selectedTestSuiteSource + } + } + } + if (shouldDisplayVersion) { + selectedTestSuiteVersion?.let { + li { + className = ClassName("breadcrumb-item active") + a { + role = "button".unsafeCast() + +selectedTestSuiteVersion + } + } + } + } + } + } +} + +private fun ChildrenBuilder.showAvailableOptions( + options: List, + onOptionClick: (String) -> Unit, +) { + ul { + className = ClassName("list-group") + if (options.isEmpty()) { + li { + className = ClassName("list-group-item") + +"There is no test suite that you can manage." + } + } else { + options.forEach { option -> + a { + className = ClassName("btn text-center list-group-item list-group-item-action") + onClick = { + onOptionClick(option) + } + +option + } + } + } + } +} + +@Suppress("TOO_LONG_FUNCTION", "LongMethod", "ComplexMethod") +private fun testSuiteSelectorBrowserMode() = FC { props -> + useTooltip() + val (selectedOrganization, setSelectedOrganization) = useState(null) + val (selectedTestSuiteSource, setSelectedTestSuiteSource) = useState(null) + val (selectedTestSuiteVersion, setSelectedTestSuiteVersion) = useState(null) + val (selectedTestSuites, setSelectedTestSuites) = useState>(emptyList()) + + val (availableOrganizations, setAvailableOrganizations) = useState>(emptyList()) + val (availableTestSuiteSources, setAvailableTestSuiteSources) = useState>(emptyList()) + val (availableTestSuitesVersions, setAvailableTestSuitesVersions) = useState>(emptyList()) + val (availableTestSuites, setAvailableTestSuites) = useState>(emptyList()) + val (fetchedTestSuites, setFetchedTestSuites) = useState>(emptyList()) + useRequest { + val options = when (props.selectorPurpose) { + TestSuiteSelectorPurpose.PUBLIC -> "?permission=READ" + TestSuiteSelectorPurpose.PRIVATE -> "?permission=WRITE" + TestSuiteSelectorPurpose.CONTEST -> "?permission=READ&isContest=true" + } + val response = get( + url = "$apiUrl/test-suites/${props.currentOrganizationName}/available$options", + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + val testSuites: List = response.decodeFromJsonString() + setFetchedTestSuites(testSuites) + setAvailableOrganizations(testSuites.map { it.organizationName }.distinct()) + } + + useEffect(selectedOrganization) { + selectedOrganization?.let { selectedOrganization -> + setAvailableTestSuiteSources( + fetchedTestSuites + .filter { it.organizationName == selectedOrganization } + .map { it.sourceName } + .distinct() + ) + } ?: setAvailableTestSuiteSources(emptyList()) + } + + useEffect(selectedTestSuiteSource) { + selectedTestSuiteSource?.let { selectedTestSuiteSource -> + setAvailableTestSuitesVersions( + fetchedTestSuites.filter { it.sourceName == selectedTestSuiteSource } + .map { it.version } + .distinct() + ) + } ?: setAvailableTestSuitesVersions(emptyList()) + } + + useEffect(selectedTestSuiteVersion) { + selectedTestSuiteVersion?.let { selectedTestSuiteVersion -> + setAvailableTestSuites( + fetchedTestSuites.filter { + it.sourceName == selectedTestSuiteSource && it.version == selectedTestSuiteVersion + } + ) + } ?: setAvailableTestSuites(emptyList()) + } + + val (namePrefix, setNamePrefix) = useState("") + div { + // ==================== BREADCRUMB ==================== + className = ClassName("") + showBreadcrumb( + selectedOrganization, + selectedTestSuiteSource, + selectedTestSuiteVersion, + availableTestSuitesVersions.size > 1, + onOrganizationsClick = { + setSelectedOrganization(null) + setSelectedTestSuiteSource(null) + setSelectedTestSuiteVersion(null) + setNamePrefix("") + }, + onSelectedOrganizationClick = { + setSelectedTestSuiteSource(null) + setSelectedTestSuiteVersion(null) + setNamePrefix("") + } + ) { + setSelectedTestSuiteVersion(null) + setNamePrefix("") + } + // ==================== TOOLBAR ==================== + div { + className = ClassName("d-flex justify-content-center mb-2") + input { + className = ClassName("form-control") + value = namePrefix + placeholder = selectedOrganization?.let { + selectedTestSuiteSource?.let { + selectedTestSuiteVersion?.let { + "Test suite name" + } ?: "Test suite version name" + } ?: "Test suite source name" + } ?: "Organization name" + onChange = { + setNamePrefix(it.target.value) + } + } + selectedTestSuiteVersion?.let { + val active = if (selectedTestSuites.containsAll(availableTestSuites)) { + "active" + } else { + "" + } + button { + type = ButtonType.button + className = ClassName("btn btn-outline-secondary $active") + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + title = "Select all" + onClick = { + setSelectedTestSuites { selectedTestSuites -> + if (selectedTestSuites.containsAll(availableTestSuites)) { + selectedTestSuites.filter { it !in availableTestSuites } + } else { + selectedTestSuites.toMutableList() + .apply { + addAll(availableTestSuites) + } + .distinctBy { it.id } + } + .also { testSuites -> + props.onTestSuitesUpdate(testSuites) + } + } + } + fontAwesomeIcon(faCheckDouble) + } + } + } + // ==================== SELECTOR ==================== + div { + className = ClassName("") + when { + selectedOrganization == null -> showAvailableOptions( + availableOrganizations.filter { it.contains(namePrefix, true) } + ) { organization -> + setSelectedOrganization(organization) + setNamePrefix("") + } + selectedTestSuiteSource == null -> showAvailableOptions( + availableTestSuiteSources.filter { it.contains(namePrefix, true) } + ) { testSuiteSource -> + setSelectedTestSuiteSource(testSuiteSource) + setNamePrefix("") + } + selectedTestSuiteVersion == null -> showAvailableOptions( + availableTestSuitesVersions.filter { it.contains(namePrefix, true) } + ) { testSuiteVersion -> + setSelectedTestSuiteVersion(testSuiteVersion) + setNamePrefix("") + } + else -> showAvailableTestSuites( + availableTestSuites.filter { it.name.contains(namePrefix, true) }, + selectedTestSuites, + TestSuiteSelectorMode.BROWSER, + ) { testSuite -> + setSelectedTestSuites { selectedTestSuites -> + selectedTestSuites.toMutableList() + .apply { + if (testSuite in selectedTestSuites) { + remove(testSuite) + } else { + add(testSuite) + } + } + .toList() + .also { listOfTestSuiteDtos -> + props.onTestSuitesUpdate(listOfTestSuiteDtos) + } + } + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorManagerMode.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorManagerMode.kt new file mode 100644 index 0000000000..8b74a47301 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorManagerMode.kt @@ -0,0 +1,77 @@ +/** + * Component for selecting test suites in manager mode + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.testsuiteselector + +import com.saveourtool.save.frontend.common.components.basic.showAvailableTestSuites +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.testsuite.TestSuiteVersioned + +import react.FC +import react.Props +import react.dom.html.ReactHTML.h6 +import react.useState +import web.cssom.ClassName + +val testSuiteSelectorManagerMode = testSuiteSelectorManagerMode() + +/** + * [Props] for [testSuiteSelectorManagerMode] component + */ +external interface TestSuiteSelectorManagerModeProps : Props { + /** + * List of test suites that should be preselected + */ + var preselectedTestSuites: List + + /** + * Callback invoked when test suite is being removed + */ + var onTestSuitesUpdate: (List) -> Unit + + /** + * Mode that defines what kind of test suites will be shown + */ + var selectorPurpose: TestSuiteSelectorPurpose + + /** + * Name of an organization by the name of which test suites are being managed. + */ + var currentOrganizationName: String +} + +@Suppress("TOO_LONG_FUNCTION", "LongMethod", "ComplexMethod") +private fun testSuiteSelectorManagerMode() = FC { props -> + val (selectedTestSuites, setSelectedTestSuites) = useState(props.preselectedTestSuites) + useTooltip() + if (props.preselectedTestSuites.isEmpty()) { + h6 { + className = ClassName("text-center") + +"No test suites are selected yet." + } + } else { + showAvailableTestSuites( + props.preselectedTestSuites, + selectedTestSuites, + TestSuiteSelectorMode.MANAGER, + ) { testSuite -> + setSelectedTestSuites { selectedTestSuites -> + selectedTestSuites.toMutableList() + .apply { + if (testSuite in selectedTestSuites) { + remove(testSuite) + } else { + add(testSuite) + } + } + .toList() + .also { listOfTestSuiteDtos -> + props.onTestSuitesUpdate(listOfTestSuiteDtos) + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorSearchMode.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorSearchMode.kt new file mode 100644 index 0000000000..b06d3118a7 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuiteselector/TestSuiteSelectorSearchMode.kt @@ -0,0 +1,169 @@ +/** + * Component for selecting test suites in search mode + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.testsuiteselector + +import com.saveourtool.save.filters.TestSuiteFilter +import com.saveourtool.save.frontend.common.components.basic.showAvailableTestSuites +import com.saveourtool.save.frontend.common.components.basic.testsuiteselector.TestSuiteSelectorPurpose.CONTEST +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.noopResponseHandler +import com.saveourtool.save.testsuite.TestSuiteVersioned +import com.saveourtool.save.utils.DEFAULT_DEBOUNCE_PERIOD + +import react.* +import react.dom.events.ChangeEvent +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import web.cssom.ClassName +import web.html.HTMLInputElement +import web.html.InputType + +val testSuiteSelectorSearchMode = testSuiteSelectorSearchMode() + +/** + * [Props] for [testSuiteSelectorSearchMode] component + */ +external interface TestSuiteSelectorSearchModeProps : Props { + /** + * List of test suites that should be preselected + */ + var preselectedTestSuites: List + + /** + * Callback invoked when test suite is being removed + */ + var onTestSuitesUpdate: (List) -> Unit + + /** + * Mode that defines what kind of test suites will be shown + */ + var selectorPurpose: TestSuiteSelectorPurpose + + /** + * Name of an organization by the name of which test suites are being managed. + */ + var currentOrganizationName: String +} + +private fun ChildrenBuilder.buildInput( + currentValue: String, + inputName: String, + classes: String, + setValue: (ChangeEvent) -> Unit +) { + input { + className = ClassName("form-control $classes") + value = currentValue + placeholder = inputName + onChange = setValue + } +} + +private fun ChildrenBuilder.showAvailableTestSuitesForSearchMode( + testSuites: List, + selectedTestSuites: List, + isOnlyLatestVersion: Boolean, + onTestSuiteClick: (TestSuiteVersioned) -> Unit, +) { + val testSuitesToBeShown = testSuites.filter { + !isOnlyLatestVersion || it.isLatestFetchedVersion + } + + showAvailableTestSuites( + testSuitesToBeShown, + selectedTestSuites, + TestSuiteSelectorMode.SEARCH, + onTestSuiteClick + ) +} + +@Suppress("TOO_LONG_FUNCTION", "LongMethod", "ComplexMethod") +private fun testSuiteSelectorSearchMode() = FC { props -> + val (selectedTestSuites, setSelectedTestSuites) = useState(props.preselectedTestSuites) + val (filteredTestSuites, setFilteredTestSuites) = useState>(emptyList()) + val (filters, setFilters) = useState(TestSuiteFilter.empty) + val getFilteredTestSuites = useDebouncedDeferredRequest(DEFAULT_DEBOUNCE_PERIOD) { + if (filters.isNotEmpty()) { + val testSuitesFromBackend: List = get( + url = "$apiUrl/test-suites/${props.currentOrganizationName}/filtered${ + filters.copy(language = encodeURIComponent(filters.language)) + .toQueryParams("isContest" to "${props.selectorPurpose == CONTEST}") + }", + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + .decodeFromJsonString() + setFilteredTestSuites(testSuitesFromBackend) + } + } + + useEffect(filters) { + if (filters.isEmpty()) { + setFilteredTestSuites(emptyList()) + } else { + getFilteredTestSuites() + } + } + + div { + className = ClassName("d-flex justify-content-around mb-2") + buildInput(filters.name, "Name", "mr-1") { event -> + setFilters { it.copy(name = event.target.value) } + } + buildInput(filters.language, "Language", "ml-1 mr-1") { event -> + setFilters { it.copy(language = event.target.value) } + } + buildInput(filters.tags, "Tags", "ml-1") { event -> + setFilters { it.copy(tags = event.target.value) } + } + } + + val (isOnlyLatestVersion, setIsOnlyLatestVersion) = useState(false) + div { + className = ClassName("d-flex justify-content-around mb-3") + div { + className = ClassName("form-group form-check") + input { + type = InputType.checkbox + className = ClassName("form-check-input") + id = "isOnlyLatestVersion" + checked = isOnlyLatestVersion + onChange = { + setIsOnlyLatestVersion(it.target.checked) + } + } + label { + className = ClassName("form-check-label") + htmlFor = "isOnlyLatestVersion" + +"Show only latest fetched version" + } + } + } + useTooltip() + showAvailableTestSuitesForSearchMode( + filteredTestSuites, + selectedTestSuites, + isOnlyLatestVersion, + ) { testSuite -> + setSelectedTestSuites { selectedTestSuites -> + selectedTestSuites.toMutableList() + .apply { + if (testSuite in selectedTestSuites) { + remove(testSuite) + } else { + add(testSuite) + } + } + .toList() + .also { listOfTestSuiteDtos -> + props.onTestSuitesUpdate(listOfTestSuiteDtos) + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitespermissions/ManageTestSuitePermissionsCard.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitespermissions/ManageTestSuitePermissionsCard.kt new file mode 100644 index 0000000000..682c0ce970 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitespermissions/ManageTestSuitePermissionsCard.kt @@ -0,0 +1,348 @@ +/** + * This file contains function to create ManageGitCredentialsCard + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.testsuitespermissions + +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.frontend.common.components.basic.testsuiteselector.TestSuiteSelectorPurpose +import com.saveourtool.save.frontend.common.components.basic.testsuiteselector.testSuiteSelector +import com.saveourtool.save.frontend.common.components.basic.testsuitespermissions.PermissionManagerMode.MESSAGE +import com.saveourtool.save.frontend.common.components.basic.testsuitespermissions.PermissionManagerMode.PUBLISH +import com.saveourtool.save.frontend.common.components.basic.testsuitespermissions.PermissionManagerMode.SUITE_SELECTOR_FOR_PUBLISH +import com.saveourtool.save.frontend.common.components.basic.testsuitespermissions.PermissionManagerMode.SUITE_SELECTOR_FOR_RIGHTS +import com.saveourtool.save.frontend.common.components.basic.testsuitespermissions.PermissionManagerMode.TRANSFER +import com.saveourtool.save.frontend.common.components.inputform.inputWithDebounceForOrganizationDto +import com.saveourtool.save.frontend.common.components.inputform.renderOrganizationWithAvatar +import com.saveourtool.save.frontend.common.components.modal.largeTransparentModalStyle +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.components.modal.modalBuilder +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.permission.Rights +import com.saveourtool.save.permission.SetRightsRequest +import com.saveourtool.save.testsuite.TestSuiteVersioned + +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import web.cssom.ClassName +import web.html.InputType + +import kotlinx.coroutines.await +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Fully independent component that allows to manage test suites permissions. + */ +val manageTestSuitePermissionsComponent = manageTestSuitePermissionsComponent() + +/** + * Props for ManageGitCredentialsCard + */ +external interface ManageTestSuitePermissionsComponentProps : Props { + /** + * name of organization, assumption that it's checked by previous views and valid here + */ + var organizationName: String + + /** + * Flag that defines if component is shown or not + */ + var isModalOpen: Boolean + + /** + * Callback to hide component + */ + var closeModal: () -> Unit + + /** + * Mode that is used to tell if PUBLISH or TRANSFER manager should be opened. + */ + var mode: PermissionManagerMode? +} + +@Suppress( + "TOO_MANY_PARAMETERS", + "LongParameterList", + "TOO_LONG_FUNCTION", + "LongMethod", +) +private fun ChildrenBuilder.displayPermissionManager( + selectedTestSuites: List, + organization: OrganizationDto, + requestedRights: Rights, + openTestSuiteSelector: () -> Unit, + setOrganization: (OrganizationDto) -> Unit, + setRequestedRights: (Rights) -> Unit, +) { + div { + div { + className = ClassName("text-xs text-center font-weight-bold text-primary text-uppercase mb-3") + +TRANSFER.purpose.orEmpty() + } + div { + className = ClassName("row mb-2") + label { + className = ClassName("col-1 float-left align-self-center m-0") + +"1. " + } + div { + className = ClassName("pl-0 col-11") + input { + className = ClassName("form-control") + placeholder = "Choose test suites to share..." + value = selectedTestSuites.map { it.name }.sorted().joinToString(", ") + onClick = { + openTestSuiteSelector() + } + } + } + } + div { + className = ClassName("row mb-2") + label { + className = ClassName("col-1 float-left align-self-center m-0") + +"2. " + } + div { + className = ClassName("pl-0 col-11") + inputWithDebounceForOrganizationDto { + selectedOption = organization + setSelectedOption = { setOrganization(it) } + getUrlForOptionsFetch = { prefix -> "$apiUrl/organizations/get/by-prefix?prefix=$prefix" } + placeholder = "Start typing organization name..." + renderOption = ::renderOrganizationWithAvatar + } + } + } + div { + className = ClassName("row") + label { + className = ClassName("col-1 float-left align-self-center m-0") + +"3. " + } + div { + className = ClassName("pl-0 col-11") + selectorBuilder(requestedRights.toString(), Rights.values().map { it.toString() }) { + setRequestedRights(Rights.valueOf(it.target.value)) + } + } + } + } +} + +@Suppress( + "TOO_MANY_PARAMETERS", + "LongParameterList", + "TOO_LONG_FUNCTION", + "LongMethod" +) +private fun ChildrenBuilder.displayMassPermissionManager( + selectedTestSuites: List, + isToBePublic: Boolean, + openTestSuiteSelector: () -> Unit, + setIsToBePublic: (Boolean) -> Unit, +) { + div { + div { + className = ClassName("text-xs text-center font-weight-bold text-primary text-uppercase mb-3") + +PUBLISH.purpose.orEmpty() + } + div { + className = ClassName("row mb-2") + label { + className = ClassName("col-1 float-left align-self-center m-0") + +"1. " + } + div { + className = ClassName("pl-0 col-11") + input { + className = ClassName("form-control") + placeholder = "Choose test suites to share..." + value = selectedTestSuites.map { it.name }.sorted().joinToString(", ") + onClick = { + openTestSuiteSelector() + } + } + } + } + div { + className = ClassName("row mb-2") + label { + className = ClassName("col-1 float-left align-self-center m-0") + +"2. " + } + div { + className = ClassName("form-check form-check-inline") + input { + className = ClassName("form-check-input") + type = "radio".unsafeCast() + name = "visibility" + id = "visibility-public" + value = "public" + checked = isToBePublic + onChange = { + setIsToBePublic(it.target.checked) + } + } + label { + className = ClassName("form-check-label") + htmlFor = "visibility-public" + +"Public" + } + } + div { + className = ClassName("form-check form-check-inline") + input { + className = ClassName("form-check-input") + type = "radio".unsafeCast() + name = "visibility" + id = "visibility-private" + value = "private" + checked = !isToBePublic + onChange = { + setIsToBePublic(!it.target.checked) + } + } + label { + className = ClassName("form-check-label") + htmlFor = "visibility-private" + +"Private" + } + } + } + } +} + +@Suppress("TOO_LONG_FUNCTION", "LongMethod", "ComplexMethod") +private fun manageTestSuitePermissionsComponent() = FC { props -> + val (selectedTestSuites, setSelectedTestSuites) = useState>(emptyList()) + val (organization, setOrganization) = useState(OrganizationDto.empty) + val (requiredRights, setRequiredRights) = useState(Rights.NONE) + + val (isToBePublic, setIsToBePublic) = useState(true) + + val (currentMode, setCurrentMode) = useState(props.mode ?: TRANSFER) + useEffect(props.mode) { + if (currentMode in listOf(TRANSFER, PUBLISH) && props.mode != currentMode) { + props.mode?.let { setCurrentMode(it) } + } + } + val (backendResponseMessage, setBackendResponseMessage) = useState("") + val sendTransferRequest = useDeferredRequest { + val response = post( + url = "$apiUrl/test-suites/${props.organizationName}/batch-set-rights", + headers = jsonHeaders, + body = Json.encodeToString(SetRightsRequest(organization.name, requiredRights, selectedTestSuites.map { it.id })), + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + val message = if (response.ok) { + response.text().await() + } else { + response.unpackMessage() + } + setBackendResponseMessage(message) + } + + val sendPublishRequest = useDeferredRequest { + val response = post( + url = "$apiUrl/test-suites/${props.organizationName}/batch-change-visibility?isPublic=$isToBePublic", + headers = jsonHeaders, + body = Json.encodeToString(selectedTestSuites.map { it.id }), + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + val message = if (response.ok) { + response.text().await() + } else { + response.unpackMessage() + } + setBackendResponseMessage(message) + } + + val clearFields = { + setSelectedTestSuites(emptyList()) + setCurrentMode(TRANSFER) + setBackendResponseMessage("") + setRequiredRights(Rights.NONE) + setOrganization(OrganizationDto.empty) + setIsToBePublic(true) + } + + modal { modalProps -> + modalProps.isOpen = props.isModalOpen + modalProps.style = largeTransparentModalStyle + modalBuilder( + title = "Test Suite Permission Manager${currentMode.title?.let { " - $it" }.orEmpty()}", + classes = "modal-lg modal-dialog-scrollable", + onCloseButtonPressed = { + props.closeModal() + clearFields() + }, + bodyBuilder = { + when (currentMode) { + TRANSFER -> displayPermissionManager( + selectedTestSuites, + organization, + requiredRights, + { setCurrentMode(SUITE_SELECTOR_FOR_RIGHTS) }, + { setOrganization(it) } + ) { setRequiredRights(it) } + SUITE_SELECTOR_FOR_PUBLISH, SUITE_SELECTOR_FOR_RIGHTS -> testSuiteSelector { + this.onTestSuiteUpdate = { + setSelectedTestSuites(it) + } + this.preselectedTestSuites = selectedTestSuites + this.selectorPurpose = TestSuiteSelectorPurpose.PRIVATE + this.currentOrganizationName = props.organizationName + } + PUBLISH -> displayMassPermissionManager( + selectedTestSuites, + isToBePublic, + { setCurrentMode(SUITE_SELECTOR_FOR_PUBLISH) }, + ) { setIsToBePublic(it) } + MESSAGE -> +backendResponseMessage + } + } + ) { + when (currentMode) { + TRANSFER -> buttonBuilder("Apply", isDisabled = selectedTestSuites.isEmpty()) { + sendTransferRequest() + setCurrentMode(MESSAGE) + } + SUITE_SELECTOR_FOR_RIGHTS -> buttonBuilder("Apply", isDisabled = selectedTestSuites.isEmpty()) { + setCurrentMode(TRANSFER) + } + SUITE_SELECTOR_FOR_PUBLISH -> buttonBuilder("Apply", isDisabled = selectedTestSuites.isEmpty()) { + setCurrentMode(PUBLISH) + } + PUBLISH -> buttonBuilder("Apply", isDisabled = selectedTestSuites.isEmpty()) { + sendPublishRequest() + setCurrentMode(MESSAGE) + } + else -> {} + } + buttonBuilder("Cancel", "secondary") { + when (currentMode) { + TRANSFER, PUBLISH, MESSAGE -> { + clearFields() + props.closeModal() + } + SUITE_SELECTOR_FOR_RIGHTS -> { + setSelectedTestSuites(emptyList()) + setCurrentMode(TRANSFER) + } + SUITE_SELECTOR_FOR_PUBLISH -> { + setSelectedTestSuites(emptyList()) + setCurrentMode(PUBLISH) + } + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitespermissions/PermissionManagerMode.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitespermissions/PermissionManagerMode.kt new file mode 100644 index 0000000000..a04ddb8fad --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitespermissions/PermissionManagerMode.kt @@ -0,0 +1,40 @@ +package com.saveourtool.save.frontend.common.components.basic.testsuitespermissions + +/** + * Enum class that defines current state of [manageTestSuitePermissionsComponent] (mostly state of the modal inside component) + * @property title + * @property purpose + */ +enum class PermissionManagerMode(val title: String? = null, val purpose: String? = null) { + /** + * State when success (or error) message is shown. + */ + MESSAGE, + + /** + * Make test suites public or private + */ + PUBLISH( + title = "Visibility mode", + purpose = "Make test suites private or public", + ), + + /** + * Select test suites that should be managed in case of visibility. + */ + SUITE_SELECTOR_FOR_PUBLISH, + + /** + * Select test suites that should be managed in case of rights. + */ + SUITE_SELECTOR_FOR_RIGHTS, + + /** + * State when a modal with three input forms is shown: what, where and how to add. + */ + TRANSFER( + title = "Transfer mode", + purpose = "Share test suites with selected organization", + ), + ; +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitessources/TestSuiteSourceUpsertComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitessources/TestSuiteSourceUpsertComponent.kt new file mode 100644 index 0000000000..a7e297af68 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitessources/TestSuiteSourceUpsertComponent.kt @@ -0,0 +1,270 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.testsuitessources + +import com.saveourtool.save.domain.EntitySaveStatus +import com.saveourtool.save.entities.GitDto +import com.saveourtool.save.frontend.common.components.basic.selectFormRequired +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.inputform.inputTextDisabled +import com.saveourtool.save.frontend.common.components.inputform.inputTextFormOptional +import com.saveourtool.save.frontend.common.components.inputform.inputTextFormRequired +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.components.views.organization.gitWindow +import com.saveourtool.save.frontend.common.externals.fontawesome.faTimesCircle +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.externals.modal.Styles +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.testsuite.TestSuitesSourceDto +import com.saveourtool.save.v1 + +import react.* +import react.dom.aria.AriaRole +import react.dom.aria.ariaLabel +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h5 +import web.cssom.ClassName +import web.html.ButtonType + +import kotlin.js.json + +val testSuiteSourceCreationComponent = testSuiteSourceUpsertComponent() + +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +private val gitSelectionForm = selectFormRequired() + +/** + * [Props] for [testSuiteSourceUpsertComponent] + */ +external interface TestSuiteSourceUpsertProps : Props { + /** + * Name of a current organization + */ + var organizationName: String + + /** + * existed [com.saveourtool.save.testsuite.TestSuitesSourceDto] to update or null to create a new one + */ + var testSuitesSource: TestSuitesSourceDto? + + /** + * Callback invoked on successful save + */ + var onSuccess: (TestSuitesSourceDto) -> Unit +} + +/** + * @param windowOpenness + * @param testSuitesSource + * @param organizationName + * @param onSuccess + */ +@Suppress("TOO_LONG_FUNCTION") +fun ChildrenBuilder.showTestSuiteSourceUpsertModal( + windowOpenness: WindowOpenness, + testSuitesSource: TestSuitesSourceDto?, + organizationName: String, + onSuccess: (TestSuitesSourceDto) -> Unit, +) { + modal { props -> + props.isOpen = windowOpenness.isOpen() + props.style = Styles( + content = json( + "top" to "10%", + "left" to "30%", + "right" to "30%", + "bottom" to "auto", + "position" to "absolute", + "overflow" to "hide", + ).unsafeCast() + ) + div { + className = ClassName("d-flex justify-content-between") + h5 { + className = ClassName("modal-title mb-3") + +"Create test suite source" + } + button { + type = ButtonType.button + className = ClassName("close") + asDynamic()["data-dismiss"] = "modal" + ariaLabel = "Close" + fontAwesomeIcon(icon = faTimesCircle) + onClick = windowOpenness.closeWindowAction().withUnusedArg() + } + } + div { + testSuiteSourceCreationComponent { + this.organizationName = organizationName + this.testSuitesSource = testSuitesSource + this.onSuccess = { + windowOpenness.closeWindow() + onSuccess(it) + } + } + } + } +} + +private fun EntitySaveStatus.message() = when (this) { + EntitySaveStatus.CONFLICT -> "Test suite source with such test root path and git id is already present" + EntitySaveStatus.EXIST -> "Test suite source already exists" + EntitySaveStatus.NEW -> "Test suite source saved successfully" + EntitySaveStatus.UPDATED -> "Test suite source updated successfully" + else -> throw NotImplementedError("Not supported save status $this") +} + +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", + "ComplexMethod", +) +private fun testSuiteSourceUpsertComponent() = FC { props -> + val (testSuiteSource, setTestSuiteSource) = useState( + props.testSuitesSource ?: TestSuitesSourceDto.empty.copy(organizationName = props.organizationName) + ) + val saveStatusState: StateInstance = useState() + val (saveStatus, setSaveStatus) = saveStatusState + val requestToUpsertEntity = prepareRequest( + id = props.testSuitesSource?.id, + testSuiteSource = testSuiteSource, + entitySaveStatusState = saveStatusState, + ) { + props.onSuccess(testSuiteSource) + } + + val gitWindowOpenness = useWindowOpenness() + val gitCredentialToUpsertState = useState(GitDto.empty) + gitWindow { + windowOpenness = gitWindowOpenness + organizationName = props.organizationName + gitToUpsertState = gitCredentialToUpsertState + } + + div { + inputTextFormRequired { + form = InputTypes.SOURCE_NAME + textValue = testSuiteSource.name + validInput = testSuiteSource.validateName() && saveStatus != EntitySaveStatus.EXIST + classes = "mb-2" + name = "Source name" + conflictMessage = saveStatus?.message() + onChangeFun = { + setTestSuiteSource(testSuiteSource.copy(name = it.target.value)) + if (saveStatus == EntitySaveStatus.EXIST) { + setSaveStatus(null) + } + } + } + inputTextDisabled( + InputTypes.ORGANIZATION_NAME, + "mb-2", + "Organization name", + testSuiteSource.organizationName + ) + inputTextFormOptional { + form = InputTypes.SOURCE_TEST_ROOT_PATH + textValue = testSuiteSource.testRootPath + classes = "mb-2" + name = "Test root path" + validInput = testSuiteSource.validateTestRootPath() && saveStatus != EntitySaveStatus.CONFLICT + onChangeFun = { + setTestSuiteSource(testSuiteSource.copy(testRootPath = it.target.value)) + if (saveStatus == EntitySaveStatus.CONFLICT) { + setSaveStatus(null) + } + } + } + gitSelectionForm { + formType = InputTypes.SOURCE_GIT + selectClasses = "custom-select" + validInput = saveStatus != EntitySaveStatus.CONFLICT + classes = "mb-2" + formName = "Git Credentials" + getData = { context -> + context.get( + "$apiUrl/organizations/${props.organizationName}/list-git", + headers = jsonHeaders, + loadingHandler = context::loadingHandler, + ) + .unsafeMap { + it.decodeFromJsonString() + } + } + getDataRequestDependencies = arrayOf(gitWindowOpenness.isOpen()) + dataToString = { it.url } + notFoundErrorMessage = "You have no available git credentials in organization ${props.organizationName}." + addNewItemChildrenBuilder = { childrenBuilder -> + with(childrenBuilder) { + a { + className = ClassName("text-primary") + role = "button".unsafeCast() + onClick = { + gitWindowOpenness.openWindow() + } + +"Add new git credentials" + } + } + } + selectedValue = testSuiteSource.gitDto.url + disabled = props.testSuitesSource != null + onChangeFun = { git -> + git?.let { + setTestSuiteSource(testSuiteSource.copy(gitDto = it)) + if (saveStatus == EntitySaveStatus.CONFLICT) { + setSaveStatus(null) + } + } + } + } + inputTextFormOptional { + form = InputTypes.DESCRIPTION + textValue = testSuiteSource.description + classes = "mb-2" + name = "Description" + validInput = true + onChangeFun = { + setTestSuiteSource(testSuiteSource.copy(description = it.target.value)) + } + } + div { + className = ClassName("d-flex justify-content-center") + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary mt-2 mb-2") + disabled = !testSuiteSource.validate() || saveStatus != null + onClick = requestToUpsertEntity.withUnusedArg() + +"Submit" + } + } + saveStatus?.let { + div { + className = ClassName("invalid-feedback d-block text-center") + +it.message() + } + } + } +} + +private fun prepareRequest( + id: Long?, + testSuiteSource: TestSuitesSourceDto, + entitySaveStatusState: StateInstance, + onSuccess: () -> Unit, +) = useDeferredRequest { + val (_, setEntitySaveStatus) = entitySaveStatusState + val response = post( + url = "/api/$v1/test-suites-sources/${id?.let { "update?id=$it" } ?: "create"}", + headers = jsonHeaders, + body = testSuiteSource.toJsonBody(), + loadingHandler = ::loadingHandler, + responseHandler = ::responseHandlerWithValidation, + ) + if (response.ok) { + onSuccess() + } else if (response.isConflict()) { + setEntitySaveStatus(response.decodeFromJsonString()) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitessources/fetch/TestSuitesSourceFetcher.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitessources/fetch/TestSuitesSourceFetcher.kt new file mode 100644 index 0000000000..3401a00e34 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/testsuitessources/fetch/TestSuitesSourceFetcher.kt @@ -0,0 +1,235 @@ +/** + * This file contains a modal window to fetch TestSuitesSource and related classes + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.testsuitessources.fetch + +import com.saveourtool.save.frontend.common.components.basic.selectFormRequired +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.inputform.inputTextFormRequired +import com.saveourtool.save.frontend.common.components.modal.largeTransparentModalStyle +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.components.modal.modalBuilder +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.testsuite.TestSuitesSourceDto +import com.saveourtool.save.testsuite.TestSuitesSourceFetchMode + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import web.cssom.ClassName +import web.html.ButtonType + +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +private val innerTestSuitesSourceFetcher = innerTestSuitesSourceFetcher() +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +private val tagSelector = selectFormRequired() +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +private val branchSelector = selectFormRequired() + +/** + * Properties for testSuitesSourceFetcher + */ +external interface TestSuitesSourceFetcherProps : Props { + /** + * Control openness of modal window + */ + var windowOpenness: WindowOpenness + + /** + * [TestSuitesSourceDto] to be fetched + */ + var testSuitesSource: TestSuitesSourceDto + + /** + * Selected fetch mode + */ + var selectedFetchModeState: StateInstance + + /** + * Selected value + */ + var selectedValueState: StateInstance +} + +/** + * @param windowOpenness + * @param testSuitesSource + */ +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", +) +fun ChildrenBuilder.testSuitesSourceFetcher( + windowOpenness: WindowOpenness, + testSuitesSource: TestSuitesSourceDto, +) { + val selectedFetchModeState = useState(TestSuitesSourceFetchMode.BY_TAG) + val (selectedFetchMode, _) = selectedFetchModeState + val selectedValueState: StateInstance = useState() + val (selectedValue, _) = selectedValueState + val triggerFetchTestSuiteSource = useDeferredRequest { + post( + url = with(testSuitesSource) { + "$apiUrl/test-suites-sources/$organizationName/${encodeURIComponent(name)}/fetch" + }, + params = jso { + mode = selectedFetchMode + version = selectedValue + }, + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + body = undefined + ) + } + + modal { modalProps -> + modalProps.isOpen = windowOpenness.isOpen() + modalProps.style = largeTransparentModalStyle + modalBuilder( + title = "Test suites source fetcher", + onCloseButtonPressed = windowOpenness.closeWindowAction(), + bodyBuilder = { + innerTestSuitesSourceFetcher { + this.windowOpenness = windowOpenness + this.testSuitesSource = testSuitesSource + this.selectedFetchModeState = selectedFetchModeState + this.selectedValueState = selectedValueState + } + }, + ) { + div { + className = ClassName("d-flex justify-content-center") + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary mt-4") + +"Fetch" + onClick = { + triggerFetchTestSuiteSource() + windowOpenness.closeWindow() + } + } + } + div { + className = ClassName("d-flex justify-content-center") + button { + type = ButtonType.button + className = ClassName("btn btn-secondary mt-4") + +"Cancel" + onClick = windowOpenness.closeWindowAction().withUnusedArg() + } + } + } + } +} + +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", +) +private fun innerTestSuitesSourceFetcher() = FC { props -> + val (selectedFetchMode, _) = props.selectedFetchModeState + val (selectedValue, setSelectedValue) = props.selectedValueState + + div { + className = ClassName("d-flex align-self-center justify-content-around mb-2") + buttonWithIcon( + icon = faTag, + tooltipText = "Fetch test suites source by tag", + buttonMode = TestSuitesSourceFetchMode.BY_TAG, + currentModeState = props.selectedFetchModeState + ) { + setSelectedValue(null) + } + buttonWithIcon( + icon = faCodeBranch, + tooltipText = "Fetch test suites source by branch", + buttonMode = TestSuitesSourceFetchMode.BY_BRANCH, + currentModeState = props.selectedFetchModeState + ) { + setSelectedValue(null) + } + buttonWithIcon( + icon = faCheckCircle, + tooltipText = "Fetch test suites source by commit", + buttonMode = TestSuitesSourceFetchMode.BY_COMMIT, + currentModeState = props.selectedFetchModeState + ) { + setSelectedValue(null) + } + } + useTooltip() + + val urlPrefix = with(props.testSuitesSource) { + "$apiUrl/test-suites-sources/$organizationName/${encodeURIComponent(name)}" + } + when (selectedFetchMode) { + TestSuitesSourceFetchMode.BY_TAG -> div { + tagSelector { + formType = InputTypes.SOURCE_TAG + validInput = selectedValue != null + classes = "mb-2" + selectClasses = "custom-select" + formName = "Source tag:" + getData = { context -> + context.get( + url = "$urlPrefix/tag-list-to-fetch", + headers = jsonHeaders, + loadingHandler = context::loadingHandler, + ) + .unsafeMap> { + it.decodeFromJsonString() + } + .also { setSelectedValue(null) } + } + dataToString = { it } + notFoundErrorMessage = "There are no tags in ${props.testSuitesSource.gitDto.url}" + this.selectedValue = selectedValue ?: "" + onChangeFun = { tag -> + setSelectedValue(tag) + } + } + } + TestSuitesSourceFetchMode.BY_BRANCH -> div { + branchSelector { + formType = InputTypes.SOURCE_BRANCH + selectClasses = "custom-select" + validInput = selectedValue != null + classes = "mb-2" + formName = "Source branch:" + getData = { context -> + context.get( + url = "$urlPrefix/branch-list-to-fetch", + headers = jsonHeaders, + loadingHandler = context::loadingHandler, + ) + .unsafeMap> { + it.decodeFromJsonString() + } + .also { setSelectedValue(null) } + } + dataToString = { it } + notFoundErrorMessage = "There are no branches in ${props.testSuitesSource.gitDto.url}" + this.selectedValue = selectedValue ?: "" + onChangeFun = { tag -> + setSelectedValue(tag) + } + } + } + TestSuitesSourceFetchMode.BY_COMMIT -> div { + inputTextFormRequired { + form = InputTypes.SOURCE_COMMIT + textValue = selectedValue + validInput = selectedValue != null + classes = "mb-2" + name = "Commit (sha-1):" + conflictMessage = null + onChangeFun = setSelectedValue.fromInput() + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/CreateOrganizationView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/CreateOrganizationView.kt similarity index 91% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/CreateOrganizationView.kt rename to save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/CreateOrganizationView.kt index 605933df7f..534e39868c 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/CreateOrganizationView.kt +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/CreateOrganizationView.kt @@ -4,15 +4,15 @@ @file:Suppress("WildcardImport", "FILE_WILDCARD_IMPORTS") -package com.saveourtool.save.frontend.components.views +package com.saveourtool.save.frontend.common.components.views.organization import com.saveourtool.save.entities.* -import com.saveourtool.save.frontend.components.basic.AVATAR_ORGANIZATION_PLACEHOLDER -import com.saveourtool.save.frontend.components.inputform.InputTypes -import com.saveourtool.save.frontend.components.inputform.inputTextFormRequired -import com.saveourtool.save.frontend.components.modal.displayModal -import com.saveourtool.save.frontend.components.modal.mediumTransparentModalStyle -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.basic.AVATAR_ORGANIZATION_PLACEHOLDER +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.inputform.inputTextFormRequired +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.modal.mediumTransparentModalStyle +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.validation.FrontendRoutes import com.saveourtool.save.validation.isValidLengthName diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/GitWindow.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/GitWindow.kt new file mode 100644 index 0000000000..c235550958 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/GitWindow.kt @@ -0,0 +1,196 @@ +/** + * Function component for project info and edit support + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.entities.GitDto +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.components.modal.smallTransparentModalStyle +import com.saveourtool.save.frontend.common.utils.* + +import react.* +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import web.cssom.ClassName +import web.html.ButtonType +import web.html.InputType + +/** + * Component that allows to change git settings in ManageGitCredentialsCard.kt + */ +val gitWindow = createGitWindow() + +/** + * GitWindow component props + */ +external interface GitWindowProps : Props { + /** + * Window openness + */ + var windowOpenness: WindowOpenness + + /** + * name of organization, assumption that it's checked by previous views and valid here + */ + var organizationName: String + + /** + * Git credential to upsert + */ + var gitToUpsertState: StateInstance + + /** + * Flag controls that current window is for update + */ + var isUpdate: Boolean + + /** + * Request to fetch git credentials + */ + var fetchGitCredentials: () -> Unit +} + +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", + "TYPE_ALIAS", +) +private fun createGitWindow() = FC { props -> + val (gitToUpsert, setGitToUpsert) = props.gitToUpsertState + + val failedResponseWindowOpenness = useWindowOpenness() + val failedResponseWindowCloseAction = { + failedResponseWindowOpenness.closeWindow() + props.windowOpenness.openWindow() + } + val (failedReason, setFailedReason) = useState("N/A") + displayModal( + failedResponseWindowOpenness.isOpen(), + "Failed to ${if (props.isUpdate) "update" else "create"} git credential", + "Url [${gitToUpsert.url}]: $failedReason", + smallTransparentModalStyle, + failedResponseWindowCloseAction + ) { + buttonBuilder( + label = "Ok", + style = "secondary", + onClickFun = failedResponseWindowCloseAction.withUnusedArg() + ) + } + + val upsertGitCredentialRequest = useDeferredRequest { + val endpointPrefix = if (props.isUpdate) { + "update" + } else { + "create" + } + val response = post( + "$apiUrl/organizations/${props.organizationName}/$endpointPrefix-git", + headers = jsonHeaders, + body = gitToUpsert.toJsonBody(), + loadingHandler = ::loadingHandler, + responseHandler = ::responseHandlerWithValidation + ) + props.windowOpenness.closeWindow() + if (!response.ok) { + setFailedReason(response.decodeFieldFromJsonString("message")) + failedResponseWindowOpenness.openWindow() + } else if (!props.isUpdate) { + props.fetchGitCredentials() + } + } + + modal { modalProps -> + modalProps.isOpen = props.windowOpenness.isOpen() + + div { + className = ClassName("row mt-2 ml-2 mr-2") + div { + className = ClassName("col-5 text-left align-self-center") + +"Git Url:" + } + div { + className = ClassName("col-7 input-group pl-0") + input { + type = InputType.text + className = ClassName("form-control") + defaultValue = gitToUpsert.url + readOnly = props.isUpdate + required = true + onChange = { + setGitToUpsert(gitToUpsert.copy(url = it.target.value)) + } + if (props.isUpdate) { + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + title = "Cannot be changed on update" + } + } + } + } + div { + className = ClassName("row mt-2 ml-2 mr-2") + div { + className = ClassName("col-5 text-left align-self-center") + +"Git Username:" + } + div { + className = ClassName("col-7 input-group pl-0") + input { + type = InputType.text + className = ClassName("form-control") + defaultValue = gitToUpsert.username + onChange = { + setGitToUpsert(gitToUpsert.copy(username = it.target.value)) + } + } + } + } + div { + className = ClassName("row mt-2 ml-2 mr-2") + div { + className = ClassName("col-5 text-left align-self-center") + +"Git Token:" + } + div { + className = ClassName("col-7 input-group pl-0") + input { + type = InputType.text + className = ClassName("form-control") + onChange = { + setGitToUpsert(gitToUpsert.copy(password = it.target.value)) + } + } + } + } + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mt-4") + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary mr-3") + onClick = { + upsertGitCredentialRequest() + } + val buttonName = if (props.isUpdate) { + "Update" + } else { + "Create" + } + +buttonName + } + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary") + onClick = props.windowOpenness.closeWindowAction().withUnusedArg() + +"Cancel" + } + } + } + + useTooltip() +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/ManageGitCredentialsCard.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/ManageGitCredentialsCard.kt new file mode 100644 index 0000000000..14d969e0ec --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/ManageGitCredentialsCard.kt @@ -0,0 +1,231 @@ +/** + * This file contains function to create ManageGitCredentialsCard + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.GitDto +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.modal.mediumTransparentModalStyle +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.utils.getHighestRole +import js.core.jso + +import org.w3c.fetch.Response +import react.FC +import react.Props +import react.StateSetter +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.hr +import react.router.dom.Link +import react.useState +import web.cssom.ClassName +import web.cssom.rem +import web.html.ButtonType + +typealias RequestWithDependency = Triple, () -> Unit> + +/** + * Props for ManageGitCredentialsCard + */ +external interface ManageGitCredentialsCardProps : Props { + /** + * Information about user who is seeing the view + */ + var selfUserInfo: UserInfo + + /** + * name of organization, assumption that it's checked by previous views and valid here + */ + var organizationName: String + + /** + * Lambda to show error after fail response + */ + @Suppress("TYPE_ALIAS") + var updateErrorMessage: (Response, String) -> Unit +} + +/** + * @return ManageGitCredentialsCard + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +fun manageGitCredentialsCardComponent() = FC { props -> + val (selfRole, setSelfRole) = useState(Role.NONE) + useRequest { + val role = get( + "$apiUrl/organizations/${props.organizationName}/users/roles", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .unsafeMap { + it.decodeFromJsonString() + } + .toRole() + setSelfRole(getHighestRole(role, props.selfUserInfo.globalRole)) + } + + val (gitCredentials, _, fetchGitCredentialsRequest) = prepareFetchGitCredentials(props.organizationName) + + val (isUpdate, setUpdateFlag) = useState(false) + val gitCredentialToUpsertState = useState(GitDto.empty) + val (_, setGitCredentialToUpsert) = gitCredentialToUpsertState + + val (gitCredentialToDelete, setGitCredentialToDelete, deleteGitCredentialRequest) = + prepareDeleteGitCredential(props.organizationName, props.updateErrorMessage, fetchGitCredentialsRequest) + + val gitWindowOpenness = useWindowOpenness() + gitWindow { + windowOpenness = gitWindowOpenness + organizationName = props.organizationName + gitToUpsertState = gitCredentialToUpsertState + this.isUpdate = isUpdate + fetchGitCredentials = fetchGitCredentialsRequest + } + + val (isConfirmDeleteGitCredentialWindowOpened, setConfirmDeleteGitCredentialWindowOpened) = useState(false) + displayModal( + isConfirmDeleteGitCredentialWindowOpened, + "Deletion of git credential", + "Please confirm deletion of git credential for ${gitCredentialToDelete.url}. " + + "Note! This action will also delete all corresponding data to that repository, such as test suites sources, test executions and so on.", + mediumTransparentModalStyle, + { setConfirmDeleteGitCredentialWindowOpened(false) }, + ) { + buttonBuilder("Ok") { + deleteGitCredentialRequest() + setConfirmDeleteGitCredentialWindowOpened(false) + } + buttonBuilder("Close", "secondary") { + setConfirmDeleteGitCredentialWindowOpened(false) + } + } + + val canModify = selfRole.isSuperAdmin() || selfRole == Role.ADMIN + div { + className = ClassName("card card-body mt-0 pt-0 pr-0 pl-0") + gitCredentials.forEachIndexed { index, gitCredential -> + val url = gitCredential.url + div { + className = ClassName("row mt-2 mr-0 justify-content-between align-items-center") + div { + className = ClassName("col-7 d-flex justify-content-start align-items-center") + div { + className = ClassName("col-2 align-items-center") + style = jso { + fontSize = 1.5.rem + } + fontAwesomeIcon( + when { + url.contains("github") -> faGithub + url.contains("codehub") -> faCopyright + else -> faHome + } + ) + } + div { + className = ClassName("col-7 text-left align-self-center pl-0") + Link { + to = url + +url + } + } + } + div { + className = ClassName("col-5 align-self-right d-flex align-items-center justify-content-end") + button { + type = ButtonType.button + className = ClassName("btn col-2 align-items-center mr-2") + fontAwesomeIcon(icon = faEdit) + id = "edit-git-credential-$index" + onClick = { + setGitCredentialToUpsert(gitCredential) + setUpdateFlag(true) + gitWindowOpenness.openWindow() + } + } + button { + type = ButtonType.button + className = ClassName("btn col-2 align-items-center mr-2") + fontAwesomeIcon(icon = faTimesCircle) + id = "remove-git-credential-$index" + onClick = { + setGitCredentialToDelete(gitCredential) + setConfirmDeleteGitCredentialWindowOpened(true) + } + } + hidden = !canModify + } + } + } + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + hr {} + div { + className = ClassName("row d-flex justify-content-center") + div { + className = ClassName("col-11 text-right") + button { + type = ButtonType.button + className = ClassName("btn btn-sm btn-outline-primary") + onClick = { + setGitCredentialToUpsert(GitDto.empty) + setUpdateFlag(false) + gitWindowOpenness.openWindow() + } + +"Add new" + } + } + } + } + + useOnce { + fetchGitCredentialsRequest() + } +} + +@Suppress("TYPE_ALIAS") +private fun prepareFetchGitCredentials(organizationName: String): RequestWithDependency> { + val (gitCredentials, setGitCredentials) = useState(emptyList()) + val fetchGitCredentialsRequest = useDeferredRequest { + get( + "$apiUrl/organizations/$organizationName/list-git", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .unsafeMap { + it.decodeFromJsonString>() + }.let { + setGitCredentials(it) + } + } + return Triple(gitCredentials, setGitCredentials, fetchGitCredentialsRequest) +} + +@Suppress("TYPE_ALIAS") +private fun prepareDeleteGitCredential( + organizationName: String, + @Suppress("TYPE_ALIAS") + updateErrorMessage: (Response, String) -> Unit, + fetchGitCredentialsRequest: () -> Unit +): RequestWithDependency { + val (gitCredentialToDelete, setGitCredentialToDelete) = useState(GitDto("N/A")) + val deleteGitCredentialRequest = useDeferredRequest { + val response = delete( + url = "$apiUrl/organizations/$organizationName/delete-git?url=${gitCredentialToDelete.url}", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + if (!response.ok) { + updateErrorMessage(response, response.unpackMessage()) + } else { + fetchGitCredentialsRequest() + } + } + return Triple(gitCredentialToDelete, setGitCredentialToDelete, deleteGitCredentialRequest) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationContestsMenu.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationContestsMenu.kt new file mode 100644 index 0000000000..62349e6509 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationContestsMenu.kt @@ -0,0 +1,267 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", + "FILE_WILDCARD_IMPORTS", + "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", +) + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.contest.ContestDto +import com.saveourtool.save.entities.contest.ContestStatus +import com.saveourtool.save.frontend.common.components.basic.contests.showContestCreationModal +import com.saveourtool.save.frontend.common.components.tables.TableProps +import com.saveourtool.save.frontend.common.components.tables.columns +import com.saveourtool.save.frontend.common.components.tables.tableComponent +import com.saveourtool.save.frontend.common.components.tables.value +import com.saveourtool.save.frontend.common.externals.fontawesome.faPlus +import com.saveourtool.save.frontend.common.externals.fontawesome.faTrash +import com.saveourtool.save.frontend.common.utils.* + +import org.w3c.fetch.Response +import react.* +import react.dom.html.ReactHTML.br +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.td +import react.router.dom.Link +import react.router.useNavigate +import web.cssom.ClassName +import web.html.InputType + +import kotlinx.browser.window +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Suppress("MAGIC_NUMBER", "TYPE_ALIAS") +private val contestsTable: FC> = tableComponent( + columns = { props -> + columns { + column(id = "name", header = "Contest Name", { name }) { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + Link { + to = "/contests/${cellContext.row.original.name}" + +cellContext.value + } + } + } + } + column(id = "description", header = "Description", { description }) { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + +(cellContext.value ?: "Description is not provided") + } + } + } + column(id = "start_time", header = "Start Time", { startTime.toString() }) { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + +cellContext.value.replace("T", " ") + } + } + } + column(id = "end_time", header = "End Time", { endTime.toString() }) { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + +cellContext.value.replace("T", " ") + } + } + } + column("checkBox", "") { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + input { + className = ClassName("mx-auto") + type = InputType.checkbox + id = "checkbox" + defaultChecked = props.selectedContestDtos.contains(cellContext.row.original) + onChange = { event -> + if (event.target.checked) { + props.addSelectedContests(cellContext.row.original) + } else { + props.removeSelectedContests(cellContext.row.original) + } + } + } + } + } + } + } + }, +) { + arrayOf(it.isContestCreated) +} + +@Suppress( + "EMPTY_BLOCK_STRUCTURE_ERROR", +) +/** + * CONTESTS tab in OrganizationView + */ +val organizationContestsMenu: FC = FC { props -> + useTooltip() + val (isToUpdateTable, setIsToUpdateTable) = useState(false) + val contestCreationWindowOpenness = useWindowOpenness() + val (selectedContests, setSelectedContests) = useState>(setOf()) + val (contests, setContests) = useState>(setOf()) + val refreshTable = { setIsToUpdateTable { !it } } + val addSelectedContestsFun: (ContestDto) -> Unit = { contest -> + setSelectedContests { it.plus(contest) } + } + val removeSelectedContestsFun: (ContestDto) -> Unit = { contest -> + setSelectedContests { it.minus(contest) } + } + val deleteContestsFun = useDeferredRequest { + val deleteContests = selectedContests.map { it.copy(status = ContestStatus.DELETED) } + val response = post( + "$apiUrl/contests/update-all", + jsonHeaders, + Json.encodeToString(deleteContests), + loadingHandler = ::noopLoadingHandler, + ) + if (response.ok) { + setContests(contests.minus(selectedContests)) + setSelectedContests(setOf()) + refreshTable() + } + } + val navigate = useNavigate() + + useRequest { + val response = get( + url = "$apiUrl/contests/by-organization?organizationName=${props.organizationName}", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + val newContests = if (response.ok) { + response.unsafeMap { it.decodeFromJsonString>() }.toSet() + } else { + emptySet() + } + setContests(newContests) + refreshTable() + } + + showContestCreationModal( + props.organizationName, + contestCreationWindowOpenness.isOpen(), + { + contestCreationWindowOpenness.closeWindow() + navigate( + to = it, + ) + }, + { + contestCreationWindowOpenness.closeWindow() + props.updateErrorMessage(it) + } + ) { + contestCreationWindowOpenness.closeWindow() + } + + div { + className = ClassName("col-8 mx-auto") + div { + className = ClassName("d-flex justify-content-end mb-1") + if (selectedContests.isNotEmpty()) { + buttonBuilder( + faTrash, + classes = "mr-2 text-sm btn-sm", + title = "Delete selected contests", + style = "danger", + ) { + if (window.confirm("Are you sure you want to delete selected contests?")) { + deleteContestsFun() + } + } + } + if (props.selfRole.hasDeletePermission()) { + buttonBuilder( + faPlus, + title = "Create contest", + isOutline = true, + classes = "text-sm btn-sm", + ) { + contestCreationWindowOpenness.openWindow() + } + } + } + div { + className = ClassName("my-3") + if (contests.isNotEmpty()) { + contestsTable { + getData = { _, _ -> contests.toTypedArray() } + isContestCreated = isToUpdateTable + addSelectedContests = addSelectedContestsFun + removeSelectedContests = removeSelectedContestsFun + selectedContestDtos = selectedContests + } + } else { + renderTablePlaceholder("text-center p-4 bg-white text-sm", "dashed") { + +"This organization has not created any contest yet." + if (props.selfRole.hasDeletePermission()) { + br { } + buttonBuilder( + "You can be the first contest creator in ${props.organizationName}.", + style = "", + classes = "text-sm text-primary" + ) { + contestCreationWindowOpenness.openWindow() + } + } + } + } + } + } +} + +/** + * OrganizationContestsMenu component props + */ +external interface OrganizationContestsMenuProps : Props { + /** + * Current organization name + */ + var organizationName: String + + /** + * [Role] of user that is observing this component + */ + var selfRole: Role + + /** + * Callback to show error message + */ + var updateErrorMessage: (Response) -> Unit +} + +/** + * Interface for table reloading after the contest creation + */ +external interface OrganizationContestsTableProps : TableProps { + /** + * Flag to update table data when contest is created + */ + var isContestCreated: Boolean + + /** + * Fun add contest to list of selected contests + */ + var addSelectedContests: (ContestDto) -> Unit + + /** + * Fun remove contest from list of selected contests + */ + var removeSelectedContests: (ContestDto) -> Unit + + /** + * Selected contests + */ + var selectedContestDtos: Set +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/OrganizationMenuBar.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationMenuBar.kt similarity index 78% rename from save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/OrganizationMenuBar.kt rename to save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationMenuBar.kt index 86d35cd6ae..9d523c2375 100644 --- a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/OrganizationMenuBar.kt +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationMenuBar.kt @@ -1,15 +1,15 @@ -package com.saveourtool.save.frontend.common.utils +package com.saveourtool.save.frontend.common.components.views.organization /** - * A value for project menu. + * A value for organization menu. */ @Suppress("WRONG_DECLARATIONS_ORDER") enum class OrganizationMenuBar(private val title: String? = null) { INFO, + VULNERABILITIES, TOOLS, BENCHMARKS, CONTESTS, - VULNERABILITIES, SETTINGS, ; diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/organizations/OrganizationSettingsMenu.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationSettingsMenu.kt similarity index 95% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/organizations/OrganizationSettingsMenu.kt rename to save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationSettingsMenu.kt index 8720ff6331..5ba41a2dab 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/organizations/OrganizationSettingsMenu.kt +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationSettingsMenu.kt @@ -1,15 +1,15 @@ @file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "LargeClass") -package com.saveourtool.save.frontend.components.basic.organizations +package com.saveourtool.save.frontend.common.components.views.organization import com.saveourtool.save.domain.Role import com.saveourtool.save.entities.OrganizationDto import com.saveourtool.save.entities.OrganizationStatus -import com.saveourtool.save.frontend.components.basic.manageUserRoleCardComponent -import com.saveourtool.save.frontend.utils.* -import com.saveourtool.save.frontend.utils.isSuperAdmin -import com.saveourtool.save.frontend.utils.noopLoadingHandler -import com.saveourtool.save.frontend.utils.noopResponseHandler +import com.saveourtool.save.frontend.common.components.basic.manageUserRoleCardComponent +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.isSuperAdmin +import com.saveourtool.save.frontend.common.utils.noopLoadingHandler +import com.saveourtool.save.frontend.common.utils.noopResponseHandler import com.saveourtool.save.info.UserInfo import com.saveourtool.save.validation.FrontendRoutes diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationTestsMenu.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationTestsMenu.kt new file mode 100644 index 0000000000..be18d9d45e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationTestsMenu.kt @@ -0,0 +1,191 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", + "FILE_WILDCARD_IMPORTS", + "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", + "TOP_LEVEL_ORDER" +) + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.frontend.common.components.basic.testsuitespermissions.PermissionManagerMode +import com.saveourtool.save.frontend.common.components.basic.testsuitespermissions.manageTestSuitePermissionsComponent +import com.saveourtool.save.frontend.common.components.basic.testsuitessources.fetch.testSuitesSourceFetcher +import com.saveourtool.save.frontend.common.components.basic.testsuitessources.showTestSuiteSourceUpsertModal +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.WithRequestStatusContext +import com.saveourtool.save.frontend.common.utils.loadingHandler +import com.saveourtool.save.frontend.common.utils.useTooltip +import com.saveourtool.save.frontend.common.utils.useWindowOpenness +import com.saveourtool.save.test.* +import com.saveourtool.save.testsuite.* + +import react.* +import react.dom.html.ReactHTML.div +import web.cssom.ClassName + +import kotlinx.browser.window + +/** + * TESTS tab in OrganizationView + */ +val organizationTestsMenu = organizationTestsMenu() + +/** + * OrganizationTestsMenu component props + */ +external interface OrganizationTestsMenuProps : Props { + /** + * Current organization name + */ + var organizationName: String + + /** + * [Role] of user that is observing this component + */ + var selfRole: Role +} + +private suspend fun WithRequestStatusContext.getTestSuitesSourcesWithId( + organizationName: String, +) = get( + url = "$apiUrl/test-suites-sources/$organizationName/list", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, +) + +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +private fun organizationTestsMenu() = FC { props -> + useTooltip() + val testSuitesSourceUpsertWindowOpenness = useWindowOpenness() + val (isSourceCreated, setIsSourceCreated) = useState(false) + val (testSuitesSources, setTestSuitesSources) = useState(emptyList()) + useRequest(dependencies = arrayOf(props.organizationName, isSourceCreated)) { + val response = getTestSuitesSourcesWithId(props.organizationName) + if (response.ok) { + setTestSuitesSources(response.decodeFromJsonString()) + } else { + setTestSuitesSources(emptyList()) + } + } + val (testSuiteSourceToFetch, setTestSuiteSourceToFetch) = useState() + val testSuitesSourceFetcherWindowOpenness = useWindowOpenness() + testSuitesSourceFetcher( + testSuitesSourceFetcherWindowOpenness, + testSuiteSourceToFetch ?: TestSuitesSourceDto.empty + ) + + val (selectedTestSuitesSource, setSelectedTestSuitesSource) = useState() + val (testsSourceVersionInfoList, setTestsSourceVersionInfoList) = useState(emptyList()) + val fetchTestsSourcesVersionInfoList = useDeferredRequest { + selectedTestSuitesSource?.let { testSuitesSource -> + val response = get( + url = "$apiUrl/test-suites-sources/${testSuitesSource.organizationName}/${encodeURIComponent(testSuitesSource.name)}/list-version", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + if (response.ok) { + response.unsafeMap { + it.decodeFromJsonString() + }.let { + setTestsSourceVersionInfoList(it) + } + } else { + setTestsSourceVersionInfoList(emptyList()) + } + } + } + + val (testsSourceVersionInfoToDelete, setTestsSourceVersionInfoToDelete) = useState() + val deleteTestSuitesSourcesSnapshotKey = useDeferredRequest { + testsSourceVersionInfoToDelete?.let { key -> + delete( + url = with(key) { + "$apiUrl/test-suites-sources/$organizationName/${encodeURIComponent(sourceName)}/delete-version?version=$version" + }, + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + setTestsSourceVersionInfoToDelete(null) + } + } + val selectHandler: (TestSuitesSourceDto) -> Unit = { + if (selectedTestSuitesSource == it) { + setSelectedTestSuitesSource(null) + } else { + setSelectedTestSuitesSource(it) + fetchTestsSourcesVersionInfoList() + } + } + val fetchHandler: (TestSuitesSourceDto) -> Unit = { + setTestSuiteSourceToFetch(it) + testSuitesSourceFetcherWindowOpenness.openWindow() + } + val (testSuiteSourceToUpsert, setTestSuiteSourceToUpsert) = useState() + val editHandler: (TestSuitesSourceDto) -> Unit = { + setTestSuiteSourceToUpsert(it) + testSuitesSourceUpsertWindowOpenness.openWindow() + } + val deleteHandler: (TestsSourceVersionInfo) -> Unit = { + if (window.confirm("Are you sure you want to delete snapshot ${it.version} of ${it.sourceName}?")) { + setTestsSourceVersionInfoToDelete(it) + deleteTestSuitesSourcesSnapshotKey() + setTestsSourceVersionInfoList(testsSourceVersionInfoList.filterNot(it::equals)) + } + } + val refreshTestSuitesSources = useDeferredRequest { + val response = getTestSuitesSourcesWithId(props.organizationName) + if (response.ok) { + setTestSuitesSources(response.decodeFromJsonString()) + } else { + setTestSuitesSources(emptyList()) + } + } + val refreshHandler: () -> Unit = { + refreshTestSuitesSources() + fetchTestsSourcesVersionInfoList() + } + val (managePermissionsMode, setManagePermissionsMode) = useState(null) + manageTestSuitePermissionsComponent { + organizationName = props.organizationName + isModalOpen = managePermissionsMode != null + closeModal = { setManagePermissionsMode(null) } + mode = managePermissionsMode + } + showTestSuiteSourceUpsertModal( + windowOpenness = testSuitesSourceUpsertWindowOpenness, + testSuitesSource = testSuiteSourceToUpsert, + organizationName = props.organizationName, + ) { + setIsSourceCreated { !it } + } + + div { + className = ClassName("d-flex justify-content-center mb-3") + buttonBuilder("+ Create test suites source", "primary", !props.selfRole.hasWritePermission(), classes = "btn-sm mr-2") { + testSuitesSourceUpsertWindowOpenness.openWindow() + } + buttonBuilder("Manage permissions", "info", !props.selfRole.hasWritePermission(), classes = "btn-sm mr-2 ml-2") { + setManagePermissionsMode(PermissionManagerMode.TRANSFER) + } + buttonBuilder("Publish test suites", "info", !props.selfRole.hasWritePermission(), classes = "btn-sm ml-2") { + setManagePermissionsMode(PermissionManagerMode.PUBLISH) + } + } + + div { + className = ClassName("mb-2 d-flex justify-content-center") + when (selectedTestSuitesSource) { + null -> showTestSuitesSources(testSuitesSources, selectHandler, fetchHandler, editHandler, refreshHandler) + else -> showTestsSourceVersionInfoList( + selectedTestSuitesSource, + testsSourceVersionInfoList, + selectHandler, + editHandler, + fetchHandler, + deleteHandler, + refreshHandler, + ) + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationToolsMenu.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationToolsMenu.kt new file mode 100644 index 0000000000..49b43b1763 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationToolsMenu.kt @@ -0,0 +1,376 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.entities.ProjectDto +import com.saveourtool.save.entities.ProjectStatus +import com.saveourtool.save.frontend.common.components.basic.scoreCard +import com.saveourtool.save.frontend.common.components.tables.TableProps +import com.saveourtool.save.frontend.common.components.tables.columns +import com.saveourtool.save.frontend.common.components.tables.tableComponent +import com.saveourtool.save.frontend.common.components.tables.value +import com.saveourtool.save.frontend.common.components.views.* +import com.saveourtool.save.frontend.common.components.views.usersettings.right.actionButtonClasses +import com.saveourtool.save.frontend.common.components.views.usersettings.right.actionIconClasses +import com.saveourtool.save.frontend.common.externals.fontawesome.faRedo +import com.saveourtool.save.frontend.common.externals.fontawesome.faTrashAlt +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.isSuperAdmin +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.validation.FrontendRoutes +import org.w3c.fetch.Response + +import react.* +import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.td +import react.router.dom.Link +import web.cssom.ClassName +import web.html.ButtonType + +/** + * The mandatory column id. + * For each cell, this will be transformed into "cell_%d_delete_button" and + * visible as the key in the "Components" tab of the developer tools. + */ +const val DELETE_BUTTON_COLUMN_ID = "delete_button" + +/** + * Empty table header. + */ +const val EMPTY_COLUMN_HEADER = "" + +val organizationToolsMenu = organizationToolsMenu() + +/** + * OrganizationToolsMenu component props + */ +external interface OrganizationToolsMenuProps : Props { + /** + * Information about current user + */ + var currentUserInfo: UserInfo? + + /** + * [Role] of user that is observing this component + */ + var selfRole: Role + + /** + * Current organization + */ + var organization: OrganizationDto + + /** + * Organization projects + */ + var projects: List + + /** + * lambda for update projects + */ + var updateProjects: (List) -> Unit +} + +private fun ChildrenBuilder.renderTopProject(topProject: ProjectDto?, organizationName: String) { + div { + className = ClassName("col-6 mb-4") + topProject?.let { + scoreCard { + name = it.name + contestScore = it.contestRating + url = "/$organizationName/${it.name}" + } + } + } +} + +/** + * Makes a call to change project status + * + * @param status - the status that will be assigned to the project [project] + * @param projectPath - the path [organizationName/projectName] for response + * @return lazy response + */ +fun responseChangeProjectStatus(projectPath: String, status: ProjectStatus): suspend WithRequestStatusContext.() -> Response = { + post( + url = "$apiUrl/projects/$projectPath/change-status?status=$status", + headers = jsonHeaders, + body = undefined, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) +} + +/** + * Removes the projects specified by [oldProjects], adds projects specified by [newProjects], + * and sorts the resulting list by their status and then by name. + * + * @param projects is list of the projects + * @param oldProject is an old project, it needs to be removed from the list + * @param newProject is a new project, it needs to be added to the list + * @param setProjects is setter to update projects + * @param updateProjects method from props, for changing props + */ +@Suppress("TYPE_ALIAS") +private fun updateOneProjectInProjects( + projects: List, + oldProject: ProjectDto, + newProject: ProjectDto, + updateProjects: (List) -> Unit, +) { + val comparator: Comparator = + compareBy { it.status.ordinal } + .thenBy { it.name } + projects + .minus(oldProject) + .plus(newProject) + .sortedWith(comparator) + .also { sortedProjects -> + updateProjects(sortedProjects) + } +} + +@Suppress("TOO_LONG_FUNCTION", "LongMethod", "CyclomaticComplexMethod") +private fun organizationToolsMenu() = FC { props -> + @Suppress("TYPE_ALIAS") + val tableWithProjects: FC> = tableComponent( + columns = { + columns { + column(id = "name", header = "Evaluated Tool", { name }) { cellContext -> + Fragment.create { + val projectDto = cellContext.row.original + td { + className = ClassName("align-middle text-center") + when (projectDto.status) { + ProjectStatus.CREATED -> div { + Link { + to = "/${projectDto.organizationName}/${cellContext.value}" + +cellContext.value + } + spanWithClassesAndText("text-muted", "active") + } + ProjectStatus.DELETED -> div { + className = ClassName("text-secondary") + +cellContext.value + spanWithClassesAndText("text-secondary", "deleted") + } + ProjectStatus.BANNED -> div { + className = ClassName("text-danger") + +cellContext.value + spanWithClassesAndText("text-danger", "banned") + } + } + } + } + } + column(id = "description", header = "Description") { + Fragment.create { + td { + className = ClassName("align-middle text-center") + +it.value.description + } + } + } + column(id = "rating", header = "Contest Rating") { + Fragment.create { + td { + className = ClassName("align-middle text-center") + +"0" + } + } + } + + /* + * A "secret" possibility to delete projects (intended for super-admins). + */ + if (props.selfRole.isHigherOrEqualThan(Role.OWNER)) { + column(id = DELETE_BUTTON_COLUMN_ID, header = EMPTY_COLUMN_HEADER) { cellProps -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + val project = cellProps.row.original + val projectName = project.name + + when (project.status) { + ProjectStatus.CREATED -> actionButton { + title = "WARNING: You are about to delete this project" + errorTitle = "You cannot delete the project $projectName" + message = """Are you sure you want to delete the project "$projectName"?""" + clickMessage = "Also ban this project" + buttonStyleBuilder = { childrenBuilder -> + with(childrenBuilder) { + fontAwesomeIcon(icon = faTrashAlt, classes = actionIconClasses.joinToString(" ")) + } + } + classes = actionButtonClasses.joinToString(" ") + modalButtons = { action, closeWindow, childrenBuilder, isClickMode -> + val actionName = if (isClickMode) "ban" else "delete" + with(childrenBuilder) { + buttonBuilder(label = "Yes, $actionName $projectName", style = "danger", classes = "mr-2") { + action() + closeWindow() + } + buttonBuilder("Cancel") { + closeWindow() + } + } + } + onActionSuccess = { isBanMode -> + updateOneProjectInProjects(props.projects, + project, + project.copy(status = if (isBanMode) ProjectStatus.BANNED else ProjectStatus.DELETED), + props.updateProjects, + ) + } + conditionClick = props.currentUserInfo.isSuperAdmin() + sendRequest = { isBanned -> + val newStatus = if (isBanned) ProjectStatus.BANNED else ProjectStatus.DELETED + responseChangeProjectStatus("${project.organizationName}/${project.name}", newStatus) + } + } + ProjectStatus.DELETED -> actionButton { + title = "WARNING: You are about to recover this project" + errorTitle = "You cannot recover the project $projectName" + message = """Are you sure you want to recover the project "$projectName"?""" + buttonStyleBuilder = { childrenBuilder -> + with(childrenBuilder) { + fontAwesomeIcon(icon = faRedo, classes = actionIconClasses.joinToString(" ")) + } + } + classes = actionButtonClasses.joinToString(" ") + modalButtons = { action, closeWindow, childrenBuilder, _ -> + with(childrenBuilder) { + buttonBuilder(label = "Yes, recover $projectName", style = "warning", classes = "mr-2") { + action() + closeWindow() + } + buttonBuilder("Cancel") { + closeWindow() + } + } + } + onActionSuccess = { _ -> + updateOneProjectInProjects( + props.projects, + project, + project.copy(status = ProjectStatus.CREATED), + props.updateProjects, + ) + } + conditionClick = false + sendRequest = { _ -> + responseChangeProjectStatus("${project.organizationName}/${project.name}", ProjectStatus.CREATED) + } + } + ProjectStatus.BANNED -> if (props.currentUserInfo.isSuperAdmin()) { + actionButton { + title = "WARNING: You are about to unban this BANNED project" + errorTitle = "You cannot unban the project $projectName" + message = """Are you sure you want to unban the project "$projectName"?""" + buttonStyleBuilder = { childrenBuilder -> + with(childrenBuilder) { + fontAwesomeIcon(icon = faRedo, classes = actionIconClasses.joinToString(" ")) + } + } + classes = actionButtonClasses.joinToString(" ") + modalButtons = { action, closeWindow, childrenBuilder, _ -> + with(childrenBuilder) { + buttonBuilder(label = "Yes, unban $projectName", style = "danger", classes = "mr-2") { + action() + closeWindow() + } + buttonBuilder("Cancel") { + closeWindow() + } + } + } + onActionSuccess = { _ -> + updateOneProjectInProjects( + props.projects, + project, + project.copy(status = ProjectStatus.CREATED), + props.updateProjects, + ) + } + conditionClick = false + sendRequest = { _ -> + responseChangeProjectStatus("${project.organizationName}/${project.name}", ProjectStatus.CREATED) + } + } + } + } + } + } + } + } + } + }, + ) { tableProps -> + /*- + * Necessary for the table to get re-rendered once a project gets + * deleted. + * + * The order and size of the array must remain constant. + */ + arrayOf(tableProps) + } + + div { + className = ClassName("row justify-content-center") + div { + className = ClassName("col-6") + div { + className = ClassName("d-flex justify-content-center mb-2") + if (props.selfRole.isHigherOrEqualThan(Role.ADMIN)) { + Link { + to = "/${FrontendRoutes.CREATE_PROJECT}/${props.organization?.name}" + button { + type = ButtonType.button + className = ClassName("btn btn-outline-info") + +"Add new Tool" + } + } + } + } + + // ================= Rows for TOP projects ================ + val topProjects = props.projects.sortedByDescending { it.contestRating }.take(TOP_PROJECTS_NUMBER) + + if (topProjects.isNotEmpty()) { + // ================= Title for TOP projects =============== + div { + className = ClassName("row justify-content-center mb-2") + ReactHTML.h4 { + +"Top Tools" + } + } + div { + className = ClassName("row justify-content-center") + + renderTopProject(topProjects.getOrNull(0), props.organization.name) + renderTopProject(topProjects.getOrNull(1), props.organization.name) + } + + @Suppress("MAGIC_NUMBER") + (div { + className = ClassName("row justify-content-center") + + renderTopProject(topProjects.getOrNull(2), props.organization.name) + renderTopProject(topProjects.getOrNull(3), props.organization.name) + }) + } + + tableWithProjects { + getData = { _, _ -> + props.projects.toTypedArray() + } + getPageCount = null + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationType.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationType.kt new file mode 100644 index 0000000000..4539e20519 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationType.kt @@ -0,0 +1,11 @@ +package com.saveourtool.save.frontend.common.components.views.organization + +/** + * A interface for organization view. + */ +interface OrganizationType { + /** + * List tabs for view. + */ + val listTab: Array +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationView.kt new file mode 100644 index 0000000000..0f1e8aa9b6 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/OrganizationView.kt @@ -0,0 +1,214 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", +) + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.entities.ProjectDto +import com.saveourtool.save.entities.ProjectStatus +import com.saveourtool.save.filters.ProjectFilter +import com.saveourtool.save.frontend.common.components.basic.AVATAR_ORGANIZATION_PLACEHOLDER +import com.saveourtool.save.frontend.common.components.basic.avatarRenderer +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.utils.getHighestRole + +import js.core.jso +import org.w3c.fetch.Headers +import react.FC +import react.Props +import react.useState + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +val organizationView: FC = FC { props -> + useBackground(Style.SAVE_LIGHT) + + val (organization, setOrganization) = useState(OrganizationDto.empty) + val (selectedMenu, setSelectedMenu) = useState(OrganizationMenuBar.defaultTab) + val (closeButtonLabel, setCloseButtonLabel) = useState(null) + val (selfRole, setSelfRole) = useState(Role.NONE) + val (isErrorOpen, setIsErrorOpen) = useState(false) + val (errorMessage, setErrorMessage) = useState("") + val (errorLabel, setErrorLabel) = useState("") + val (isAvatarWindowOpen, setIsAvatarWindowOpen) = useState(false) + val (usersInOrganization, setUsersInOrganization) = useState>(emptyList()) + val (avatar, setAvatar) = useState(AVATAR_ORGANIZATION_PLACEHOLDER) + val (projects, setProjects) = useState>(emptyList()) + + val (canCreateContests, setCanCreateContests) = useState(false) + val (canBulkUpload, setCanBulkUpload) = useState(false) + + val valuesOrganizationMenuBar: Array = props.organizationType.listTab + + useRequest { + val organizationLoaded: OrganizationDto = get( + "$apiUrl/organizations/${props.organizationName}", + jsonHeaders, + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + .decodeFromJsonString() + setOrganization(organizationLoaded) + organizationLoaded.avatar?.let { setAvatar(it.avatarRenderer()) } + setCanCreateContests(organizationLoaded.canCreateContests) + + val comparator: Comparator = + compareBy { it.status.ordinal } + .thenBy { it.name } + + val projectsLoaded = post( + url = "$apiUrl/projects/by-filters", + headers = jsonHeaders, + body = Json.encodeToString(ProjectFilter("", props.organizationName, enumValues().toSet())), + loadingHandler = ::loadingHandler, + ) + .unsafeMap { + it.decodeFromJsonString>() + } + setProjects(projectsLoaded.sortedWith(comparator)) + + val role = get( + url = "$apiUrl/organizations/${props.organizationName}/users/roles", + headers = Headers().also { + it.set("Accept", "application/json") + }, + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + .unsafeMap { + it.decodeFromJsonString() + } + + val users = get( + url = "$apiUrl/organizations/${props.organizationName}/users", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .unsafeMap { it.decodeFromJsonString>() } + + val highestRole = getHighestRole(role, props.currentUserInfo?.globalRole) + + setSelfRole(highestRole) + setUsersInOrganization(users) + } + + val onCanCreateContestsChange = useDeferredRequest { + val response = post( + "$apiUrl/organizations/${props.organizationName}/manage-contest-permission?isAbleToCreateContests=${!organization.canCreateContests}", + headers = jsonHeaders, + undefined, + loadingHandler = ::loadingHandler, + ) + if (response.ok) { + setOrganization { + it.copy(canCreateContests = canCreateContests) + } + } + } + + val onCanBulkUploadCosvFilesChange = useDeferredRequest { + val response = post( + "$apiUrl/organizations/${props.organizationName}/manage-bulk-upload-permission", + params = jso { + isAbleToToBulkUpload = !organization.canBulkUpload + }, + headers = jsonHeaders, + undefined, + loadingHandler = ::loadingHandler, + ) + if (response.ok) { + setOrganization { + it.copy(canBulkUpload = canBulkUpload) + } + } + } + + renderOrganizationMenuBar { + this.isAvatarWindowOpen = isAvatarWindowOpen + this.setIsAvatarWindowOpen = setIsAvatarWindowOpen + this.avatar = avatar + this.setAvatar = setAvatar + this.organization = organization + this.valuesOrganizationMenuBar = valuesOrganizationMenuBar + this.selectedMenu = selectedMenu + this.setSelectedMenu = setSelectedMenu + this.selfRole = selfRole + this.organizationName = props.organizationName + } + + when (selectedMenu) { + OrganizationMenuBar.INFO -> renderInfoTab { + this.usersInOrganization = usersInOrganization + this.organization = organization + this.setOrganization = setOrganization + this.selfRole = selfRole + this.organizationName = props.organizationName + } + OrganizationMenuBar.SETTINGS -> organizationSettingsMenu { + this.organizationName = props.organizationName + this.currentUserInfo = props.currentUserInfo ?: UserInfo(name = "Undefined") + this.selfRole = selfRole + this.updateErrorMessage = { response, message -> + setIsErrorOpen(true) + setErrorLabel(response.statusText) + setErrorMessage(message) + } + this.updateNotificationMessage = { notificationLabel, notificationMessage -> + setIsErrorOpen(true) + setErrorLabel(notificationLabel) + setErrorMessage(notificationMessage) + setCloseButtonLabel("Confirm") + } + this.organization = organization + this.onCanCreateContestsChange = { isCreateContests -> + setCanCreateContests(isCreateContests) + onCanCreateContestsChange() + } + this.onCanBulkUploadCosvFilesChange = { isCanBulkUpload -> + setCanBulkUpload(isCanBulkUpload) + onCanBulkUploadCosvFilesChange() + } + } + OrganizationMenuBar.VULNERABILITIES -> renderVulnerabilitiesTab { + this.currentUserInfo = props.currentUserInfo + this.organizationName = props.organizationName + this.selfRole = selfRole + } + OrganizationMenuBar.BENCHMARKS -> organizationTestsMenu { + this.organizationName = props.organizationName + this.selfRole = selfRole + } + OrganizationMenuBar.CONTESTS -> organizationContestsMenu { + this.organizationName = props.organizationName + this.selfRole = selfRole + this.updateErrorMessage = { response -> + setIsErrorOpen(true) + setErrorLabel("") + setErrorMessage("Failed to create contest: ${response.status} ${response.statusText}") + } + } + OrganizationMenuBar.TOOLS -> organizationToolsMenu { + this.currentUserInfo = props.currentUserInfo + this.selfRole = selfRole + this.organization = organization + this.projects = projects + this.updateProjects = { projectsList -> + setProjects(projectsList) + } + } + } +} + +/** + * `Props` retrieved from router + */ +@Suppress("MISSING_KDOC_CLASS_ELEMENTS") +external interface OrganizationProps : Props { + var organizationName: String + var currentUserInfo: UserInfo? + var organizationType: OrganizationType +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderInfoTab.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderInfoTab.kt new file mode 100644 index 0000000000..ccb17605f3 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderInfoTab.kt @@ -0,0 +1,146 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", +) + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.frontend.common.components.basic.userBoard +import com.saveourtool.save.frontend.common.externals.fontawesome.faCheck +import com.saveourtool.save.frontend.common.externals.fontawesome.faEdit +import com.saveourtool.save.frontend.common.externals.fontawesome.faTimesCircle +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h6 +import react.dom.html.ReactHTML.textarea +import web.cssom.AlignItems +import web.cssom.ClassName +import web.cssom.Display +import web.cssom.rem +import web.html.ButtonType + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +const val TOP_PROJECTS_NUMBER = 4 + +internal val renderInfoTab: FC = FC { props -> + + val (draftOrganizationDescription, setDraftOrganizationDescription) = useStateFromProps(props.organization.description) + val (isEditDisabled, setIsEditDisabled) = useState(true) + + val fetchOrganizationSave = useDeferredRequest { + props.organization.copy( + description = draftOrganizationDescription + ).let { organizationWithNewDescription -> + val response = post( + "$apiUrl/organizations/${organizationWithNewDescription.name}/update", + jsonHeaders, + Json.encodeToString(organizationWithNewDescription), + loadingHandler = ::noopLoadingHandler, + ) + if (response.ok) { + props.setOrganization(organizationWithNewDescription) + } + } + } + + div { + className = ClassName("row justify-content-center") + + div { + className = ClassName("col-4 mb-4") + div { + className = ClassName("card shadow mb-4") + div { + className = ClassName("card-header py-3") + div { + className = ClassName("row") + h6 { + className = ClassName("m-0 font-weight-bold text-primary") + style = jso { + display = Display.flex + alignItems = AlignItems.center + } + +"Description" + } + if (props.selfRole.hasWritePermission() && isEditDisabled) { + button { + type = ButtonType.button + className = ClassName("btn btn-link text-xs text-muted text-left ml-auto") + +"Edit " + fontAwesomeIcon(icon = faEdit) + onClick = { + setIsEditDisabled(false) + } + } + } + } + } + div { + className = ClassName("card-body") + textarea { + className = ClassName("auto_height form-control-plaintext pt-0 pb-0") + value = draftOrganizationDescription + disabled = !props.selfRole.hasWritePermission() || isEditDisabled + onChange = { + setDraftOrganizationDescription(it.target.value) + } + } + } + div { + className = ClassName("ml-3 mt-2 align-items-right float-right") + button { + type = ButtonType.button + className = ClassName("btn") + fontAwesomeIcon(icon = faCheck) + hidden = !props.selfRole.hasWritePermission() || isEditDisabled + onClick = { + fetchOrganizationSave() + setIsEditDisabled(true) + } + } + + button { + type = ButtonType.button + className = ClassName("btn") + fontAwesomeIcon(icon = faTimesCircle) + hidden = !props.selfRole.hasWritePermission() || isEditDisabled + onClick = { + setIsEditDisabled(true) + } + } + } + } + } + + div { + className = ClassName("col-2") + userBoard { + users = props.usersInOrganization + avatarOuterClasses = "col-4 px-0" + avatarInnerClasses = "mx-sm-3" + widthAndHeight = 6.rem + } + } + } +} + +/** + * RenderInfoTab component props + */ +@Suppress("MISSING_KDOC_CLASS_ELEMENTS") +external interface RenderInfoTabProps : Props { + var usersInOrganization: List + var organization: OrganizationDto + var setOrganization: StateSetter + var selfRole: Role + var organizationName: String +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderOrganizationMenuBar.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderOrganizationMenuBar.kt new file mode 100644 index 0000000000..0404063bb1 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderOrganizationMenuBar.kt @@ -0,0 +1,136 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", +) + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.frontend.common.components.basic.AVATAR_ORGANIZATION_PLACEHOLDER +import com.saveourtool.save.frontend.common.components.basic.avatarForm +import com.saveourtool.save.frontend.common.components.views.usersettings.AVATAR_TITLE +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.utils.AvatarType +import com.saveourtool.save.utils.CONTENT_LENGTH_CUSTOM +import com.saveourtool.save.utils.FILE_PART_NAME + +import js.core.jso +import org.w3c.fetch.Headers +import react.FC +import react.Props +import react.StateSetter +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.label +import react.useState +import web.cssom.* +import web.file.File +import web.http.FormData + +import kotlinx.browser.window + +internal val renderOrganizationMenuBar: FC = FC { props -> + + val (avatarFile, setAvatarFile) = useState(null) + + val fetchAvatarUpload = useDeferredRequest { + avatarFile?.let { file -> + val response = post( + url = "$apiUrl/avatar/upload", + params = jso { + owner = props.organizationName + this.type = AvatarType.ORGANIZATION + }, + Headers().apply { append(CONTENT_LENGTH_CUSTOM, file.size.toString()) }, + FormData().apply { set(FILE_PART_NAME, file) }, + loadingHandler = ::noopLoadingHandler, + ) + if (response.ok) { + window.location.reload() + } + } + } + + avatarForm { + isOpen = props.isAvatarWindowOpen + title = AVATAR_TITLE + onCloseWindow = { + props.setIsAvatarWindowOpen(true) + } + imageUpload = { file -> + setAvatarFile(file) + fetchAvatarUpload() + } + } + + div { + className = ClassName("row d-flex") + div { + className = ClassName("col-3 ml-auto justify-content-center") + style = jso { + display = Display.flex + alignItems = AlignItems.center + } + label { + className = ClassName("btn") + title = AVATAR_TITLE + onClick = { + props.setIsAvatarWindowOpen(true) + } + img { + className = ClassName("avatar avatar-user width-full border color-bg-default rounded-circle") + src = props.avatar + style = jso { + height = "10rem".unsafeCast() + width = "10rem".unsafeCast() + } + onError = { + props.setAvatar(AVATAR_ORGANIZATION_PLACEHOLDER) + } + } + } + + h1 { + className = ClassName("h3 mb-0 text-gray-800 ml-2") + +(props.organization?.name ?: "N/A") + } + } + + val listTabs = props.valuesOrganizationMenuBar.filter { + it != OrganizationMenuBar.SETTINGS || props.selfRole.isHigherOrEqualThan(Role.ADMIN) + } + + div { + className = ClassName("col-auto mx-0") + tab( + props.selectedMenu.name, + listTabs.map { it.name }, + "nav nav-tabs mt-3" + ) { value -> + props.setSelectedMenu { OrganizationMenuBar.valueOf(value) } + } + } + + div { + className = ClassName("col-3 mr-auto justify-content-center align-items-center") + } + } +} + +/** + * RenderOrganizationMenuBar component props + */ +@Suppress("MISSING_KDOC_CLASS_ELEMENTS") +external interface RenderOrganizationMenuBarProps : Props { + var isAvatarWindowOpen: Boolean + var setIsAvatarWindowOpen: StateSetter + var avatar: String + var setAvatar: StateSetter + var organization: OrganizationDto? + var valuesOrganizationMenuBar: Array + var selectedMenu: OrganizationMenuBar + var setSelectedMenu: StateSetter + var selfRole: Role + var organizationName: String +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderVulnerabilitiesTab.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderVulnerabilitiesTab.kt new file mode 100644 index 0000000000..c817080db7 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/RenderVulnerabilitiesTab.kt @@ -0,0 +1,46 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", +) + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.frontend.common.components.views.vuln.vulnerabilityTableComponent +import com.saveourtool.save.info.UserInfo +import react.FC +import react.Props +import react.dom.html.ReactHTML.div +import web.cssom.ClassName + +internal val renderVulnerabilitiesTab: FC = FC { props -> + + div { + className = ClassName("col-7 mx-auto mb-4") + + vulnerabilityTableComponent { + this.currentUserInfo = props.currentUserInfo + this.organizationName = props.organizationName + this.isCurrentUserIsAdminInOrganization = props.selfRole.isHigherOrEqualThan(Role.ADMIN) + } + } +} + +/** + * RenderVulnerabilitiesTab component props + */ +external interface RenderVulnerabilitiesTabProps : Props { + /** + * Current logged-in user + */ + var currentUserInfo: UserInfo? + + /** + * [Role] of user that is observing this component + */ + var selfRole: Role + + /** + * Name of organization + */ + var organizationName: String +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/TestSuitesSourcesDisplayer.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/TestSuitesSourcesDisplayer.kt new file mode 100644 index 0000000000..5160fa86cd --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/organization/TestSuitesSourcesDisplayer.kt @@ -0,0 +1,203 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", + "FILE_WILDCARD_IMPORTS", + "WildcardImport", + "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", +) + +package com.saveourtool.save.frontend.common.components.views.organization + +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.test.TestsSourceVersionInfo +import com.saveourtool.save.test.TestsSourceVersionInfoList +import com.saveourtool.save.testsuite.* +import com.saveourtool.save.utils.prettyPrint + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.ul +import web.cssom.ClassName +import web.cssom.Cursor + +/** + * Display single TestSuiteSource as list option + * + * @param isSelected flag that defines if this test suite source is selected or not + * @param testSuitesSourceDto + * @param selectHandler callback invoked on TestSuitesSource selection + * @param editHandler callback invoked on edit TestSuitesSource button pressed + * @param fetchHandler callback invoked on fetch button pressed + * @param refreshHandler callback invoked on refresh button pressed + */ +@Suppress( + "TOO_LONG_FUNCTION", + "TOO_MANY_PARAMETERS", + "LongParameterList", + "LongMethod", +) +fun ChildrenBuilder.showTestSuitesSourceAsListElement( + testSuitesSourceDto: TestSuitesSourceDto, + isSelected: Boolean, + selectHandler: (TestSuitesSourceDto) -> Unit, + editHandler: (TestSuitesSourceDto) -> Unit, + fetchHandler: (TestSuitesSourceDto) -> Unit, + refreshHandler: () -> Unit, +) { + val active = if (isSelected) "list-group-item-secondary" else "" + li { + className = ClassName("list-group-item $active") + div { + className = ClassName("d-flex w-100 justify-content-between") + button { + className = ClassName("btn btn-lg btn-link p-0 mb-1") + onClick = { + selectHandler(testSuitesSourceDto) + } + label { + style = jso { + cursor = "pointer".unsafeCast() + } + fontAwesomeIcon( + if (isSelected) { + faArrowLeft + } else { + faArrowRight + } + ) + +(" ${testSuitesSourceDto.name}") + } + } + + buttonBuilder(faEdit, null, title = "Edit source") { + editHandler(testSuitesSourceDto) + } + } + div { + p { + +(testSuitesSourceDto.description ?: "Description is not provided.") + } + } + div { + className = ClassName("clearfix") + div { + className = ClassName("float-left") + buttonBuilder("Fetch new version", "info", isOutline = true, classes = "btn-sm mr-2") { + fetchHandler(testSuitesSourceDto) + } + } + if (isSelected) { + div { + className = ClassName("float-left") + buttonBuilder(faSyncAlt, "info", isOutline = false, classes = "btn-sm mr-2") { + refreshHandler() + } + } + } + span { + className = ClassName("float-right align-bottom") + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + title = "Organization-creator" + +(testSuitesSourceDto.organizationName) + } + } + } +} + +/** + * Display list of TestSuiteSources as a list + * + * @param testSuitesSources [TestSuitesSourceDtoList] + * @param selectHandler callback invoked on TestSuitesSource selection + * @param editHandler callback invoked on edit TestSuitesSource button pressed + * @param fetchHandler callback invoked on fetch button pressed + * @param refreshHandler + */ +fun ChildrenBuilder.showTestSuitesSources( + testSuitesSources: TestSuitesSourceDtoList, + selectHandler: (TestSuitesSourceDto) -> Unit, + fetchHandler: (TestSuitesSourceDto) -> Unit, + editHandler: (TestSuitesSourceDto) -> Unit, + refreshHandler: () -> Unit, +) { + div { + className = ClassName("list-group col-8") + testSuitesSources.forEach { + showTestSuitesSourceAsListElement(it, false, selectHandler, editHandler, fetchHandler, refreshHandler) + } + } +} + +/** + * Display list of [TestsSourceVersionInfo] of [selectedTestSuiteSource] + * + * @param selectedTestSuiteSource + * @param testsSourceVersionInfoList + * @param selectHandler callback invoked on TestSuitesSource selection + * @param editHandler callback invoked on edit TestSuitesSource button pressed + * @param fetchHandler callback invoked on fetch button pressed + * @param deleteHandler callback invoked on [TestsSourceVersionInfo] deletion + * @param refreshHandler + */ +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS") +fun ChildrenBuilder.showTestsSourceVersionInfoList( + selectedTestSuiteSource: TestSuitesSourceDto, + testsSourceVersionInfoList: TestsSourceVersionInfoList, + selectHandler: (TestSuitesSourceDto) -> Unit, + editHandler: (TestSuitesSourceDto) -> Unit, + fetchHandler: (TestSuitesSourceDto) -> Unit, + deleteHandler: (TestsSourceVersionInfo) -> Unit, + refreshHandler: () -> Unit, +) { + ul { + className = ClassName("list-group col-8") + showTestSuitesSourceAsListElement(selectedTestSuiteSource, true, selectHandler, editHandler, fetchHandler, refreshHandler) + if (testsSourceVersionInfoList.isEmpty()) { + li { + className = ClassName("list-group-item list-group-item-light") + +"This source is not fetched yet..." + } + } else { + li { + className = ClassName("list-group-item list-group-item-light") + div { + className = ClassName("clearfix") + div { + className = ClassName("float-left") + +"Version" + } + div { + className = ClassName("float-right") + +"Git commit time" + } + } + } + testsSourceVersionInfoList.forEach { testsSourceVersionInfo -> + li { + className = ClassName("list-group-item") + div { + className = ClassName("clearfix") + div { + className = ClassName("float-left") + +testsSourceVersionInfo.version + } + buttonBuilder(faTimesCircle, style = null, classes = "float-right btn-sm pt-0 pb-0") { + deleteHandler(testsSourceVersionInfo) + } + div { + className = ClassName("float-right") + +testsSourceVersionInfo.creationTime.prettyPrint() + } + } + } + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestCreationComponent.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestCreationComponent.kt index bd6ca02c0f..d12bfc7171 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestCreationComponent.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestCreationComponent.kt @@ -3,6 +3,7 @@ package com.saveourtool.save.frontend.components.basic.contests import com.saveourtool.save.entities.contest.ContestDto +import com.saveourtool.save.frontend.common.components.basic.contests.ContestCreationComponentProps import com.saveourtool.save.frontend.components.basic.* import com.saveourtool.save.frontend.components.basic.testsuiteselector.showContestTestSuitesSelectorModal import com.saveourtool.save.frontend.components.inputform.* diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestInfoMenu.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestInfoMenu.kt index d3f03791a0..a875c532fc 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestInfoMenu.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestInfoMenu.kt @@ -3,6 +3,8 @@ package com.saveourtool.save.frontend.components.basic.contests import com.saveourtool.save.entities.contest.ContestDto +import com.saveourtool.save.frontend.common.components.basic.contests.ContestInfoMenuProps +import com.saveourtool.save.frontend.common.components.basic.contests.publicTestComponent import com.saveourtool.save.frontend.components.basic.cardComponent import com.saveourtool.save.frontend.components.basic.markdown import com.saveourtool.save.frontend.utils.* 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 index df40c89ea3..aee419bd65 100644 --- 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 @@ -4,6 +4,7 @@ package com.saveourtool.save.frontend.components.basic.contests import com.saveourtool.save.entities.contest.ContestResult import com.saveourtool.save.execution.ExecutionStatus +import com.saveourtool.save.frontend.common.components.basic.contests.ContestSubmissionsMenuProps import com.saveourtool.save.frontend.components.tables.TableProps import com.saveourtool.save.frontend.components.tables.columns import com.saveourtool.save.frontend.components.tables.tableComponent diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSummaryMenu.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSummaryMenu.kt index 9a69e2c2f3..bc73a42206 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSummaryMenu.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/ContestSummaryMenu.kt @@ -3,6 +3,7 @@ package com.saveourtool.save.frontend.components.basic.contests import com.saveourtool.save.entities.contest.ContestResult +import com.saveourtool.save.frontend.common.components.basic.contests.ContestSummaryMenuProps import com.saveourtool.save.frontend.utils.* import react.* diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/PublicTestCardComponent.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/PublicTestCardComponent.kt index 6a25a72bb0..684f8749c3 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/PublicTestCardComponent.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/contests/PublicTestCardComponent.kt @@ -2,6 +2,7 @@ package com.saveourtool.save.frontend.components.basic.contests +import com.saveourtool.save.frontend.common.components.basic.contests.PublicTestComponentProps import com.saveourtool.save.frontend.components.basic.* import com.saveourtool.save.frontend.externals.markdown.reactMarkdown import com.saveourtool.save.frontend.externals.markdown.rehype.rehypeHighlightPlugin 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 43f2acd219..ca20387243 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 @@ -3,10 +3,10 @@ package com.saveourtool.save.frontend.components.views import com.saveourtool.save.entities.contest.ContestDto +import com.saveourtool.save.frontend.common.components.basic.contests.contestInfoMenu +import com.saveourtool.save.frontend.common.components.basic.contests.contestSubmissionsMenu +import com.saveourtool.save.frontend.common.components.basic.contests.contestSummaryMenu import com.saveourtool.save.frontend.components.RequestStatusContext -import com.saveourtool.save.frontend.components.basic.contests.contestInfoMenu -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.http.getContest import com.saveourtool.save.frontend.utils.* diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationAdminView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationAdminView.kt index a3b3b9c6c2..9b2ae4d66c 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationAdminView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationAdminView.kt @@ -5,12 +5,12 @@ package com.saveourtool.save.frontend.components.views import com.saveourtool.save.entities.OrganizationDto import com.saveourtool.save.entities.OrganizationStatus import com.saveourtool.save.filters.OrganizationFilter -import com.saveourtool.save.frontend.components.basic.organizations.responseChangeOrganizationStatus import com.saveourtool.save.frontend.components.tables.TableProps import com.saveourtool.save.frontend.components.tables.columns import com.saveourtool.save.frontend.components.tables.tableComponent import com.saveourtool.save.frontend.components.tables.value import com.saveourtool.save.frontend.externals.fontawesome.* +import com.saveourtool.save.frontend.http.responseChangeOrganizationStatus import com.saveourtool.save.frontend.utils.* import com.saveourtool.save.frontend.utils.classLoadingHandler diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt index 654b532457..f29e6e0bdb 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt @@ -7,7 +7,7 @@ package com.saveourtool.save.frontend.components.views import com.saveourtool.save.domain.Role import com.saveourtool.save.entities.* import com.saveourtool.save.filters.ProjectFilter -import com.saveourtool.save.frontend.common.components.views.vuln.vulnerabilityTableComponent +import com.saveourtool.save.frontend.common.components.views.organization.organizationSettingsMenu import com.saveourtool.save.frontend.components.RequestStatusContext import com.saveourtool.save.frontend.components.basic.* import com.saveourtool.save.frontend.components.basic.organizations.* @@ -105,16 +105,6 @@ external interface OrganizationViewState : StateWithRole, State { */ var errorLabel: String - /** - * State for the creation of unified confirmation logic - */ - var confirmationType: ConfirmationType - - /** - * Flag to handle confirm Window - */ - var isConfirmWindowOpen: Boolean - /** * Whether editing of organization info is disabled */ @@ -162,9 +152,7 @@ class OrganizationView : AbstractView( state.closeButtonLabel = null state.selfRole = Role.NONE state.draftOrganizationDescription = "" - state.isConfirmWindowOpen = false state.isErrorOpen = false - state.confirmationType = ConfirmationType.DELETE_CONFIRM state.isAvatarWindowOpen = false } @@ -220,7 +208,6 @@ class OrganizationView : AbstractView( OrganizationMenuBar.BENCHMARKS -> renderBenchmarks() OrganizationMenuBar.SETTINGS -> renderSettings() OrganizationMenuBar.CONTESTS -> renderContests() - OrganizationMenuBar.VULNERABILITIES -> renderVulnerabilities() } } @@ -398,18 +385,6 @@ class OrganizationView : AbstractView( } } - private fun ChildrenBuilder.renderVulnerabilities() { - div { - className = ClassName("col-7 mx-auto mb-4") - - vulnerabilityTableComponent { - this.currentUserInfo = props.currentUserInfo - this.organizationName = props.organizationName - this.isCurrentUserIsAdminInOrganization = state.selfRole.isHigherOrEqualThan(Role.ADMIN) - } - } - } - private fun ChildrenBuilder.renderSettings() { organizationSettingsMenu { organizationName = props.organizationName @@ -425,7 +400,7 @@ class OrganizationView : AbstractView( updateNotificationMessage = ::showNotification organization = state.organization ?: OrganizationDto.empty onCanCreateContestsChange = ::onCanCreateContestsChange - onCanBulkUploadCosvFilesChange = ::onCanBulkUploadCosvFilesChange + onCanBulkUploadCosvFilesChange = ::onCanCreateContestsChange } } @@ -461,25 +436,6 @@ class OrganizationView : AbstractView( } } - private fun onCanBulkUploadCosvFilesChange(canBulkUpload: Boolean) { - scope.launch { - val response = post( - "$apiUrl/organizations/${props.organizationName}/manage-bulk-upload-permission", - params = jso { - isAbleToToBulkUpload = !state.organization!!.canBulkUpload - }, - headers = jsonHeaders, - undefined, - loadingHandler = ::classLoadingHandler, - ) - if (response.ok) { - setState { - organization = organization?.copy(canBulkUpload = canBulkUpload) - } - } - } - } - private suspend fun getRoleInOrganization(): Role = get( url = "$apiUrl/organizations/${props.organizationName}/users/roles", headers = Headers().also { diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/right/Organizations.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/right/Organizations.kt index 7a248ec77c..92323ef9ad 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/right/Organizations.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/right/Organizations.kt @@ -10,13 +10,13 @@ import com.saveourtool.save.entities.OrganizationWithUsers import com.saveourtool.save.filters.OrganizationFilter import com.saveourtool.save.frontend.components.basic.AVATAR_ORGANIZATION_PLACEHOLDER import com.saveourtool.save.frontend.components.basic.avatarRenderer -import com.saveourtool.save.frontend.components.basic.organizations.responseChangeOrganizationStatus import com.saveourtool.save.frontend.components.views.actionButtonClasses import com.saveourtool.save.frontend.components.views.actionIconClasses import com.saveourtool.save.frontend.components.views.usersettings.SettingsProps import com.saveourtool.save.frontend.externals.fontawesome.faRedo import com.saveourtool.save.frontend.externals.fontawesome.faTrashAlt import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.http.responseChangeOrganizationStatus import com.saveourtool.save.frontend.utils.* import com.saveourtool.save.validation.FrontendRoutes diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/http/Requests.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/http/Requests.kt index 0d1abeb197..58932df224 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/http/Requests.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/http/Requests.kt @@ -180,6 +180,23 @@ suspend fun ComponentWithScope<*, *>.getExecutionInfoFor( ::noopResponseHandler ) +/** + * Makes a call to change project status + * + * @param organizationName name of the organization whose status will be changed + * @param status is new status + * @return lazy response + */ +fun responseChangeOrganizationStatus(organizationName: String, status: OrganizationStatus): suspend WithRequestStatusContext.() -> Response = { + post( + url = "$apiUrl/organizations/$organizationName/change-status?status=$status", + headers = jsonHeaders, + body = undefined, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) +} + @Suppress("TYPE_ALIAS") private suspend fun getDebugInfoFor( testExecutionId: Long, diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt index 39c6f0df06..5574ead9ce 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/routing/BasicRouting.kt @@ -9,6 +9,8 @@ package com.saveourtool.save.frontend.routing import com.saveourtool.save.domain.ProjectCoordinates import com.saveourtool.save.domain.TestResultStatus import com.saveourtool.save.filters.TestExecutionFilter +import com.saveourtool.save.frontend.common.components.views.organization.createOrganizationView +import com.saveourtool.save.frontend.common.components.views.organization.organizationView import com.saveourtool.save.frontend.components.basic.projects.createProjectProblem import com.saveourtool.save.frontend.components.basic.projects.projectProblem import com.saveourtool.save.frontend.components.views.* @@ -93,11 +95,11 @@ val basicRouting: FC = FC { props -> } } - val organizationView = withRouter { location, params -> - OrganizationView::class.react { + val organizationView = com.saveourtool.save.frontend.common.utils.withRouter { location, params -> + organizationView { organizationName = params["owner"]!! currentUserInfo = props.userInfo - this.location = location + organizationType = SaveOrganizationType } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/OrganizationMenuBar.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/OrganizationMenuBar.kt index e61719d21f..05bc2c55d9 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/OrganizationMenuBar.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/OrganizationMenuBar.kt @@ -9,7 +9,6 @@ enum class OrganizationMenuBar(private val title: String? = null) { TOOLS, BENCHMARKS, CONTESTS, - VULNERABILITIES, SETTINGS, ; diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/SaveOrganizationType.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/SaveOrganizationType.kt new file mode 100644 index 0000000000..d791e5e69a --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/utils/SaveOrganizationType.kt @@ -0,0 +1,14 @@ +package com.saveourtool.save.frontend.utils + +import com.saveourtool.save.frontend.common.components.views.organization.OrganizationMenuBar +import com.saveourtool.save.frontend.common.components.views.organization.OrganizationType + +object SaveOrganizationType : OrganizationType { + override val listTab: Array = arrayOf( + OrganizationMenuBar.INFO, + OrganizationMenuBar.TOOLS, + OrganizationMenuBar.BENCHMARKS, + OrganizationMenuBar.CONTESTS, + OrganizationMenuBar.SETTINGS + ) +}