diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 8cc2f2e7ed..61945634e7 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -52,6 +52,7 @@ jobs: 'save-backend', 'save-orchestrator', 'save-frontend', + 'save-cosv-frontend', 'save-preprocessor', 'test-utils', 'save-api', diff --git a/.run/Cosv-Frontend-LocalRun.run.xml b/.run/Cosv-Frontend-LocalRun.run.xml new file mode 100644 index 0000000000..21f5eb7627 --- /dev/null +++ b/.run/Cosv-Frontend-LocalRun.run.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/save-cosv-frontend/README.md b/save-cosv-frontend/README.md new file mode 100644 index 0000000000..7f4313cc15 --- /dev/null +++ b/save-cosv-frontend/README.md @@ -0,0 +1,16 @@ +# COSV Frontend + +### Building +* For IR usage see https://github.com/JetBrains/kotlin-wrappers#experimental-ir-backend + +To run frontend locally, use `./gradlew :save-cosv-frontend:browserDevelopmentRun --continuous` or `./gradlew :save-cosv-frontend:browserProductionRun --continuous`. + +To pack distribution, use `./gradlew :save-cosv-frontend:browserDevelopmentWebpack` and `./gradlew :save-cosv-frontend:browserProductionWebpack`. + +save-backend consumes frontend distribution as a dependency. Frontend distribution is copied and included in spring boot resources. + +### `nginx` [configuration](../save-frontend-common/README.md) + +### `webpack-dev-server` [configuration for no `api-gateway` run](../save-frontend-common/README.md) + +### [Using OAuth with a local deployment (`api-gateway` on)](../save-frontend-common/README.md) diff --git a/save-cosv-frontend/build.gradle.kts b/save-cosv-frontend/build.gradle.kts new file mode 100644 index 0000000000..8954aa8dfa --- /dev/null +++ b/save-cosv-frontend/build.gradle.kts @@ -0,0 +1,261 @@ +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin +import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest + +@Suppress("DSL_SCOPE_VIOLATION", "RUN_IN_SCRIPT") // https://github.com/gradle/gradle/issues/22797 +plugins { + kotlin("js") + id("com.saveourtool.save.buildutils.build-frontend-image-configuration") + id("com.saveourtool.save.buildutils.code-quality-convention") + id("com.saveourtool.save.buildutils.save-cloud-version-file-configuration") + alias(libs.plugins.kotlin.plugin.serialization) +} + +rootProject.plugins.withType { + rootProject.the().nodeVersion = "16.13.1" +} + +dependencies { + implementation(projects.saveCloudCommon) + implementation(projects.saveFrontendCommon) + + implementation(enforcedPlatform(libs.kotlin.wrappers.bom)) + implementation("org.jetbrains.kotlin-wrappers:kotlin-react") + implementation("org.jetbrains.kotlin-wrappers:kotlin-extensions") + implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom") + implementation("org.jetbrains.kotlin-wrappers:kotlin-react-router-dom") + implementation("org.jetbrains.kotlin-wrappers:kotlin-tanstack-react-table") + implementation("org.jetbrains.kotlin-wrappers:kotlin-mui-icons") + implementation("org.jetbrains.kotlin-wrappers:kotlin-mui") + implementation("io.github.petertrr:kotlin-multiplatform-diff-js:0.4.0") + + implementation(libs.save.common) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + implementation(libs.ktor.http) +} + +val distributionsDirName = "distributions" + +kotlin { + js(IR) { + // as for `-pre.148-kotlin-1.4.21`, react-table gives errors with IR + browser { + distribution( + Action { + // TODO: need to remove this overriding + outputDirectory = layout.buildDirectory.dir(distributionsDirName) + } + ) + testTask { + useKarma { + when (properties["save.profile"]) { + "dev" -> { + useChrome() + // useFirefox() + } + null -> useChromeHeadless() + } + } + } + commonWebpackConfig { + cssSupport { + enabled.set(true) + } + } + } + // kotlin-wrapper migrates to commonjs and missed @JsNonModule annotations + // https://github.com/JetBrains/kotlin-wrappers/issues/1935 + useCommonJs() + binaries.executable() // already default for LEGACY, but explicitly needed for IR + sourceSets.all { + languageSettings.apply { + optIn("kotlin.RequiresOptIn") + optIn("kotlinx.serialization.ExperimentalSerializationApi") + optIn("kotlin.js.ExperimentalJsExport") + } + } + sourceSets["main"].dependencies { + compileOnly(devNpm("sass", "^1.43.0")) + compileOnly(devNpm("sass-loader", "^12.0.0")) + compileOnly(devNpm("style-loader", "^3.3.1")) + compileOnly(devNpm("css-loader", "^6.5.0")) + compileOnly(devNpm("file-loader", "^6.2.0")) + // https://getbootstrap.com/docs/4.0/getting-started/webpack/#importing-precompiled-sass + compileOnly(devNpm("postcss-loader", "^6.2.1")) + compileOnly(devNpm("postcss", "^8.2.13")) + // See https://stackoverflow.com/a/72828500; newer versions are supported only for Bootstrap 5.2+ + compileOnly(devNpm("autoprefixer", "10.4.5")) + compileOnly(devNpm("webpack-bundle-analyzer", "^4.5.0")) + compileOnly(devNpm("mini-css-extract-plugin", "^2.6.0")) + compileOnly(devNpm("html-webpack-plugin", "^5.5.0")) + + // web-specific dependencies + implementation(npm("@fortawesome/fontawesome-svg-core", "^1.2.36")) + implementation(npm("@fortawesome/free-solid-svg-icons", "5.15.3")) + implementation(npm("@fortawesome/free-brands-svg-icons", "5.15.3")) + implementation(npm("@fortawesome/react-fontawesome", "^0.1.16")) + implementation(npm("devicon", "^2.15.1")) + implementation(npm("animate.css", "^4.1.1")) + implementation(npm("react-scroll-motion", "^0.3.0")) + implementation(npm("react-spinners", "0.13.0")) + implementation(npm("react-tsparticles", "1.42.1")) + implementation(npm("tsparticles", "2.1.3")) + implementation(npm("jquery", "3.6.0")) + // BS5: implementation(npm("@popperjs/core", "2.11.0")) + implementation(npm("popper.js", "1.16.1")) + // BS5: implementation(npm("bootstrap", "5.0.1")) + implementation(npm("react-calendar", "^3.8.0")) + implementation(npm("bootstrap", "^4.6.0")) + implementation(npm("react", "^18.0.0")) + implementation(npm("react-dom", "^18.0.0")) + implementation(npm("react-modal", "^3.0.0")) + implementation(npm("os-browserify", "^0.3.0")) + implementation(npm("path-browserify", "^1.0.1")) + implementation(npm("react-minimal-pie-chart", "^8.2.0")) + implementation(npm("lodash.debounce", "^4.0.8")) + implementation(npm("react-markdown", "^8.0.3")) + implementation(npm("rehype-highlight", "^5.0.2")) + implementation(npm("react-ace", "^10.1.0")) + implementation(npm("react-avatar-image-cropper", "^1.4.2")) + implementation(npm("react-circle", "^1.1.1")) + implementation(npm("react-diff-viewer-continued", "^3.2.6")) + implementation(npm("react-json-view", "^1.21.3")) + implementation(npm("multi-range-slider-react", "^2.0.5")) + // react-sigma + implementation(npm("@react-sigma/core", "^3.1.0")) + implementation(npm("sigma", "^2.4.0")) + implementation(npm("graphology", "^0.25.1")) + implementation(npm("graphology-layout", "^0.6.1")) + implementation(npm("graphology-layout-forceatlas2", "^0.10.1")) + implementation(npm("@react-sigma/layout-core", "^3.1.0")) + implementation(npm("@react-sigma/layout-random", "^3.1.0")) + implementation(npm("@react-sigma/layout-circular", "^3.1.0")) + implementation(npm("@react-sigma/layout-forceatlas2", "^3.1.0")) + implementation(npm("react-graph-viz-engine", "^0.1.0")) + implementation(npm("cytoscape", "^3.25.0")) + // translation + implementation(npm("i18next", "^23.4.5")) + implementation(npm("react-i18next", "^13.2.0")) + implementation(npm("i18next-http-backend", "^2.2.2")) + implementation(npm("js-cookie", "^3.0.5")) + // transitive dependencies with explicit version ranges required for security reasons + compileOnly(devNpm("minimist", "^1.2.6")) + compileOnly(devNpm("async", "^2.6.4")) + compileOnly(devNpm("follow-redirects", "^1.14.8")) + } + sourceSets["test"].dependencies { + implementation(kotlin("test-js")) + implementation(devNpm("jsdom", "^19.0.0")) + implementation(devNpm("global-jsdom", "^8.4.0")) + implementation(devNpm("@testing-library/react", "^13.2.0")) + implementation(devNpm("@testing-library/user-event", "^14.0.0")) + implementation(devNpm("karma-mocha-reporter", "^2.0.0")) + implementation(devNpm("istanbul-instrumenter-loader", "^3.0.1")) + implementation(devNpm("karma-coverage-istanbul-reporter", "^3.0.3")) + implementation(devNpm("msw", "^0.40.0")) + } + } +} + +rootProject.plugins.withType(NodeJsRootPlugin::class.java) { + rootProject.the().versions.apply { + // workaround for continuous work of WebPack: (https://github.com/webpack/webpack-cli/issues/2990) + webpackCli.version = "4.9.0" + webpackDevServer.version = "^4.9.0" + // override default version from KGP for security reasons + karma.version = "^6.3.14" + mocha.version = "^9.2.0" + } +} +// store yarn.lock in the root directory +rootProject.extensions.configure { + lockFileDirectory = rootProject.projectDir +} + +val mswScriptTargetPath = file("${rootProject.buildDir}/js/packages/${rootProject.name}-${project.name}-test/node_modules").absolutePath +val mswScriptTargetFile = "$mswScriptTargetPath/mockServiceWorker.js" +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +val installMwsScriptTaskProvider = tasks.register("installMswScript") { + dependsOn(":kotlinNodeJsSetup", ":kotlinNpmInstall", "packageJson") + inputs.dir(mswScriptTargetPath) + outputs.file(mswScriptTargetFile) + // cd to directory where the generated package.json is located. This is required for correct operation of npm/npx + workingDir("$rootDir/build/js") + + val isWindows = DefaultNativePlatform.getCurrentOperatingSystem().isWindows + val nodeJsEnv = NodeJsRootPlugin.apply(project.rootProject).requireConfigured() + val nodeDir = nodeJsEnv.nodeDir + val nodeBinDir = nodeJsEnv.nodeBinDir + listOf( + System.getenv("PATH"), + nodeBinDir.absolutePath, + ) + .filterNot { it.isNullOrEmpty() } + .joinToString(separator = File.pathSeparator) + .let { environment("PATH", it) } + + if (!isWindows) { + doFirst { + // workaround, because `npx` is a symlink but symlinks are lost when Gradle unpacks archive + exec { + commandLine("ln", "-sf", "$nodeDir/lib/node_modules/npm/bin/npx-cli.js", "$nodeBinDir/npx") + } + exec { + commandLine("ln", "-sf", "$nodeDir/lib/node_modules/npm/bin/npm-cli.js", "$nodeBinDir/npm") + } + exec { + commandLine("ln", "-sf", "$nodeDir/lib/node_modules/corepack/dist/corepack.js", "$nodeBinDir/corepack") + } + } + } + + commandLine( + nodeBinDir.resolve(if (isWindows) "npx.cmd" else "npx").canonicalPath, + "msw", + "init", + mswScriptTargetPath, + "--no-save", + ) +} +tasks.named("browserTest").configure { + dependsOn(installMwsScriptTaskProvider) + inputs.file(mswScriptTargetFile) +} + +tasks.withType { + // Since we inject timestamp into HTML file, we would like this task to always be re-run. + inputs.property("Build timestamp", System.currentTimeMillis()) + doFirst { + val additionalWebpackResources = fileTree("$buildDir/processedResources/js/main/") { + include("scss/**") + include("index.html") + } + copy { + from(additionalWebpackResources) + into("${rootProject.buildDir}/js/packages/${rootProject.name}-${project.name}") + } + } +} + +val distribution: Configuration by configurations.creating +val distributionJarTask by tasks.registering(Jar::class) { + dependsOn(":save-cosv-frontend:browserDistribution") + archiveClassifier.set("distribution") + from("$buildDir/$distributionsDirName") { + into("static") + exclude("scss") + } + from("$projectDir/nginx.conf") { + into("") + } +} +artifacts.add(distribution.name, distributionJarTask.get().archiveFile) { + builtBy(distributionJarTask) +} + +detekt { + config.setFrom(config.plus(file("detekt.yml"))) +} diff --git a/save-cosv-frontend/detekt.yml b/save-cosv-frontend/detekt.yml new file mode 100644 index 0000000000..665dca7995 --- /dev/null +++ b/save-cosv-frontend/detekt.yml @@ -0,0 +1,8 @@ +style: + MagicNumber: + # There are a lot of magic number in UI code, so it's easier to just suppress the rule + active: false +naming: + MatchingDeclarationName: + # Because common pattern is RProps class and functional component in a single file, which triggers this rule + active: false \ No newline at end of file diff --git a/save-cosv-frontend/nginx.conf b/save-cosv-frontend/nginx.conf new file mode 100644 index 0000000000..f09f1f5cdb --- /dev/null +++ b/save-cosv-frontend/nginx.conf @@ -0,0 +1,192 @@ +# Number of worker processes running in container +worker_processes 1; + +# Run NGINX in foreground (necessary for containerized NGINX) +daemon off; + +# Set the location of the server's error log +error_log stderr; + +events { + # Set number of simultaneous connections each worker process can serve + worker_connections 1024; +} + +http { + client_body_temp_path /tmp/client_body_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + + charset utf-8; + + # Map media types to file extensions + types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/atom+xml atom; + application/rss+xml rss; + font/ttf ttf; + font/woff woff; + font/woff2 woff2; + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + text/cache-manifest manifest; + image/png png; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + image/svg+xml svg svgz; + image/webp webp; + application/java-archive jar war ear; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.ms-excel xls; + application/vnd.ms-powerpoint ppt; + application/vnd.wap.wmlc wmlc; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/zip zip; + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream eot; + application/octet-stream iso img; + application/octet-stream msi msp msm; + application/json json; + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + video/3gpp 3gpp 3gp; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; + } + + access_log /dev/stdout; + + # Set the default MIME type of responses; 'application/octet-stream' + # represents an arbitrary byte stream + default_type application/octet-stream; + + # (Performance) When sending files, skip copying into buffer before sending. + sendfile on; + # (Only active with sendfile on) wait for packets to reach max size before + # sending. + tcp_nopush on; + + # (Performance) Enable compressing responses + gzip on; + # For all clients + gzip_static always; + # Including responses to proxied requests + gzip_proxied any; + # For responses above a certain length + gzip_min_length 1100; + # That are one of the following MIME types + gzip_types text/plain text/css text/js text/xml text/javascript application/javascript application/x-javascript application/json application/xml application/xml+rss; + # Compress responses to a medium degree + gzip_comp_level 6; + # Using 16 buffers of 8k bytes each + gzip_buffers 16 8k; + + # Add "Vary: Accept-Encoding� response header to compressed responses + gzip_vary on; + + # Decompress responses if client doesn't support compressed + gunzip on; + + # Don't compress responses if client is Internet Explorer 6 + gzip_disable "msie6"; + + # Set a timeout during which a keep-alive client connection will stay open on + # the server side + keepalive_timeout 30; + + # Ensure that redirects don't include the internal container PORT - <%= + # ENV["PORT"] %> + port_in_redirect off; + + # (Security) Disable emitting nginx version on error pages and in the + # “Server� response header field + server_tokens off; + + server { + listen {{port}} default_server; + server_name _; + + # Directory where static files are located + root /workspace/static; + + location ~* \.(html)$ { + add_header Cache-Control "private; no-store"; + etag off; + add_header Last-Modified ""; + if_modified_since off; + } + + location ~* \.(js|css|json)$ { + # 'no-cache' tells the browser to validate resources on the server before using it from cache + add_header Cache-Control "public; no-cache; must-revalidate"; + } + + location ~* \.(gif|jpg|png|ico|otf|sng|xls|doc|exe|jpeg|tgx)$ { + expires 1y; + add_header Cache-Control "public"; + } + + # (Security) Don't serve dotfiles, except .well-known/, which is needed by + # LetsEncrypt + location ~ /\.(?!well-known) { + deny all; + return 404; + } + + location / { + # Specify files sent to client if specific file not requested (e.g. + # GET www.example.com/). NGINX sends first existing file in the list. + index index.html; + try_files $uri $uri/ /index.html; + add_header Cache-Control "private; no-store"; + etag off; + add_header Last-Modified ""; + if_modified_since off; + } + } +} diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/App.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/App.kt new file mode 100644 index 0000000000..75bc006331 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/App.kt @@ -0,0 +1,95 @@ +/** + * Main entrypoint for SAVE frontend + */ + +package com.saveourtool.save.cosv.frontend + +import com.saveourtool.save.cosv.frontend.components.ErrorBoundary +import com.saveourtool.save.cosv.frontend.components.requestModalHandler +import com.saveourtool.save.cosv.frontend.components.topbar.topBarComponent +import com.saveourtool.save.cosv.frontend.routing.basicRouting +import com.saveourtool.save.frontend.common.components.* +import com.saveourtool.save.frontend.common.components.basic.cookieBanner +import com.saveourtool.save.frontend.common.components.basic.scrollToTopButton +import com.saveourtool.save.frontend.common.externals.i18next.initI18n +import com.saveourtool.save.frontend.common.externals.modal.ReactModal +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.validation.FrontendRoutes + +import react.* +import react.dom.client.createRoot +import react.dom.html.ReactHTML.div +import react.router.dom.BrowserRouter +import web.cssom.ClassName +import web.dom.document +import web.html.HTMLElement + +import kotlinx.browser.window +import kotlinx.coroutines.await +import kotlinx.serialization.json.Json + +/** + * Main component for the whole App + */ +@JsExport +@OptIn(ExperimentalJsExport::class) +@Suppress("VARIABLE_NAME_INCORRECT_FORMAT", "NULLABLE_PROPERTY_TYPE", "EMPTY_BLOCK_STRUCTURE_ERROR") +val App: FC = FC { + val (userInfo, setUserInfo) = useState(null) + useRequest { + get( + "$apiUrl/users/user-info", + jsonHeaders, + loadingHandler = ::loadingHandler, + ).run { + val responseText = text().await() + if (ok && responseText.isNotEmpty() && responseText != "null") { + val userInfoNew: UserInfo = Json.decodeFromString(responseText) + setUserInfo(userInfoNew) + } + } + } + BrowserRouter { + basename = "/" + requestModalHandler { + this.userInfo = userInfo + div { + className = ClassName("d-flex flex-column") + id = "content-wrapper" + ErrorBoundary::class.react { + topBarComponent { this.userInfo = userInfo } + div { + className = ClassName("container-fluid") + id = "common-save-container" + basicRouting { + this.userInfo = userInfo + this.userInfoSetter = setUserInfo + } + } + if (kotlinx.browser.window.location.pathname != "/${FrontendRoutes.COOKIE}") { + cookieBanner { } + } + footer { } + } + } + } + scrollToTopButton() + } +} + +fun main() { + /* Workaround for issue: https://youtrack.jetbrains.com/issue/KT-31888 */ + @Suppress("UnsafeCastFromDynamic") + if (window.asDynamic().__karma__) { + return + } + + kotlinext.js.require("../scss/save-cosv-frontend.scss") // this is needed for webpack to include resource + kotlinext.js.require("bootstrap") // this is needed for webpack to include bootstrap + ReactModal.setAppElement(document.getElementById("wrapper") as HTMLElement) // required for accessibility in react-modal + + initI18n() + val mainDiv = document.getElementById("wrapper") as HTMLElement + createRoot(mainDiv).render(App.create()) +} diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/ErrorBoundary.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/ErrorBoundary.kt new file mode 100644 index 0000000000..752aa5f359 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/ErrorBoundary.kt @@ -0,0 +1,73 @@ +/** + * Support for [error boundaries](https://reactjs.org/docs/error-boundaries.html) + */ + +package com.saveourtool.save.cosv.frontend.components + +import com.saveourtool.save.cosv.frontend.components.topbar.topBarComponent +import com.saveourtool.save.frontend.common.components.footer +import com.saveourtool.save.frontend.common.components.views.FallbackView + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.div +import web.cssom.ClassName + +/** + * State of error boundary component + */ +external interface ErrorBoundaryState : State { + /** + * The error message + */ + var errorMessage: String? + + /** + * True is there is an error in the wrapped component tree + */ + var hasError: Boolean? +} + +/** + * Component to act as React Error Boundary + */ +class ErrorBoundary : Component() { + init { + state = jso { + errorMessage = null + hasError = false + } + } + + override fun render(): ReactNode? = if (state.hasError == true) { + FC { + div { + className = ClassName("container-fluid") + topBarComponent() + FallbackView::class.react { + bigText = "Error" + smallText = "Something went wrong: ${state.errorMessage ?: "Unknown error"}" + } + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + footer { } + } + }.create() + } else { + props.children + } + + companion object : RStatics(ErrorBoundary::class) { + init { + /* + * From [React docs](https://reactjs.org/docs/error-boundaries.html): + * 'A class component becomes an error boundary if it defines either (or both) of the lifecycle methods static getDerivedStateFromError() or componentDidCatch()' + */ + getDerivedStateFromError = { ex -> + jso { + errorMessage = ex.message + hasError = true + } + } + } + } +} diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/RequestModalHandler.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/RequestModalHandler.kt new file mode 100644 index 0000000000..a6702fabd8 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/RequestModalHandler.kt @@ -0,0 +1,157 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.cosv.frontend.components + +import com.saveourtool.save.cosv.frontend.components.topbar.topBarComponent +import com.saveourtool.save.frontend.common.components.* +import com.saveourtool.save.frontend.common.components.modal.loaderModalStyle +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.components.views.FallbackView +import com.saveourtool.save.frontend.common.utils.UserInfoAwarePropsWithChildren + +import org.w3c.fetch.Response +import react.* +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.span +import react.router.useNavigate +import web.cssom.ClassName +import web.html.ButtonType + +import kotlinx.browser.window + +/** + * Component that displays generic warning about unsuccessful request based on info in [requestStatusContext]. + * Also renders its `children`. + */ +@Suppress("TOO_MANY_LINES_IN_LAMBDA", "MAGIC_NUMBER") +val requestModalHandler: FC = FC { props -> + val (response, setResponse) = useState(null) + val (loadingCounter, setLoadingCounter) = useState(0) + val (redirectToFallbackView, setRedirectToFallbackView) = useState(false) + val statusContext = RequestStatusContext(setResponse, setRedirectToFallbackView, setLoadingCounter) + val (modalState, setModalState) = useState( + ErrorModalState( + isErrorModalOpen = false, + errorMessage = "", + errorLabel = "", + status = null, + ) + ) + val (loadingState, setLoadingState) = useState( + LoadingModalState( + false, + ) + ) + + val navigate = useNavigate() + + useEffect(response) { + val newModalState = when (response?.status) { + 401.toShort() -> ErrorModalState( + isErrorModalOpen = true, + errorMessage = "You are not logged in", + errorLabel = "Unauthenticated", + confirmationText = "Proceed to login page", + status = response.status, + ) + 404.toShort() -> ErrorModalState( + isErrorModalOpen = !redirectToFallbackView, + errorMessage = "${response.status} ${response.statusText}", + errorLabel = response.status.toString(), + status = response.status, + redirectToFallbackView = redirectToFallbackView, + ) + else -> ErrorModalState( + isErrorModalOpen = response != null, + errorMessage = "${response?.status} ${response?.statusText}", + errorLabel = response?.status.toString(), + status = response?.status, + ) + } + setModalState(newModalState) + } + + modal { modalProps -> + modalProps.isOpen = modalState.isErrorModalOpen + modalProps.contentLabel = modalState.errorLabel + div { + className = ClassName("row align-items-center justify-content-center") + h2 { + className = ClassName("h6 text-gray-800") + +modalState.errorMessage + } + } + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mt-4") + button { + className = ClassName("btn btn-outline-primary") + type = ButtonType.button + onClick = { + if (response?.status == 401.toShort()) { + // if 401 - change current URL to the main page (with login screen) + navigate(to = "/") + window.location.reload() + } + setResponse(null) + setModalState(modalState.copy(isErrorModalOpen = false)) + } + +modalState.confirmationText + } + } + } + + useEffect(loadingCounter) { + if (loadingCounter != 0) { + setLoadingState(LoadingModalState(true)) + } else { + setLoadingState(LoadingModalState(false)) + } + } + + modal(loaderModalStyle) { modalProps -> + modalProps.isOpen = loadingState.isLoadingModalOpen + div { + className = ClassName("d-flex justify-content-center mt-4") + div { + +ringLoader + span { + className = ClassName("sr-only") + +"Loading..." + } + } + } + } + + val contextPayload = useMemo( + arrayOf(statusContext) + ) { statusContext } + + val reactNode = if (modalState.redirectToFallbackView) { + div.create { + className = ClassName("d-flex flex-column") + id = "content-wrapper" + topBarComponent { + userInfo = props.userInfo + } + div { + className = ClassName("container-fluid") + FallbackView::class.react { + bigText = "${response?.status}" + smallText = "Page not found" + withRouterLink = false + } + } + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + (footer { }) + } + } else { + props.children + } + + requestStatusContext.Provider { + value = contextPayload + +reactNode + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/VulnerabilityIntroductionComponent.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/basic/VulnerabilityIntroductionComponent.kt similarity index 84% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/VulnerabilityIntroductionComponent.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/basic/VulnerabilityIntroductionComponent.kt index 614e14b6b8..f8bd8ff1dd 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/VulnerabilityIntroductionComponent.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/basic/VulnerabilityIntroductionComponent.kt @@ -1,9 +1,11 @@ @file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.basic +package com.saveourtool.save.cosv.frontend.components.basic + +import com.saveourtool.save.cosv.frontend.components.views.vuln.columnHeight +import com.saveourtool.save.frontend.common.components.basic.markdown +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation -import com.saveourtool.save.frontend.components.views.vuln.columnHeight -import com.saveourtool.save.frontend.externals.i18next.useTranslation import js.core.jso import react.FC import react.Props diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBar.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBar.kt new file mode 100644 index 0000000000..2c7f0cab60 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBar.kt @@ -0,0 +1,80 @@ +/** + * Top bar of web page + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS", "FILE_WILDCARD_IMPORTS", "WildcardImport") + +package com.saveourtool.save.cosv.frontend.components.topbar + +import com.saveourtool.save.frontend.common.components.basic.languageSelector +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.fontawesome.FontAwesomeIconModule +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.UserInfoAwarePropsWithChildren +import com.saveourtool.save.frontend.common.utils.notIn +import com.saveourtool.save.validation.FrontendRoutes + +import js.core.jso +import react.* +import react.dom.html.ButtonHTMLAttributes +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.nav +import react.router.useLocation +import web.cssom.ClassName +import web.cssom.vw +import web.html.ButtonType +import web.html.HTMLButtonElement + +/** + * A component for web page top bar. + */ +val topBarComponent: FC = FC { props -> + val location = useLocation() + nav { + className = + ClassName("navbar navbar-expand navbar-dark bg-dark topbar mb-3 static-top shadow mr-1 ml-1 rounded") + style = jso { + @Suppress("MAGIC_NUMBER") + width = 100.vw + } + id = "navigation-top-bar" + topBarUrlSplits { + this.location = location + } + if (location.notIn(FrontendRoutes.noTopBarViewList)) { + topBarLinks { this.location = location } + } + + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + languageSelector { } + + topBarUserField { + userInfo = props.userInfo + } + } +} + +/** + * @param faIcon + * @param text + * @param isSelected + * @param handler + * @return button + */ +fun ChildrenBuilder.dropdownEntry( + faIcon: FontAwesomeIconModule?, + text: String, + isSelected: Boolean = false, + handler: ChildrenBuilder.(ButtonHTMLAttributes) -> Unit = { }, +) = button { + type = ButtonType.button + val active = if (isSelected) "active" else "" + className = ClassName("btn btn-no-outline dropdown-item rounded-0 shadow-none $active") + faIcon?.let { + fontAwesomeIcon(icon = faIcon) { + it.className = "fas fa-sm fa-fw mr-2 text-gray-400" + } + } + +text + handler(this) +} diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarLinks.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarLinks.kt new file mode 100644 index 0000000000..f5480c979c --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarLinks.kt @@ -0,0 +1,102 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.cosv.frontend.components.topbar + +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.validation.FrontendRoutes + +import react.* +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.ul +import react.router.dom.Link +import remix.run.router.Location +import web.cssom.ClassName + +/** + * If [Location.pathname] has more slashes then [TOP_BAR_PATH_SEGMENTS_HIGHLIGHT], + * there is no need to highlight topbar element as we have `/demo` and `/project/.../demo` + */ +private const val TOP_BAR_PATH_SEGMENTS_HIGHLIGHT = 4 + +/** + * Displays the static links that do not depend on the url. + */ +@Suppress("LongMethod", "TOO_LONG_FUNCTION") +val topBarLinks: FC = FC { props -> + val (t) = useTranslation("topbar") + + val vulnTopbarLinks = sequenceOf( + TopBarLink(hrefAnchor = FrontendRoutes.INDEX.path, text = "Main page".t()), + TopBarLink(hrefAnchor = FrontendRoutes.VULN_CREATE.path, text = "Propose vulnerability".t()), + TopBarLink(hrefAnchor = FrontendRoutes.VULNERABILITIES.path, text = "Vulnerabilities list".t()), + TopBarLink(hrefAnchor = FrontendRoutes.VULN_TOP_RATING.path, text = "Top Rating".t()), + ) + + ul { + className = ClassName("navbar-nav mx-auto") + vulnTopbarLinks + .forEach { elem -> + li { + className = ClassName("nav-item") + if (elem.isExternalLink) { + a { + className = ClassName("nav-link d-flex align-items-center text-light me-2 active") + href = elem.hrefAnchor + +elem.text + } + } else { + Link { + className = ClassName( + "nav-link d-flex align-items-center me-2 ${ + textColor( + elem.hrefAnchor, + props.location + ) + } active mx-2 text-nowrap col-auto" + ) + to = elem.hrefAnchor + +elem.text + } + } + } + } + } +} + +/** + * [Props] of the top bar links component + */ +external interface TopBarLinksProps : Props { + /** + * The location is needed to change the color of the text. + */ + var location: Location<*> +} + +/** + * @property hrefAnchor the link + * @property text the link text + * @property isExternalLink + */ +data class TopBarLink( + val hrefAnchor: String, + val text: String, + val isExternalLink: Boolean = false, +) + +private fun textColor( + hrefAnchor: String, + location: Location<*>, +): String { + val isMainPage = (location.pathname.count { it == '/' } == 1) && hrefAnchor.isBlank() + val isNeedToHighlightTopBar = (hrefAnchor.isNotBlank() && + location.pathname.endsWith(hrefAnchor) && location.pathname.count { it == '/' } < TOP_BAR_PATH_SEGMENTS_HIGHLIGHT) || + isMainPage + + return if (isNeedToHighlightTopBar) { + "text-warning" + } else { + "text-light" + } +} diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarUrlSplits.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarUrlSplits.kt new file mode 100644 index 0000000000..5f9e3fcfe1 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarUrlSplits.kt @@ -0,0 +1,88 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.cosv.frontend.components.topbar + +import com.saveourtool.save.frontend.common.utils.TopBarUrl +import com.saveourtool.save.utils.URL_PATH_DELIMITER +import com.saveourtool.save.validation.FrontendRoutes + +import react.FC +import react.Props +import react.dom.aria.AriaCurrent +import react.dom.aria.ariaCurrent +import react.dom.aria.ariaLabel +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.nav +import react.dom.html.ReactHTML.ol +import react.router.dom.Link +import remix.run.router.Location +import web.cssom.ClassName + +/** + * Displays the URL split with "/". + */ +val topBarUrlSplits: FC = FC { props -> + nav { + className = ClassName("navbar-nav mr-auto w-100") + ariaLabel = "breadcrumb" + ol { + className = ClassName("breadcrumb mb-0") + li { + className = ClassName("breadcrumb-item") + ariaCurrent = "page".unsafeCast() + Link { + to = "/" + // if we are on welcome page right now - need to highlight SAVE in menu + val textColor = if (props.location.pathname == "/") "text-warning" else "text-light" + className = ClassName(textColor) + +"COSV" + } + } + props.location.pathname + // workaround for avoiding invalid routing to /vuln/list/:param from /vuln/collection/vulnName + .replace("${FrontendRoutes.VULNERABILITY_SINGLE}", "${FrontendRoutes.VULNERABILITIES}") + .substringBeforeLast("?") + .split(URL_PATH_DELIMITER) + .filterNot { it.isBlank() } + .apply { + val url = TopBarUrl( + props.location.pathname.substringBeforeLast("?") + ) + forEachIndexed { index: Int, pathPart: String -> + url.changeUrlBeforeButton(pathPart) + if (url.shouldDisplayPathFragment(index)) { + li { + className = ClassName("breadcrumb-item") + ariaCurrent = "page".unsafeCast() + if (index == size - 1) { + a { + className = ClassName("text-warning") + +pathPart + } + } else { + Link { + // removePrefix - to remove the # at the beginning and not rewrite the logic with the construction of the url + to = url.currentPath.removePrefix("#") + className = ClassName("text-light") + +pathPart + } + } + } + } + url.changeUrlAfterButton(pathPart) + } + } + } + } +} + +/** + * [Props] of the top bar url splits component + */ +external interface TopBarUrlSplitsProps : Props { + /** + * User location for url analysis. + */ + var location: Location<*> +} diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarUserField.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarUserField.kt new file mode 100644 index 0000000000..24b9532c7d --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/topbar/TopBarUserField.kt @@ -0,0 +1,145 @@ +/** + * FC user's topbar + */ + +package com.saveourtool.save.cosv.frontend.components.topbar + +import com.saveourtool.save.frontend.common.components.basic.avatarRenderer +import com.saveourtool.save.frontend.common.components.modal.logoutModal +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.AVATAR_PROFILE_PLACEHOLDER +import com.saveourtool.save.frontend.common.utils.UserInfoAwareProps +import com.saveourtool.save.frontend.common.utils.isSuperAdmin +import com.saveourtool.save.validation.FrontendRoutes + +import js.core.jso +import react.* +import react.dom.aria.* +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.small +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.ul +import react.router.useNavigate +import web.cssom.ClassName +import web.cssom.rem + +@Suppress("MAGIC_NUMBER") +val logoSize: CSSProperties = jso { + height = 2.5.rem + width = 2.5.rem +} + +/** + * Displays the user's field. + */ +@Suppress( + "MAGIC_NUMBER", + "LongMethod", + "TOO_LONG_FUNCTION", + "LOCAL_VARIABLE_EARLY_DECLARATION" +) +val topBarUserField: FC = FC { props -> + val (t) = useTranslation("topbar") + val navigate = useNavigate() + var isLogoutModalOpen by useState(false) + var isAriaExpanded by useState(false) + + ul { + className = ClassName("navbar-nav ml-auto") + div { + className = ClassName("topbar-divider d-none d-sm-block") + } + // Nav Item - User Information + li { + className = ClassName("nav-item dropdown no-arrow") + onClickCapture = { isAriaExpanded = !isAriaExpanded } + a { + href = "#" + className = ClassName("nav-link dropdown-toggle") + id = "userDropdown" + role = "button".unsafeCast() + ariaExpanded = isAriaExpanded + ariaHasPopup = true.unsafeCast() + asDynamic()["data-toggle"] = "dropdown" + + div { + className = ClassName("d-flex flex-row") + div { + className = ClassName("d-flex flex-column") + span { + className = ClassName("mr-2 text-white-400") + +(props.userInfo?.name.orEmpty()) + } + small { + className = ClassName("text-gray-400 text-justify") + props.userInfo?.let { + if (props.userInfo.isSuperAdmin()) { + +"Super user".t() + } else { + +"User settings".t() + } + } + } + } + props.userInfo?.let { userInfo -> + img { + className = + ClassName("ml-2 align-self-center avatar avatar-user width-full border color-bg-default rounded-circle fas mr-2") + src = props.userInfo?.avatar?.avatarRenderer() ?: AVATAR_PROFILE_PLACEHOLDER + style = logoSize + } + } ?: fontAwesomeIcon(icon = faUser) { + it.className = "m-2 align-self-center fas fa-lg fa-fw mr-2 text-gray-400" + } + } + } + // Dropdown - User Information + div { + className = ClassName("dropdown-menu dropdown-menu-right shadow animated--grow-in${if (isAriaExpanded) " show" else ""}") + ariaLabelledBy = "userDropdown" + props.userInfo?.name?.let { name -> + dropdownEntry(faUser, "Profile".t()) { attrs -> + attrs.onClick = { + navigate(to = "/${FrontendRoutes.VULN_PROFILE}/$name") + } + } + dropdownEntry(faCog, "Settings".t()) { attrs -> + attrs.onClick = { + navigate(to = "/${FrontendRoutes.SETTINGS_PROFILE}") + } + } + dropdownEntry( + faCity, + "Manage organizations".t() + ) { attrs -> + attrs.onClick = { + navigate(to = "/${FrontendRoutes.SETTINGS_ORGANIZATIONS}") + } + } + dropdownEntry(faSignOutAlt, "Log out".t()) { attrs -> + attrs.onClick = { + isLogoutModalOpen = true + } + } + } ?: run { + dropdownEntry(faSignInAlt, "Log in".t()) { attrs -> + attrs.onClick = { + navigate(to = "/") + } + } + } + } + } + } + + logoutModal { + isLogoutModalOpen = false + }() { + isOpen = isLogoutModalOpen + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/CosvSchemaView.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/CosvSchemaView.kt similarity index 90% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/CosvSchemaView.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/CosvSchemaView.kt index 8dda804953..fa1236b8a2 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/CosvSchemaView.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/CosvSchemaView.kt @@ -2,14 +2,18 @@ * View, which represents the COSV schema */ -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln -import com.saveourtool.save.frontend.components.views.vuln.utils.COSV_SCHEMA_JSON -import com.saveourtool.save.frontend.components.views.vuln.utils.cosvFieldsDescriptionsMap -import com.saveourtool.save.frontend.components.views.vuln.utils.keysOnlyFromCosv -import com.saveourtool.save.frontend.utils.* -import js.core.jso +import com.saveourtool.save.cosv.frontend.components.views.vuln.utils.COSV_SCHEMA_JSON +import com.saveourtool.save.cosv.frontend.components.views.vuln.utils.cosvFieldsDescriptionsMap +import com.saveourtool.save.cosv.frontend.components.views.vuln.utils.keysOnlyFromCosv +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.Style +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.particles +import com.saveourtool.save.frontend.common.utils.useBackground +import js.core.jso import react.FC import react.Props import react.dom.html.ReactHTML.a diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/CreateVulnerabilityView.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/CreateVulnerabilityView.kt similarity index 95% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/CreateVulnerabilityView.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/CreateVulnerabilityView.kt index 6c777c0352..027bad8c48 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/CreateVulnerabilityView.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/CreateVulnerabilityView.kt @@ -1,26 +1,27 @@ @file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln +import com.saveourtool.save.cosv.frontend.components.views.vuln.component.cvssBaseScoreCalculator import com.saveourtool.save.cvsscalculator.CvssVersion import com.saveourtool.save.cvsscalculator.calculateBaseScore import com.saveourtool.save.cvsscalculator.parsingVectorToMap import com.saveourtool.save.cvsscalculator.v3.CvssVectorV3 import com.saveourtool.save.entities.OrganizationDto -import com.saveourtool.save.frontend.components.basic.addUserComponent -import com.saveourtool.save.frontend.components.basic.markdown -import com.saveourtool.save.frontend.components.basic.renderAvatar -import com.saveourtool.save.frontend.components.inputform.InputTypes -import com.saveourtool.save.frontend.components.inputform.inputTextFormOptional -import com.saveourtool.save.frontend.components.modal.MAX_Z_INDEX -import com.saveourtool.save.frontend.components.modal.calculatorModalStyle -import com.saveourtool.save.frontend.components.modal.displayModal -import com.saveourtool.save.frontend.components.views.vuln.component.cvssBaseScoreCalculator -import com.saveourtool.save.frontend.components.views.vuln.component.uploadCosvButton -import com.saveourtool.save.frontend.externals.fontawesome.* -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.themes.Colors -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.basic.addUserComponent +import com.saveourtool.save.frontend.common.components.basic.markdown +import com.saveourtool.save.frontend.common.components.basic.renderAvatar +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.inputform.inputTextFormOptional +import com.saveourtool.save.frontend.common.components.modal.MAX_Z_INDEX +import com.saveourtool.save.frontend.common.components.modal.calculatorModalStyle +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.views.vuln.uploadCosvButton +import com.saveourtool.save.frontend.common.externals.fontawesome.faPlus +import com.saveourtool.save.frontend.common.externals.fontawesome.faTimesCircle +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.themes.Colors +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.utils.* import com.saveourtool.save.validation.FrontendRoutes import com.saveourtool.save.validation.isValidUrl diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/UploadVulnerabilityView.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/UploadVulnerabilityView.kt similarity index 76% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/UploadVulnerabilityView.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/UploadVulnerabilityView.kt index 878d7e9604..45851c2cff 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/UploadVulnerabilityView.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/UploadVulnerabilityView.kt @@ -1,13 +1,13 @@ @file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln -import com.saveourtool.save.frontend.components.basic.fileuploader.cosvFileManagerComponent -import com.saveourtool.save.frontend.components.modal.MAX_Z_INDEX -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.Style -import com.saveourtool.save.frontend.utils.particles -import com.saveourtool.save.frontend.utils.useBackground +import com.saveourtool.save.cosv.frontend.components.views.vuln.component.cosvFileManagerComponent +import com.saveourtool.save.frontend.common.components.modal.MAX_Z_INDEX +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.Style +import com.saveourtool.save.frontend.common.utils.particles +import com.saveourtool.save.frontend.common.utils.useBackground import js.core.jso import react.FC import react.Props diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityBadge.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityBadge.kt similarity index 91% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityBadge.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityBadge.kt index 1c9c2e1f5c..cae28cb4a2 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityBadge.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityBadge.kt @@ -1,19 +1,19 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln +import com.saveourtool.save.cosv.frontend.components.views.vuln.component.cvssBaseScoreCalculator import com.saveourtool.save.cvsscalculator.CvssVersion import com.saveourtool.save.cvsscalculator.parsingVectorToMap import com.saveourtool.save.cvsscalculator.v3.CvssVectorV3 import com.saveourtool.save.entities.cosv.VulnerabilityExt -import com.saveourtool.save.frontend.components.modal.calculatorModalStyle -import com.saveourtool.save.frontend.components.modal.displayModal -import com.saveourtool.save.frontend.components.views.vuln.component.cvssBaseScoreCalculator -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.externals.progressbar.progressBar -import com.saveourtool.save.frontend.themes.Colors -import com.saveourtool.save.frontend.utils.buttonBuilder -import com.saveourtool.save.frontend.utils.useWindowOpenness +import com.saveourtool.save.frontend.common.components.modal.calculatorModalStyle +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.externals.progressbar.progressBar +import com.saveourtool.save.frontend.common.themes.Colors +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.useWindowOpenness import com.saveourtool.osv4k.Severity import com.saveourtool.osv4k.SeverityType diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityChangesTab.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityChangesTab.kt similarity index 96% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityChangesTab.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityChangesTab.kt index 849f40ff91..0dd189460a 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityChangesTab.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityChangesTab.kt @@ -1,10 +1,10 @@ @file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.cosv.CosvFileDto -import com.saveourtool.save.frontend.externals.diffviewer.reactDiffViewer -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.externals.diffviewer.reactDiffViewer +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.utils.toUnixCalendarFormat import com.saveourtool.osv4k.RawOsvSchema diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityCollectionView.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityCollectionView.kt similarity index 77% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityCollectionView.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityCollectionView.kt index a88695d6a9..e8fac11c5b 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityCollectionView.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityCollectionView.kt @@ -4,11 +4,16 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln +import com.saveourtool.save.cosv.frontend.components.basic.vulnerabilityIntroductionComponent import com.saveourtool.save.filters.VulnerabilityFilter -import com.saveourtool.save.frontend.components.basic.* -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.basic.* +import com.saveourtool.save.frontend.common.components.views.vuln.vulnerabilityTableComponent +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.Style +import com.saveourtool.save.frontend.common.utils.particles +import com.saveourtool.save.frontend.common.utils.useBackground import com.saveourtool.save.info.UserInfo import js.core.jso diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityCommentTab.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityCommentTab.kt similarity index 92% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityCommentTab.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityCommentTab.kt index 00d806c6dd..8d08a6145f 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityCommentTab.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityCommentTab.kt @@ -1,13 +1,13 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.CommentDto import com.saveourtool.save.entities.cosv.VulnerabilityExt -import com.saveourtool.save.frontend.components.basic.commentWindow -import com.saveourtool.save.frontend.components.basic.newCommentWindow -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.basic.commentWindow +import com.saveourtool.save.frontend.common.components.basic.newCommentWindow +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.info.UserInfo import js.core.jso diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityDateModal.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityDateModal.kt similarity index 90% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityDateModal.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityDateModal.kt index 9c2c478cba..2d6614c4a8 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityDateModal.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityDateModal.kt @@ -1,15 +1,18 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.cosv.VulnerabilityExt import com.saveourtool.save.entities.vulnerability.VulnerabilityDateDto import com.saveourtool.save.entities.vulnerability.VulnerabilityDateType -import com.saveourtool.save.frontend.components.inputform.* -import com.saveourtool.save.frontend.components.inputform.inputTextDisabled -import com.saveourtool.save.frontend.components.modal.modal -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.inputform.* +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.WindowOpenness +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.dateStringToLocalDateTime +import com.saveourtool.save.frontend.common.utils.selectorBuilder import com.saveourtool.save.utils.getTimeline import react.FC diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityGeneralInfoProps.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityGeneralInfoProps.kt similarity index 96% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityGeneralInfoProps.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityGeneralInfoProps.kt index 21e80b082d..9404164939 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityGeneralInfoProps.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityGeneralInfoProps.kt @@ -1,16 +1,19 @@ -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.cosv.VulnerabilityExt import com.saveourtool.save.entities.cosv.VulnerabilityMetadataDto.Companion.SUMMARY_LENGTH import com.saveourtool.save.entities.vulnerability.VulnerabilityStatus -import com.saveourtool.save.frontend.components.basic.renderAvatar -import com.saveourtool.save.frontend.components.basic.renderUserAvatarWithName -import com.saveourtool.save.frontend.components.basic.userBoard -import com.saveourtool.save.frontend.components.inputform.InputTypes -import com.saveourtool.save.frontend.components.inputform.inputTextFormRequired -import com.saveourtool.save.frontend.externals.fontawesome.* -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.basic.renderAvatar +import com.saveourtool.save.frontend.common.components.basic.renderUserAvatarWithName +import com.saveourtool.save.frontend.common.components.basic.userBoard +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.inputform.inputTextFormRequired +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.isSuperAdmin +import com.saveourtool.save.frontend.common.utils.selectorBuilder import com.saveourtool.save.info.UserInfo import com.saveourtool.save.utils.NO_BREAK_SPACE import com.saveourtool.save.utils.PRETTY_DELIMITER diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityHeader.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityHeader.kt similarity index 94% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityHeader.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityHeader.kt index ad0b64fa5a..785f90b012 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityHeader.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityHeader.kt @@ -4,21 +4,20 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.CommentDto import com.saveourtool.save.entities.cosv.VulnerabilityExt import com.saveourtool.save.entities.vulnerability.VulnerabilityStatus -import com.saveourtool.save.frontend.components.inputform.InputTypes -import com.saveourtool.save.frontend.components.modal.displayModal -import com.saveourtool.save.frontend.components.modal.mediumTransparentModalStyle -import com.saveourtool.save.frontend.components.views.contests.tab -import com.saveourtool.save.frontend.externals.fontawesome.faDownload -import com.saveourtool.save.frontend.externals.fontawesome.faImage -import com.saveourtool.save.frontend.externals.fontawesome.faTable -import com.saveourtool.save.frontend.externals.fontawesome.faTrash -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +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.faDownload +import com.saveourtool.save.frontend.common.externals.fontawesome.faImage +import com.saveourtool.save.frontend.common.externals.fontawesome.faTable +import com.saveourtool.save.frontend.common.externals.fontawesome.faTrash +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.info.UserInfo import com.saveourtool.save.validation.FrontendRoutes diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityHistoryTab.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityHistoryTab.kt similarity index 83% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityHistoryTab.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityHistoryTab.kt index e5557a8643..463206aee4 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityHistoryTab.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityHistoryTab.kt @@ -1,12 +1,15 @@ @file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.cosv.CosvFileDto -import com.saveourtool.save.frontend.components.tables.* -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.tables.* +import com.saveourtool.save.frontend.common.components.tables.TABLE_HEADERS_LOCALE_NAMESPACE +import com.saveourtool.save.frontend.common.components.tables.tableComponent +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.utils.toUnixCalendarFormat + import react.* import react.dom.html.ReactHTML.td import web.cssom.ClassName diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityInfoTab.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityInfoTab.kt similarity index 93% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityInfoTab.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityInfoTab.kt index c24d131035..bce2235e78 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityInfoTab.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityInfoTab.kt @@ -4,24 +4,26 @@ "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", ) -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln +import com.saveourtool.save.cosv.frontend.components.views.vuln.component.timelineComponent import com.saveourtool.save.entities.cosv.VulnerabilityExt import com.saveourtool.save.entities.vulnerability.VulnerabilityDateDto import com.saveourtool.save.entities.vulnerability.VulnerabilityDateType import com.saveourtool.save.entities.vulnerability.VulnerabilityProjectType -import com.saveourtool.save.frontend.components.basic.carousel -import com.saveourtool.save.frontend.components.basic.timelineComponent -import com.saveourtool.save.frontend.components.modal.displayModal -import com.saveourtool.save.frontend.components.modal.mediumTransparentModalStyle -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.externals.i18next.TranslationFunction -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.basic.carousel +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.modal.mediumTransparentModalStyle +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.faTrashAlt +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.externals.i18next.TranslationFunction +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.info.UserInfo import com.saveourtool.save.utils.ELLIPSIS import com.saveourtool.save.utils.asTimelineEntry @@ -436,7 +438,8 @@ private fun ChildrenBuilder.renderProjects( private fun ChildrenBuilder.renderProjectCard( project: CosvAffected, projectType: VulnerabilityProjectType, - t: TranslationFunction) { + t: TranslationFunction +) { div { className = ClassName("card card-body") h4 { diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityProjectWindow.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityProjectWindow.kt similarity index 94% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityProjectWindow.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityProjectWindow.kt index 209f79bffa..440ad801d7 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityProjectWindow.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityProjectWindow.kt @@ -1,15 +1,17 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.vulnerability.VulnerabilityLanguage import com.saveourtool.save.entities.vulnerability.VulnerabilityProjectType -import com.saveourtool.save.frontend.components.inputform.InputTypes -import com.saveourtool.save.frontend.components.inputform.inputTextFormOptional -import com.saveourtool.save.frontend.components.inputform.inputTextFormRequired -import com.saveourtool.save.frontend.components.modal.modal -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +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.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.WindowOpenness +import com.saveourtool.save.frontend.common.utils.selectorBuilder import com.saveourtool.save.utils.PRETTY_DELIMITER import com.saveourtool.save.validation.isValidUrl diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityRawDataTab.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityRawDataTab.kt similarity index 83% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityRawDataTab.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityRawDataTab.kt index 375a7fd1c0..bf906716ff 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityRawDataTab.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityRawDataTab.kt @@ -1,9 +1,9 @@ @file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.cosv.VulnerabilityExt -import com.saveourtool.save.frontend.externals.jsonview.reactJson +import com.saveourtool.save.frontend.common.externals.jsonview.reactJson import react.FC import react.Props diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityTagsComponent.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityTagsComponent.kt similarity index 90% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityTagsComponent.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityTagsComponent.kt index 01309740ad..096f1dfda9 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityTagsComponent.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityTagsComponent.kt @@ -1,11 +1,13 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.cosv.VulnerabilityExt -import com.saveourtool.save.frontend.externals.fontawesome.faPlus -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.externals.fontawesome.faPlus +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.useTooltip import com.saveourtool.save.info.UserInfo import com.saveourtool.save.validation.FrontendRoutes import com.saveourtool.save.validation.isValidTag diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityView.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityView.kt similarity index 98% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityView.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityView.kt index 4748d79eb3..b7e6e40a22 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityView.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/VulnerabilityView.kt @@ -1,14 +1,14 @@ /** - * View for FossGraph + * View for COSV */ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.cosv.frontend.components.views.vuln import com.saveourtool.save.entities.cosv.VulnerabilityExt import com.saveourtool.save.frontend.TabMenuBar -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.info.UserInfo import com.saveourtool.save.validation.FrontendRoutes diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/CosvFileManagerComponent.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/CosvFileManagerComponent.kt new file mode 100644 index 0000000000..c90e4a64fd --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/CosvFileManagerComponent.kt @@ -0,0 +1,463 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.cosv.frontend.components.views.vuln.component + +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.entities.cosv.RawCosvFileDto +import com.saveourtool.save.entities.cosv.RawCosvFileDto.Companion.isDuplicate +import com.saveourtool.save.entities.cosv.RawCosvFileDto.Companion.isHasErrors +import com.saveourtool.save.entities.cosv.RawCosvFileDto.Companion.isPendingRemoved +import com.saveourtool.save.entities.cosv.RawCosvFileDto.Companion.isProcessing +import com.saveourtool.save.entities.cosv.RawCosvFileDto.Companion.isUploadedJsonFile +import com.saveourtool.save.entities.cosv.RawCosvFileDto.Companion.isZipArchive +import com.saveourtool.save.entities.cosv.RawCosvFileStatisticsDto +import com.saveourtool.save.entities.cosv.RawCosvFileStreamingResponse +import com.saveourtool.save.frontend.common.components.basic.fileuploader.defaultProgressBarComponent +import com.saveourtool.save.frontend.common.components.basic.fileuploader.deleteFileButton +import com.saveourtool.save.frontend.common.components.basic.fileuploader.downloadFileButton +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.dragAndDropForm +import com.saveourtool.save.frontend.common.externals.fontawesome.faBoxOpen +import com.saveourtool.save.frontend.common.externals.fontawesome.faReload +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.utils.FILE_PART_NAME +import com.saveourtool.save.utils.toKilobytes +import com.saveourtool.save.validation.isValidName + +import js.core.asList +import js.core.jso +import org.w3c.fetch.Headers +import react.FC +import react.Props +import react.dom.html.ReactHTML.b +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.ul +import react.useState +import web.cssom.ClassName +import web.file.File +import web.html.ButtonType +import web.http.FormData + +import kotlinx.browser.window +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onCompletion +import kotlinx.serialization.json.Json + +private const val DEFAULT_SIZE = 10 + +val cosvFileManagerComponent: FC = FC { + useTooltip() + val (t) = useTranslation("vulnerability-upload") + + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + val organizationSelectForm = selectFormRequired() + + val (statistics, setStatistics) = useState(RawCosvFileStatisticsDto.empty) + val (lastPage, setLastPage) = useState(0) + val (availableFiles, setAvailableFiles) = useState>(emptyList()) + val (filesForUploading, setFilesForUploading) = useState>(emptyList()) + + val leftAvailableFilesCount = statistics.allAvailableFilesCount - lastPage * DEFAULT_SIZE + + val (userOrganizations, setUserOrganizations) = useState(emptyList()) + val (selectedOrganization, setSelectedOrganization) = useState() + + val (fileToDelete, setFileToDelete) = useState() + val (fileToUnzip, setFileToUnzip) = useState() + + val (currentProgress, setCurrentProgress) = useState(-1) + val (currentProgressMessage, setCurrentProgressMessage) = useState("") + val resetCurrentProgress = { + setCurrentProgress(-1) + setCurrentProgressMessage("") + } + + val (isStreamingOperationActive, setStreamingOperationActive) = useState(false) + + val deleteFile = useDeferredRequest { + fileToDelete?.let { file -> + val response = delete( + "$apiUrl/raw-cosv/$selectedOrganization/delete/${file.requiredId()}", + headers = Headers().withAcceptJson(), + loadingHandler = ::noopLoadingHandler, + ) + + if (response.ok) { + setAvailableFiles { it.minus(file) } + setStatistics { it.copy(allAvailableFilesCount = statistics.allAvailableFilesCount.dec()) } + when { + file.isZipArchive() -> setStatistics { it.copy(uploadedArchivesCount = statistics.uploadedArchivesCount.dec()) } + file.isUploadedJsonFile() -> setStatistics { it.copy(uploadedJsonFilesCount = statistics.uploadedJsonFilesCount.dec()) } + file.isProcessing() -> setStatistics { it.copy(processingFilesCount = statistics.processingFilesCount.dec()) } + file.isPendingRemoved() -> setStatistics { it.copy(pendingRemovedFilesCount = statistics.pendingRemovedFilesCount.dec()) } + file.isDuplicate() -> setStatistics { it.copy(duplicateFilesCount = statistics.duplicateFilesCount.dec()) } + file.isHasErrors() -> setStatistics { it.copy(errorFilesCount = statistics.errorFilesCount.dec()) } + } + setFileToDelete(null) + } else { + window.alert("Failed to delete file due to ${response.unpackMessageOrHttpStatus()}") + } + } + } + + useRequest { + val organizations = get( + url = "$apiUrl/organizations/with-allow-bulk-upload", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + .unsafeMap { + it.decodeFromJsonString>() + } + + setUserOrganizations(organizations) + } + + val getStatistics = useDeferredRequest { + selectedOrganization?.let { + val response = get( + url = "$apiUrl/raw-cosv/$selectedOrganization/statistics", + jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler + ) + when { + response.ok -> setStatistics(response.unsafeMap { it.decodeFromJsonString() }) + else -> window.alert("Failed to get statistics data: ${response.unpackMessageOrNull().orEmpty()}") + } + } + } + + val fetchMoreFiles = useDeferredRequest { + selectedOrganization?.let { + getStatistics() + val newPage = lastPage.inc() + val response = get( + url = "$apiUrl/raw-cosv/$selectedOrganization/list", + params = jso { + page = newPage - 1 + size = DEFAULT_SIZE + }, + headers = Headers().withAcceptNdjson().withContentTypeJson(), + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler + ) + when { + response.ok -> { + setStreamingOperationActive(true) + response + .readLines() + .filter(String::isNotEmpty) + .onCompletion { + setStreamingOperationActive(false) + setLastPage(newPage) + } + .collect { message -> + val uploadedFile: RawCosvFileDto = Json.decodeFromString(message) + setAvailableFiles { it.plus(uploadedFile) } + } + } + else -> window.alert("Failed to fetch next page: ${response.unpackMessageOrNull().orEmpty()}") + } + } + } + + val reFetchFiles = useDeferredRequest { + selectedOrganization?.let { + setAvailableFiles(emptyList()) + setLastPage(0) + fetchMoreFiles() + } + } + + val deleteAllDuplicatedCosvFiles = useDeferredRequest { + val response = delete( + url = "$apiUrl/raw-cosv/$selectedOrganization/delete-all-duplicated-files", + jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler + ) + when { + response.ok -> reFetchFiles() + else -> window.alert("Failed to delete duplicated files") + } + } + + var processedBytes by useState(0L) + val uploadFiles = useDeferredRequest { + setStreamingOperationActive(true) + setCurrentProgress(0) + setCurrentProgressMessage("Initializing...") + val totalBytes = filesForUploading.sumOf { it.size.toLong() } + val response = post( + url = "$apiUrl/raw-cosv/$selectedOrganization/batch-upload", + headers = Headers().withAcceptNdjson(), + body = FormData().apply { filesForUploading.forEach { append(FILE_PART_NAME, it) } }, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + when { + response.ok -> response + .readLines() + .filter(String::isNotEmpty) + .onCompletion { + setStreamingOperationActive(false) + reFetchFiles() + } + .collect { message -> + val uploadedFile: RawCosvFileDto = Json.decodeFromString(message) + processedBytes += uploadedFile.requiredContentLength() + if (processedBytes == totalBytes) { + setCurrentProgress(((processedBytes / totalBytes) * 100).toInt()) + setCurrentProgressMessage("${processedBytes.toKilobytes()} / ${totalBytes.toKilobytes()} KB") + } else { + setCurrentProgress(100) + setCurrentProgressMessage("Successfully uploaded ${totalBytes.toKilobytes()} KB.") + } + } + else -> { + setStreamingOperationActive(false) + resetCurrentProgress() + window.alert(response.unpackMessageOrNull().orEmpty()) + } + } + } + + val unzipFile = useDeferredRequest { + fileToUnzip?.let { file -> + setStreamingOperationActive(true) + val response = post( + "$apiUrl/raw-cosv/$selectedOrganization/unzip/${file.requiredId()}", + headers = Headers().withContentTypeJson().withAcceptNdjson(), + body = undefined, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + when { + response.ok -> { + setAvailableFiles { + it.minus(file) + } + response + .readLines() + .filter(String::isNotEmpty) + .onCompletion { + setStreamingOperationActive(false) + setFileToUnzip(null) + reFetchFiles() + } + .collect { message -> + val entryResponse: RawCosvFileStreamingResponse = Json.decodeFromString(message) + setCurrentProgress(entryResponse.progress) + setCurrentProgressMessage(entryResponse.progressMessage) + } + } + else -> { + setStreamingOperationActive(false) + resetCurrentProgress() + window.alert("Failed to unzip ${file.fileName}: ${response.unpackMessageOrNull().orEmpty()}") + } + } + } + } + + val submitAllUploadedCosvFiles = useDeferredRequest { + val response = post( + url = "$apiUrl/raw-cosv/$selectedOrganization/submit-all-uploaded-to-process", + jsonHeaders, + body = undefined, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler + ) + if (response.ok) { + reFetchFiles() + setCurrentProgress(100) + setCurrentProgressMessage("All uploaded files submitted to be processed") + } + } + + div { + if (selectedOrganization.isNullOrEmpty()) { + div { + className = ClassName("mx-auto") + b { + +"${"Organization that has permission".t()}!" + } + } + } + + organizationSelectForm { + selectClasses = "custom-select" + formType = InputTypes.ORGANIZATION_NAME + validInput = !selectedOrganization.isNullOrEmpty() && selectedOrganization.isValidName() + classes = "mb-3" + formName = "Organization" + getData = { userOrganizations.map { it.name } } + dataToString = { it } + selectedValue = selectedOrganization.orEmpty() + disabled = false + onChangeFun = { value -> + setSelectedOrganization(value) + reFetchFiles() + } + } + + ul { + className = ClassName("list-group") + + // ===== UPLOAD FILES FIELD ===== + li { + className = ClassName("list-group-item p-0 d-flex bg-light") + dragAndDropForm { + isDisabled = selectedOrganization.isNullOrEmpty() || isStreamingOperationActive + isMultipleFilesSupported = true + tooltipMessage = "Only JSON files or ZIP archives" + onChangeEventHandler = { files -> + setFilesForUploading(files!!.asList()) + uploadFiles() + } + } + } + + // ===== SUBMIT BUTTONS ===== + li { + className = ClassName("list-group-item p-1 d-flex bg-light justify-content-center") + buttonBuilder("Submit all uploaded", classes = "mr-1", isDisabled = statistics.uploadedJsonFilesCount == 0 || isStreamingOperationActive) { + if (window.confirm("Processed files will be removed. Do you want to continue?")) { + submitAllUploadedCosvFiles() + } + } + buttonBuilder("Delete all duplicates", classes = "mr-1", isDisabled = statistics.duplicateFilesCount == 0 || isStreamingOperationActive) { + if (window.confirm("Duplicated files will be removed. Do you want to continue?")) { + deleteAllDuplicatedCosvFiles() + } + } + buttonBuilder(faReload, isDisabled = isStreamingOperationActive) { + reFetchFiles() + } + } + + // ===== STATUS BAR ===== + with(statistics) { + if (!isStreamingOperationActive && allAvailableFilesCount > 0) { + li { + className = ClassName("list-group-item p-1 d-flex bg-light justify-content-center") + + when { + uploadedJsonFilesCount > 0 && uploadedArchivesCount > 0 -> +"Uploaded $uploadedJsonFilesCount new json files and $uploadedArchivesCount archives. " + uploadedJsonFilesCount > 0 -> +"Uploaded $uploadedJsonFilesCount new json files. " + uploadedArchivesCount > 0 -> +"Uploaded $uploadedArchivesCount new archives. " + } + + if (processingFilesCount > 0) { + +"Still processing $processingFilesCount files. " + } + + if (pendingRemovedFilesCount > 0) { + +"Pending to be removed $pendingRemovedFilesCount files. " + } + + when { + duplicateFilesCount > 0 && errorFilesCount > 0 -> +"Failed with $duplicateFilesCount duplicates, $errorFilesCount files with another errors." + duplicateFilesCount > 0 -> +"Failed with $duplicateFilesCount duplicates." + errorFilesCount > 0 -> +"Failed $errorFilesCount files with errors." + } + } + } + } + + // ===== PROGRESS BAR ===== + defaultProgressBarComponent { + this.currentProgress = currentProgress + this.currentProgressMessage = currentProgressMessage + reset = { + setCurrentProgress(-1) + setCurrentProgressMessage("") + } + } + + // ===== AVAILABLE FILES ===== + availableFiles.map { file -> + li { + val highlightZipArchive = when { + file.isZipArchive() -> "font-weight-bold" + else -> "" + } + val fileColor = when { + file.isZipArchive() -> "primary" + file.isUploadedJsonFile() -> "success" + file.isProcessing() -> "secondary" + file.isPendingRemoved() -> "light" + file.isDuplicate() -> "warning" + file.isHasErrors() -> "danger" + else -> "primary" + } + className = ClassName("list-group-item $highlightZipArchive text-left list-group-item-$fileColor") + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "left" + title = when { + file.isZipArchive() -> "It's a ZIP archive, please unzip to get JSON files" + file.isUploadedJsonFile() -> "It's a JSON file, you can submit it" + file.isProcessing() -> "In progress, please wait" + file.isPendingRemoved() -> "Already processed, will be deleted shortly" + file.isDuplicate() -> "Duplicate, the vulnerability with such ID already uploaded: ${file.statusMessage.orEmpty()}" + file.isHasErrors() -> "This JSON file has error: ${file.statusMessage.orEmpty()}" + else -> "" + } + if (file.isZipArchive()) { + button { + type = ButtonType.button + className = ClassName("btn") + fontAwesomeIcon(icon = faBoxOpen) + disabled = isStreamingOperationActive + onClick = { + val confirm = window.confirm( + "Are you sure you want to unzip and then remove ${file.fileName} file?" + ) + if (confirm) { + setFileToUnzip(file) + unzipFile() + } + } + } + } + downloadFileButton(file, RawCosvFileDto::fileName) { + "$apiUrl/raw-cosv/$selectedOrganization/download/${file.requiredId()}" + } + deleteFileButton(file, RawCosvFileDto::fileName) { + setFileToDelete(it) + deleteFile() + } + +"${file.fileName} " + span { + className = ClassName("font-weight-bold text-justify") + +when { + file.isProcessing() -> " (in progress)" + file.isPendingRemoved() -> " (processed, will be deleted shortly)" + file.isDuplicate() -> " (duplicate)" + file.isHasErrors() -> " (error)" + else -> " " + } + } + } + } + + if (leftAvailableFilesCount > 0) { + li { + className = ClassName("list-group-item p-1 d-flex bg-light justify-content-center") + buttonBuilder("Load more (left $leftAvailableFilesCount)", isDisabled = isStreamingOperationActive) { + fetchMoreFiles() + } + } + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/component/CvssBaseScoreCalculator.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/CvssBaseScoreCalculator.kt similarity index 98% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/component/CvssBaseScoreCalculator.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/CvssBaseScoreCalculator.kt index 9ab375d5c8..7ec2371798 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/component/CvssBaseScoreCalculator.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/CvssBaseScoreCalculator.kt @@ -1,10 +1,10 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln.component +package com.saveourtool.save.cosv.frontend.components.views.vuln.component import com.saveourtool.save.cvsscalculator.* import com.saveourtool.save.cvsscalculator.v3.* -import com.saveourtool.save.frontend.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.buttonBuilder import js.core.jso import react.ChildrenBuilder import react.FC diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/TimelineComponent.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/TimelineComponent.kt new file mode 100644 index 0000000000..d81c2fe06d --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/component/TimelineComponent.kt @@ -0,0 +1,112 @@ +@file:Suppress("FILE_NAME_INCORRECT", "FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.cosv.frontend.components.views.vuln.component + +import com.saveourtool.save.entities.cosv.VulnerabilityExt +import com.saveourtool.save.entities.vulnerability.VulnerabilityDateDto +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import react.* +import react.dom.html.ReactHTML.div +import web.cssom.* + +import kotlinx.datetime.LocalDateTime + +const val HOVERABLE_CONST = "hoverable" + +val timelineComponent: FC = FC { props -> + val (t) = useTranslation("dates") + val hoverable = props.onNodeClick?.let { HOVERABLE_CONST }.orEmpty() + + div { + className = ClassName("mb-3") + props.title?.let { title -> + div { + className = ClassName("mt-3 mb-3 text-xs text-center font-weight-bold text-primary text-uppercase") + +title + } + } + + props.onAddClick?.let { onClickCallback -> + buttonBuilder( + label = "Add date".t(), + style = "secondary", + isOutline = true, + classes = "btn btn-sm btn-primary" + ) { + onClickCallback() + } + } + + div { + className = ClassName("p-0 timeline-container") + div { + className = ClassName("steps-container") + div { + className = ClassName("line") + } + + // is nullable field in schema, so if it is null we should not be showing it + props.dates + .toList() + .sortedBy { it.date } + .forEach { (dateTime, label) -> + div { + className = + ClassName(if (!label.isSystemDateType()) "step $hoverable" else "step-non-editable") + if (!label.isSystemDateType()) { + props.onNodeClick?.let { onClickCallback -> + onClick = { onClickCallback(dateTime, label.value) } + } + } + div { + className = ClassName("text-label") + +label.value.t() + } + div { + className = ClassName("date-label") + +dateTime.date.toString() + } + } + div { + className = ClassName("line") + } + } + div { + className = ClassName("line-end") + } + } + } + } +} + +/** + * [Props] of [timelineComponent] + */ +external interface TimelineComponentProps : Props { + /** + * Timeline title + */ + var title: String? + + /** + * Map with dates where key is [LocalDateTime] and value is label + */ + var dates: List + + /** + * Callback that should be invoked on add button click + */ + var onAddClick: (() -> Unit)? + + /** + * Callback that should be invoked on timeline node click + */ + @Suppress("TYPE_ALIAS") + var onNodeClick: ((LocalDateTime, String) -> Unit)? + + /** + * Vulnerability dto of vulnerability + */ + var vulnerability: VulnerabilityExt +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/OrganizationRatingTab.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/OrganizationRatingTab.kt similarity index 90% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/OrganizationRatingTab.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/OrganizationRatingTab.kt index 1a931fd3d7..8996645b26 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/OrganizationRatingTab.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/OrganizationRatingTab.kt @@ -1,15 +1,15 @@ @file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.toprating +package com.saveourtool.save.cosv.frontend.components.views.vuln.toprating import com.saveourtool.save.entities.OrganizationDto 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.table.filters.nameFiltersRow -import com.saveourtool.save.frontend.components.tables.* -import com.saveourtool.save.frontend.utils.* -import com.saveourtool.save.frontend.utils.noopResponseHandler +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.components.basic.table.filters.nameFiltersRow +import com.saveourtool.save.frontend.common.components.tables.* +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.noopResponseHandler import js.core.jso import react.* diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/RatingUtils.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/RatingUtils.kt similarity index 80% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/RatingUtils.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/RatingUtils.kt index f154174e6d..1414a9f276 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/RatingUtils.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/RatingUtils.kt @@ -1,9 +1,9 @@ @file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.toprating +package com.saveourtool.save.cosv.frontend.components.views.vuln.toprating -import com.saveourtool.save.frontend.externals.fontawesome.faTrophy -import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.externals.fontawesome.faTrophy +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon import js.core.jso import react.ChildrenBuilder import react.dom.html.ReactHTML.td diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/TopRatingView.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/TopRatingView.kt similarity index 80% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/TopRatingView.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/TopRatingView.kt index 58487606f1..affd79bf5a 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/TopRatingView.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/TopRatingView.kt @@ -4,14 +4,14 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.toprating +package com.saveourtool.save.cosv.frontend.components.views.vuln.toprating import com.saveourtool.save.frontend.TabMenuBar -import com.saveourtool.save.frontend.components.views.contests.tab -import com.saveourtool.save.frontend.components.views.contests.title -import com.saveourtool.save.frontend.externals.fontawesome.faTrophy -import com.saveourtool.save.frontend.utils.Style -import com.saveourtool.save.frontend.utils.useBackground +import com.saveourtool.save.frontend.common.externals.fontawesome.faTrophy +import com.saveourtool.save.frontend.common.utils.Style +import com.saveourtool.save.frontend.common.utils.tab +import com.saveourtool.save.frontend.common.utils.title +import com.saveourtool.save.frontend.common.utils.useBackground import com.saveourtool.save.validation.FrontendRoutes import react.FC import react.Props diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/UserRatingTab.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/UserRatingTab.kt similarity index 88% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/UserRatingTab.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/UserRatingTab.kt index 5417b39055..d8f74550ba 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/toprating/UserRatingTab.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/toprating/UserRatingTab.kt @@ -1,12 +1,12 @@ @file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components.views.toprating +package com.saveourtool.save.cosv.frontend.components.views.vuln.toprating -import com.saveourtool.save.frontend.components.basic.renderUserAvatarWithName -import com.saveourtool.save.frontend.components.basic.table.filters.nameFiltersRow -import com.saveourtool.save.frontend.components.tables.* -import com.saveourtool.save.frontend.utils.* -import com.saveourtool.save.frontend.utils.noopResponseHandler +import com.saveourtool.save.frontend.common.components.basic.renderUserAvatarWithName +import com.saveourtool.save.frontend.common.components.basic.table.filters.nameFiltersRow +import com.saveourtool.save.frontend.common.components.tables.* +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.noopResponseHandler import com.saveourtool.save.info.UserInfo import react.* import react.dom.html.ReactHTML.div diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvDescriptions.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvDescriptions.kt similarity index 99% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvDescriptions.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvDescriptions.kt index bfd55f31d4..70a71ed79f 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvDescriptions.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvDescriptions.kt @@ -2,7 +2,7 @@ * The list of descriptions of each field in COSV schema */ -package com.saveourtool.save.frontend.components.views.vuln.utils +package com.saveourtool.save.cosv.frontend.components.views.vuln.utils val schemaVersionDescr = """ The `schema_version` field is used to indicate which version diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvFieldsToDescriptions.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvFieldsToDescriptions.kt similarity index 97% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvFieldsToDescriptions.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvFieldsToDescriptions.kt index 27576d2982..67140989d4 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvFieldsToDescriptions.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvFieldsToDescriptions.kt @@ -2,7 +2,7 @@ * The map of keys to descriptions of each field in COSV schema */ -package com.saveourtool.save.frontend.components.views.vuln.utils +package com.saveourtool.save.cosv.frontend.components.views.vuln.utils val cosvFieldsDescriptionsMap = listOf( "schema_version" to schemaVersionDescr, diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvSchema.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvSchema.kt similarity index 97% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvSchema.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvSchema.kt index 025b308117..217b45a5e4 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/utils/CosvSchema.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/vuln/utils/CosvSchema.kt @@ -2,7 +2,7 @@ * The base spec of COSV schema */ -package com.saveourtool.save.frontend.components.views.vuln.utils +package com.saveourtool.save.cosv.frontend.components.views.vuln.utils const val COSV_SCHEMA_JSON = """ { diff --git a/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/MarketingTitles.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/MarketingTitles.kt new file mode 100644 index 0000000000..6014618458 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/MarketingTitles.kt @@ -0,0 +1,90 @@ +/** + * Utility methods for beautiful titles/slogans on welcome view + */ + +package com.saveourtool.save.cosv.frontend.components.views.welcome + +import com.saveourtool.save.frontend.common.externals.fontawesome.faChevronDown +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.h3 +import web.cssom.* + +/** + * @param textColor + * @param isDark + */ +fun ChildrenBuilder.vulnWelcomeMarketingTitle(textColor: String, isDark: Boolean = false) { + div { + className = ClassName("col-4 text-left mt-5 mx-5 $textColor") + marketingTitle("Vulnerability", isDark) + marketingTitle("Database", isDark) + marketingTitle(" and", isDark) + marketingTitle("Benchmark", isDark) + marketingTitle("Archive", isDark) + h3 { + if (isDark) { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast() + } + } + className = ClassName("mt-4") + +"A huge storage of known vulnerabilities." + } + } +} + +/** + * @param str + * @param isDark + */ +fun ChildrenBuilder.marketingTitle(str: String, isDark: Boolean) { + div { + if (isDark) { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast() + } + } + className = ClassName("mb-0 mt-0") + h1Bold(str[0].toString()) + h1Normal(str.substring(1, str.length)) + } +} + +/** + * @param col + */ +@Suppress("MAGIC_NUMBER") +fun ChildrenBuilder.chevron(col: String) { + div { + className = ClassName("mt-5 row justify-content-center") + h1 { + className = ClassName("mt-5 animate__animated animate__pulse animate__infinite") + style = jso { + fontSize = 5.rem + color = col.unsafeCast() + } + fontAwesomeIcon(faChevronDown) + } + } +} + +private fun ChildrenBuilder.h1Bold(str: String) = h1 { + +str + style = jso { + fontWeight = "bold".unsafeCast() + display = Display.inline + fontSize = "4.5rem".unsafeCast() + } +} + +private fun ChildrenBuilder.h1Normal(str: String) = h1 { + +str + style = jso { + display = Display.inline + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/VulnerabilityWelcomeView.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/VulnerabilityWelcomeView.kt similarity index 84% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/VulnerabilityWelcomeView.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/VulnerabilityWelcomeView.kt index 73cd48e824..40f0d0dc5d 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/VulnerabilityWelcomeView.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/VulnerabilityWelcomeView.kt @@ -9,15 +9,21 @@ "FILE_NAME_MATCH_CLASS" ) -package com.saveourtool.save.frontend.components.views.welcome +package com.saveourtool.save.cosv.frontend.components.views.welcome +import com.saveourtool.save.cosv.frontend.components.views.welcome.pagers.vuln.renderVulnerabilityGeneralInfo import com.saveourtool.save.filters.VulnerabilityFilter.Companion.approved -import com.saveourtool.save.frontend.components.views.welcome.pagers.vuln.renderVulnerabilityGeneralInfo -import com.saveourtool.save.frontend.externals.fontawesome.* -import com.saveourtool.save.frontend.externals.i18next.TranslationFunction -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.themes.Colors -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.views.welcome.hrNoMargin +import com.saveourtool.save.frontend.common.components.views.welcome.inputCredentialsView +import com.saveourtool.save.frontend.common.components.views.welcome.menuTextAndLink +import com.saveourtool.save.frontend.common.components.views.welcome.welcomeUserMenu +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.i18next.TranslationFunction +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.themes.Colors +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.UserInfoAwarePropsWithChildren +import com.saveourtool.save.frontend.common.utils.useBackground import com.saveourtool.save.info.OauthProviderInfo import com.saveourtool.save.validation.FrontendRoutes @@ -103,8 +109,6 @@ val vulnWelcomeView: FC = FC { props -> menuTextAndLink("Propose vulnerability".t(), FrontendRoutes.VULN_CREATE, faPlus) hrNoMargin() menuTextAndLink("Top rating".t(), FrontendRoutes.VULN_TOP_RATING, faTrophy) - hrNoMargin() - menuTextAndLink("Go to main page".t(), FrontendRoutes.INDEX, faHome) } } ?: inputCredentialsView( oauthProviders, diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/vuln/WhatIsVuln.kt b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/pagers/vuln/WhatIsVuln.kt similarity index 91% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/vuln/WhatIsVuln.kt rename to save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/pagers/vuln/WhatIsVuln.kt index 2bf2426f37..87021d5ef5 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/vuln/WhatIsVuln.kt +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/components/views/welcome/pagers/vuln/WhatIsVuln.kt @@ -2,12 +2,12 @@ * [1 page] Main information about SAVE-cloud */ -package com.saveourtool.save.frontend.components.views.welcome.pagers.vuln +package com.saveourtool.save.cosv.frontend.components.views.welcome.pagers.vuln -import com.saveourtool.save.frontend.components.views.welcome.BIG_FONT_SIZE -import com.saveourtool.save.frontend.components.views.welcome.FIRST_RAW_HEIGHT -import com.saveourtool.save.frontend.components.views.welcome.SECOND_RAW_HEIGHT -import com.saveourtool.save.frontend.externals.i18next.TranslationFunction +import com.saveourtool.save.cosv.frontend.components.views.welcome.BIG_FONT_SIZE +import com.saveourtool.save.cosv.frontend.components.views.welcome.FIRST_RAW_HEIGHT +import com.saveourtool.save.cosv.frontend.components.views.welcome.SECOND_RAW_HEIGHT +import com.saveourtool.save.frontend.common.externals.i18next.TranslationFunction import com.saveourtool.save.validation.FrontendRoutes import js.core.jso import react.ChildrenBuilder 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 new file mode 100644 index 0000000000..af8c382592 --- /dev/null +++ b/save-cosv-frontend/src/main/kotlin/com/saveourtool/save/cosv/frontend/routing/BasicRouting.kt @@ -0,0 +1,136 @@ +/** + * All routs for the mobile version of the frontend + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS", "EXTENSION_FUNCTION_WITH_CLASS") + +package com.saveourtool.save.cosv.frontend.routing + +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.registrationView +import com.saveourtool.save.frontend.common.components.views.userprofile.userProfileView +import com.saveourtool.save.frontend.common.components.views.usersettings.userSettingsView +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.withRouter +import com.saveourtool.save.validation.FrontendRoutes.* + +import org.w3c.dom.url.URLSearchParams +import react.* +import react.router.* + +/** + * Just put a map: View -> Route URL to this list + */ +val basicRouting: FC = FC { props -> + useUserStatusRedirects(props.userInfo?.status) + + val userProfileView = withRouter { _, params -> + userProfileView { + userName = params["name"]!! + currentUserInfo = props.userInfo + } + } + + val vulnerabilityCollectionView = withRouter { location, _ -> + vulnerabilityCollectionView { + currentUserInfo = props.userInfo + filter = URLSearchParams(location.search).toVulnerabilitiesFilter() + } + } + + val vulnerabilityView = withRouter { _, params -> + vulnerabilityView { + identifier = requireNotNull(params["identifier"]) + currentUserInfo = props.userInfo + } + } + + Routes { + listOf( + registrationView.create { + userInfo = props.userInfo + userInfoSetter = props.userInfoSetter + } to REGISTRATION, + vulnWelcomeView.create { userInfo = props.userInfo } to "/", + FallbackView::class.react.create { + bigText = "404" + smallText = "Page not found" + withRouterLink = true + } to ERROR_404, + vulnerabilityCollectionView.create() to "$VULN/list/:params?", + createVulnerabilityView.create() to VULN_CREATE, + uploadVulnerabilityView.create() to VULN_UPLOAD, + vulnerabilityView.create() to "$VULNERABILITY_SINGLE/:identifier", + cosvSchemaView.create() to VULN_COSV_SCHEMA, + topRatingView.create() to VULN_TOP_RATING, + userProfileView.create() to "$VULN_PROFILE/:name", + + userSettingsView.create { + this.userInfoSetter = props.userInfoSetter + userInfo = props.userInfo + type = SETTINGS_PROFILE + } to SETTINGS_PROFILE, + + userSettingsView.create { + this.userInfoSetter = props.userInfoSetter + userInfo = props.userInfo + type = SETTINGS_EMAIL + } to SETTINGS_EMAIL, + + userSettingsView.create { + userInfo = props.userInfo + type = SETTINGS_TOKEN + } to SETTINGS_TOKEN, + + userSettingsView.create { + userInfo = props.userInfo + type = SETTINGS_ORGANIZATIONS + } to SETTINGS_ORGANIZATIONS, + + userSettingsView.create { + userInfo = props.userInfo + type = SETTINGS_DELETE + } to SETTINGS_DELETE, + + ).forEach { (view, route) -> + PathRoute { + this.element = view + this.path = "/$route" + } + } + + props.userInfo?.name?.run { + PathRoute { + path = "/$this" + element = Navigate.create { to = "/$this/$SETTINGS_PROFILE" } + } + } + + PathRoute { + path = "*" + element = FallbackView::class.react.create { + bigText = "404" + smallText = "Page not found" + withRouterLink = true + } + } + } +} + +private val fallbackNode = FallbackView::class.react.create { + bigText = "404" + smallText = "Page not found" + withRouterLink = false +} + +/** + * @param view + * @return a view or a fallback of user info is null + */ +fun UserInfoAwareMutablePropsWithChildren.viewWithFallBack(view: ReactElement<*>) = this.userInfo?.name?.let { + view +} ?: fallbackNode diff --git a/save-cosv-frontend/src/main/resources/browserconfig.xml b/save-cosv-frontend/src/main/resources/browserconfig.xml new file mode 100644 index 0000000000..b3930d0f04 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar1.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar1.png new file mode 100644 index 0000000000..179a7d5b55 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar1.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar2.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar2.png new file mode 100644 index 0000000000..9566075877 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar2.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar3.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar3.png new file mode 100644 index 0000000000..1551e83d8a Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar3.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar4.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar4.png new file mode 100644 index 0000000000..8611a46d2f Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar4.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar5.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar5.png new file mode 100644 index 0000000000..571cb55127 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar5.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar6.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar6.png new file mode 100644 index 0000000000..b68e468c9f Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar6.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar7.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar7.png new file mode 100644 index 0000000000..49a86a8a27 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar7.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar8.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar8.png new file mode 100644 index 0000000000..2c839b0281 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar8.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar9.png b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar9.png new file mode 100644 index 0000000000..0fc4c71797 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_packs/avatar9.png differ diff --git a/save-cosv-frontend/src/main/resources/img/avatar_placeholder.png b/save-cosv-frontend/src/main/resources/img/avatar_placeholder.png new file mode 100644 index 0000000000..1e59d7daa9 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/avatar_placeholder.png differ diff --git a/save-cosv-frontend/src/main/resources/img/company.png b/save-cosv-frontend/src/main/resources/img/company.png new file mode 100644 index 0000000000..09b706ebe4 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/company.png differ diff --git a/save-cosv-frontend/src/main/resources/img/gitee.svg b/save-cosv-frontend/src/main/resources/img/gitee.svg new file mode 100644 index 0000000000..9e4cfa123f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/gitee.svg @@ -0,0 +1,9 @@ + + + logo_gitee_g_red@1x + + \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/img/github.svg b/save-cosv-frontend/src/main/resources/img/github.svg new file mode 100644 index 0000000000..37fa923df3 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/img/google.svg b/save-cosv-frontend/src/main/resources/img/google.svg new file mode 100644 index 0000000000..b1d12d3bb3 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/google.svg @@ -0,0 +1,50 @@ + + + + btn_google_light_normal_ios + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/img/huawei.svg b/save-cosv-frontend/src/main/resources/img/huawei.svg new file mode 100644 index 0000000000..aab31ee1b1 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/huawei.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + diff --git a/save-cosv-frontend/src/main/resources/img/logo-anim.gif b/save-cosv-frontend/src/main/resources/img/logo-anim.gif new file mode 100644 index 0000000000..1afab749a9 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/logo-anim.gif differ diff --git a/save-cosv-frontend/src/main/resources/img/logo-bg-p-3.png b/save-cosv-frontend/src/main/resources/img/logo-bg-p-3.png new file mode 100644 index 0000000000..84f3cb9805 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/logo-bg-p-3.png differ diff --git a/save-cosv-frontend/src/main/resources/img/not_publiched_packs/avatar10.png b/save-cosv-frontend/src/main/resources/img/not_publiched_packs/avatar10.png new file mode 100644 index 0000000000..992e7ab0ec Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/not_publiched_packs/avatar10.png differ diff --git a/save-cosv-frontend/src/main/resources/img/sad_cat.png b/save-cosv-frontend/src/main/resources/img/sad_cat.png new file mode 100644 index 0000000000..75b728183d Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/sad_cat.png differ diff --git a/save-cosv-frontend/src/main/resources/img/schema.png b/save-cosv-frontend/src/main/resources/img/schema.png new file mode 100644 index 0000000000..eddccecd80 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/schema.png differ diff --git a/save-cosv-frontend/src/main/resources/img/settings_icon1.png b/save-cosv-frontend/src/main/resources/img/settings_icon1.png new file mode 100644 index 0000000000..eb0290c75e Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/settings_icon1.png differ diff --git a/save-cosv-frontend/src/main/resources/img/settings_icon2.png b/save-cosv-frontend/src/main/resources/img/settings_icon2.png new file mode 100644 index 0000000000..79c7c47a00 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/settings_icon2.png differ diff --git a/save-cosv-frontend/src/main/resources/img/undraw_happy_announcement_re_tsm0.svg b/save-cosv-frontend/src/main/resources/img/undraw_happy_announcement_re_tsm0.svg new file mode 100644 index 0000000000..623968572f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/undraw_happy_announcement_re_tsm0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/img/undraw_image_not_found.png b/save-cosv-frontend/src/main/resources/img/undraw_image_not_found.png new file mode 100644 index 0000000000..15ed9255ac Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/undraw_image_not_found.png differ diff --git a/save-cosv-frontend/src/main/resources/img/undraw_important.svg b/save-cosv-frontend/src/main/resources/img/undraw_important.svg new file mode 100644 index 0000000000..dd315d6381 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/undraw_important.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/img/undraw_profile.svg b/save-cosv-frontend/src/main/resources/img/undraw_profile.svg new file mode 100644 index 0000000000..9802341781 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/undraw_profile.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + diff --git a/save-cosv-frontend/src/main/resources/img/undraw_question.svg b/save-cosv-frontend/src/main/resources/img/undraw_question.svg new file mode 100644 index 0000000000..f916e6ce22 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/undraw_question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/img/undraw_warning.svg b/save-cosv-frontend/src/main/resources/img/undraw_warning.svg new file mode 100644 index 0000000000..2a19354c5f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/img/undraw_warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/img/vuln-logo-bg.png b/save-cosv-frontend/src/main/resources/img/vuln-logo-bg.png new file mode 100644 index 0000000000..51bc655ec7 Binary files /dev/null and b/save-cosv-frontend/src/main/resources/img/vuln-logo-bg.png differ diff --git a/save-cosv-frontend/src/main/resources/index.html b/save-cosv-frontend/src/main/resources/index.html new file mode 100644 index 0000000000..ff5236cb6b --- /dev/null +++ b/save-cosv-frontend/src/main/resources/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + COSV Platform + + + + + + + + + + + + + + + + + +
+ Loading... +
+ + diff --git a/save-cosv-frontend/src/main/resources/locales/cn/comments.json b/save-cosv-frontend/src/main/resources/locales/cn/comments.json new file mode 100644 index 0000000000..7275d281c8 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/comments.json @@ -0,0 +1,7 @@ +{ + "Authorize in order to write comments": "请登录以进行评论", + "Unknown": "目前未知", + "Are you sure you want to delete a comment?": "确定要删除此评论吗?", + "Rating": "排行", + "Write a comment": "留下一条评论" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/cookies.json b/save-cosv-frontend/src/main/resources/locales/cn/cookies.json new file mode 100644 index 0000000000..cdebe3ff4c --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/cookies.json @@ -0,0 +1,9 @@ +{ + "What are cookies": "### 什么是Cookie\n\n这个Cookie策略说明了什么是cookie, 我们如何使用它们, 我们使用哪些类型的cookie (例如, 我们收集哪些信息,以及我们如何使用这些信息), 以及如何管理这些cookies.\n\nCookies 是用于存储小块信息的小文本文件。当网站加载到您的浏览器中时,它们就会存储在您的设备上。这些 Cookie 帮助我们确保网站正常运行、增强其安全性、提供更好的用户体验、了解网站的运行情况、分析哪些内容有效并确定需要改进的领域。", + "How do we use cookies": "### 我们如何使用 cookie\n\n与大多数在线服务一样,我们的网站将第一方 cookie 用于各种目的。这些 cookie 对于网站的正常运行至关重要,并且不会收集您的任何个人身份数据。", + "What exactly do we store": "### 我们到底存储什么\n\n* 我们存储有关您的**首选语言**的信息,该信息将在 **1 年**后过期。这些数据是为了您的方便而需要的,不会传递到任何地方。\n\n* 如果您曾经在我们的网站上接受过 Cookie,我们会将其保存在我们的 Cookie 中(**1 年**后过期)。", + "We value your privacy": "### **我们重视您的隐私**\n\n我们使用 cookie 来增强您的浏览体验、提供个性化广告或内容以及分析我们的流量。单击“接受”即表示您同意我们使用 cookie。", + "Accept": "接受", + "Decline": "拒绝", + "Read more": "查看更多" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/dates.json b/save-cosv-frontend/src/main/resources/locales/cn/dates.json new file mode 100644 index 0000000000..5c9e14abb2 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/dates.json @@ -0,0 +1,11 @@ +{ + "Add date": "添加日期信息", + "Disclosed": "曝光时间", + "Fixed": "修复时间", + "Found": "发现时间", + "Introduced": "引入时间", + "Modified": "修改时间", + "Published": "公布时间", + "Submitted": "提交时间", + "Withdrawn": "撤回时间" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/index.json b/save-cosv-frontend/src/main/resources/locales/cn/index.json new file mode 100644 index 0000000000..213e41863f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/index.json @@ -0,0 +1,32 @@ +{ + "Cloud Platform for CI and Benchmarking of Code Analyzers": "CI和代码静态分析工具的BenchMark云服务平台", + "Archive of 1-Day Vulnerabilities Aggregated from Various Sources": "聚合多来源的1-day漏洞库", + "Non-profit Opensource Ecosystem with a focus on finding code bugs": "专注于查找代码错误的非营利开源生态系统", + "Welcome": "欢迎", + "Registered since": "注册时间", + "Profile Settings": "用户设置", + "Vulnerabilities Archive": "漏洞库", + "Save Cloud Platform": "Save 云服务平台", + "Your organizations": "您的组织", + "Create": "创建", + "Your statistics": "关于您的统计信息", + "Vulnerabilities": "漏洞", + "Top rating": "贡献排行", + "For the better User Experience we recommend you to register.": "为了获得更好的用户体验,我们建议您使用受支持的 Oauth 提供商之一注册或登录 SaveOurTool 平台。无论如何,您无需注册即可继续,但功能会受到限制。", + "The easiest way to start working with our Ecosystem is to create your organization.": "开始使用我们的生态系统的最简单方法是创建您的组织、邀请合作者并开始使用您喜欢的服务。", + "Multiple different services": "Multiple different services", + "provides Intelligent Services for developers of code analysis tools. Our two main directions:": "为代码分析工具开发人员提供智能服务。我们的两个主要方向:", + "- a platform for a distributed Cloud CI of code analyzers with a special test framework. With SAVE you can:": "- 具有特殊测试框架的分布式云 CI 代码分析器平台。通过SAVE,您可以:", + "quickly establish testing and CI of your analyzer": "|- 快速建立测试用例并集成到您的代码分析工具中; \n |- 与社区分享您的测试用例,并对比你们的工具能力;\n |- 使用 SAVE,您甚至可以为您的代码分析工具创建在线演示并为您的社区进行配置。", + "- a platform for reporting, aggregation and deduplication of 1-day Vulnerabilities.": "- 用于报告发现、聚合并去重已知漏洞的平台。", + "Also we establish contests in the area of code analysis.": "此外,我们还在代码分析领域举办竞赛,您可以提出用于查找错误的自动化解决方案并与其他项目竞争。", + "We are just a group of several developers working on this community project.": "我们只是一群致力于这个社区项目的开发人员。我们的主要想法是,我们可以将代码分析领域中完成的所有日常工作统一在一起,并帮助分析器的开发人员专注于他们的主要工作:查找代码中的错误。", + "About Us": "关于我们", + "We kindly ask you not to break this service and report any problems that you will find to our Github.": "我们恳请您不要破坏此服务并将您发现的任何问题报告到我们的 Github", + "Please also read our": "请别忘了阅读此", + "Terms of Usage": "使用条款", + "and": "以及", + "Cookie policy": "Cookie政策", + "Your notifications will be located here.": "您的通知将位于此处。", + "Notifications": "通知" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/organization.json b/save-cosv-frontend/src/main/resources/locales/cn/organization.json new file mode 100644 index 0000000000..43070ffc6b --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/organization.json @@ -0,0 +1,4 @@ +{ + "No vulnerabilities were found for this organization.": "未找到当前组织提供的漏洞", + "You can be the first one to create vulnerability.": "你可以成为第一位创建者" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/profile.json b/save-cosv-frontend/src/main/resources/locales/cn/profile.json new file mode 100644 index 0000000000..f105e6c5f0 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/profile.json @@ -0,0 +1,20 @@ +{ + "Add bio and info:": "添加介绍信息", + "Upload or select avatar:": "上传或选择头像", + "Save changes": "保存修改", + "400 characters": "限400个字符", + "Upload avatar": "上传头像", + "profile": "简介", + "Basic Settings": "基本设置", + "Profile settings": "个人设置", + "Your name": "名字", + "Login and email": "登录和邮箱", + "Organizations": "组织", + "Security Settings": "安全设置", + "Personal access tokens": "个人访问token", + "OAuth accounts": "认证账户", + "Personal Statistics": "个人数据统计", + "Delete Profile": "删除用户页", + "Company/Affiliation": "公司", + "Website": "网站" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/proposing.json b/save-cosv-frontend/src/main/resources/locales/cn/proposing.json new file mode 100644 index 0000000000..60e335b82b --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/proposing.json @@ -0,0 +1,26 @@ +{ + "Base Score Calculator": "漏洞评分计算器", + "Propose a new vulnerability": "创建一个新漏洞报告", + "Vulnerability identifier": "漏洞ID", + "Summary": "漏洞简介", + "Generate identifier": "生成漏洞ID", + "Details": "详情", + "Identifier": "ID", + "Related link": "相关链接", + "Severity score vector": "危险评分向量", + "Organization": "组织", + "Collaborators": "合作者", + "Propose a vulnerability": "提交", + "Project type": "项目类型", + "Affected versions": "受影响版本", + "Add": "添加", + "Cancel": "取消", + "Project Name": "项目名称", + "Library Name": "三方库名称", + "Library Url": "三方库地址", + "Commit Url": "提交地址", + "Commit Hash": "提交哈希", + "Project Url": "项目地址", + "Name must not be empty": "名称不允许为空", + "Please input a valid name and URL": "请输入有效的名称和地址" +} diff --git a/save-cosv-frontend/src/main/resources/locales/cn/table-headers.json b/save-cosv-frontend/src/main/resources/locales/cn/table-headers.json new file mode 100644 index 0000000000..9b1e34cee7 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/table-headers.json @@ -0,0 +1,13 @@ +{ + "Name": "漏洞名", + "Identifier": "编号", + "Organization": "组织", + "Description": "描述", + "COSV Submitter": "作者", + "Criticality": "危险程度", + "Language": "语言", + "Status": "状态", + "Tag": "标签", + "Version": "版本", + "Updated date": "更新日期" +} diff --git a/save-cosv-frontend/src/main/resources/locales/cn/thanks-for-registration.json b/save-cosv-frontend/src/main/resources/locales/cn/thanks-for-registration.json new file mode 100644 index 0000000000..89e1b19254 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/thanks-for-registration.json @@ -0,0 +1,5 @@ +{ + "Thank you for registration": "感谢您的注册", + "You need to wait until you will be reviewed and approved by admins": "很快我们的管理员会通过您的注册请求", + "Your request is pending review": "当前此平台尚在建设中,我们的管理团队会及时查看您的需求和建议,并且尽快给予反馈。无需犹豫,欢迎随时联系我们:" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/topbar.json b/save-cosv-frontend/src/main/resources/locales/cn/topbar.json new file mode 100644 index 0000000000..075bba1f24 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/topbar.json @@ -0,0 +1,22 @@ +{ + "Main page": "回到主页", + "Demo": "样例", + "CPG": "CPG", + "Awesome Benchmarks": "一流的基准", + "Try SAVE format": "尝试使用SAVE format", + "Projects board": "项目面板", + "Contests": "竞赛", + "About us": "关于我们", + "Propose vulnerability": "创建漏洞", + "Vulnerabilities": "漏洞", + "Top Rating": "贡献排名", + "SAVE Projects list": "SAVE项目列表", + "Vulnerabilities list": "漏洞列表", + "Super user": "超级用户", + "User settings": "用户设置", + "Profile": "用户主页", + "Settings": "设置", + "Manage organizations": "管理组织", + "Log out": "登出", + "Log in": "登录" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/vulnerability-collection.json b/save-cosv-frontend/src/main/resources/locales/cn/vulnerability-collection.json new file mode 100644 index 0000000000..66403b7098 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/vulnerability-collection.json @@ -0,0 +1,7 @@ +{ + "Introducing": "快速开始", + "introMd": "|## 开源漏洞库\n |当前页面给出的是已公开的安全漏洞详情", + "addNewMd": "|### 新增漏洞\n |你可以在此添加你自己的漏洞 [新增漏洞](/vuln/create-vulnerability), 如果你在列表中没发现相同的漏洞\n |在被审核通过后,你将会在漏洞库中发现属于你自己的漏洞报告,并且有一个独一无二的漏洞ID", + "howToMd": "|### 如何添加漏洞报告?\n |1. 创建 saveourtool [组织](/create-organization) 和 [项目](/create-project);\n |2. 到你的项目的安全页面;\n |3. 创建新的问题以及你的漏洞编号;\n |\n |以防有错,请随时 [联系我们](https://github.com/saveourtool/save-cloud/issues/new).", + "topRatingMd": "|### 贡献排名\n |每一个被审核并接受的漏洞都可以获得一定贡献分数. 在此你可以看到个人用户或组织的 [贡献排名](/vuln/top-rating)" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/vulnerability-upload.json b/save-cosv-frontend/src/main/resources/locales/cn/vulnerability-upload.json new file mode 100644 index 0000000000..858c61e8e6 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/vulnerability-upload.json @@ -0,0 +1,4 @@ +{ + "Upload COSV files": "上传 COSV 格式文件", + "Organization that has permission": "只有获取管理员许可后,您才可以批量上传漏洞,如果您所在组织拥有上传权限,请联系saveourtool@gmail.com" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/cn/vulnerability.json b/save-cosv-frontend/src/main/resources/locales/cn/vulnerability.json new file mode 100644 index 0000000000..5605a3c6c8 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/vulnerability.json @@ -0,0 +1,58 @@ +{ + "Delete Vulnerability": "删除漏洞", + "Are you sure you want to remove this vulnerability?": "确定要删除此漏洞报告吗?", + "Ok": "OK", + "Close": "关闭", + "Reject vulnerability": "拒绝此漏洞", + "Ready for review": "可被审查", + "Are you sure you want to reject this vulnerability?": "您确定要拒绝此漏洞吗?", + "Are you sure you want to submit this vulnerability for review once again?": "您确定要再次提交此漏洞进行审核吗?", + "Write a comment": "写一个评论", + "Approved": "已被接受", + "Not Approved": "未被接受", + "Approve": "接受", + "Reject": "拒绝", + "Edit": "编辑", + "Last update time": "上次更新时间", + "Description": "描述", + "CWE Names": "CWE名称", + "CWE IDs": "CWE编号", + "Tags": "标签", + "Related Vulnerabilities": "相关漏洞", + "Confirm type": "确认类型", + "Related link": "相关链接", + "COSV Submitter": "提交者", + "Organization": "组织", + "Collaborators": "合作者", + "Add a new tag...": "添加一个新标签", + "Tag should not have commas, length should be more than 2 and less than 16.": "标签不能有都好,并且应该介于2~16个字符之间", + "Vulnerability identifier": "漏洞ID", + "Date type": "日期类型", + "Date": "日期", + "Add": "添加", + "Cancel": "取消", + "Are you sure you want to delete a date?": "您确定要删除日期吗?", + "Affected libraries": "受影响的三方库", + "Commits with fix": "修复提交", + "Affected open source projects": "受影响的开源项目", + "No version information": "暂无版本信息", + "Versions": "版本列表", + "Name": "名称", + "No information": "无信息", + "Criticality Scoring": "危险性评分", + "Delete project": "删除项目", + "Are you sure you want to delete this project?": "您确定要删除该项目吗?", + "Delete vulnerability": "删除漏洞", + "Download vulnerability in COSV": "下载COSV漏洞", + "Failed to download": "下载失败", + "Commit": "提交", + "Library": "三方库", + "Project": "开源项目", + "Project type": "项目类型", + "Hash": "哈希", + "must not be empty": "不能为空", + "Please input a valid hash and URL": "请输入有效的哈希和URL", + "Please input a valid name and URL": "请输入有效的名称和URL", + "Change to card mode": "改为卡片模式", + "Change to table mode": "改为列表模式" +} diff --git a/save-cosv-frontend/src/main/resources/locales/cn/welcome.json b/save-cosv-frontend/src/main/resources/locales/cn/welcome.json new file mode 100644 index 0000000000..1f8d22672a --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/cn/welcome.json @@ -0,0 +1,27 @@ +{ + "Sign in with": "登录方式", + "Don't have an account?": "没有账户?", + "with limited functionality": "部分功能使用受限", + "Continue": "继续使用", + "Notifications": "通知", + "Total number of submitted vulnerabilities": "公开漏洞总数", + "Vulnerability database": "漏洞库", + "Propose vulnerability": "报告漏洞", + "Top rating": "贡献排名", + "What is vulnerability?": "什么是漏洞?", + "COSV Platform": "COSV Platform", + "Useful links": "有用的链接", + "Vulnerability is a weakness or flaw in a system, network, software, or hardware.": "漏洞是系统、网络、软件或硬件中的弱点或缺陷,未经授权的个人或恶意软件可以利用这些弱点或缺陷来获得未经授权的访问、中断操作或窃取敏感信息。漏洞可能是由编程错误、配置错误、过时的软件或设计缺陷引起的。", + "Archive importance": "本服务可视为1-day漏洞的存储库,可用于公布和审核1-day漏洞。它具有聚合数据库,允许安全专业人员过滤和搜索漏洞。除此之外,我们的平台还提供 API,支持自动化服务使用自动化代码分析工具来披露、上传甚至下载漏洞。此外,我们还为 OSV 架构引入了特定的 COSV 扩展。该扩展向后兼容,并提供对于漏洞识别和修复至关重要的详细信息。本标准由中国计算机学会主导。", + "OSV Schema": "提供人类可读和机器可解释的数据格式。\n\n", + "COSV Schema": "增强开源漏洞描述,\n促进供应链安全和效率的标准化数据格式。\n\n", + "cosv4k": "用于 COSV Schema 的序列化和反序列化的\nKotlin 和 Java 模型。", + "Go to main page": "回到主页", + "Welcome": "欢迎", + "Contests": "竞赛", + "List of Projects": "项目列表", + "Benchmarks Archive": "基准档案", + "Create new organization": "创建新组织", + "Manage organizations": "管理组织", + "New project in organization": "在当前组织下新建项目" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/comments.json b/save-cosv-frontend/src/main/resources/locales/en/comments.json new file mode 100644 index 0000000000..f9abc3cfed --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/comments.json @@ -0,0 +1,7 @@ +{ + "Authorize in order to write comments": "Authorize in order to write comments", + "Unknown": "Unknown", + "Are you sure you want to delete a comment?": "Are you sure you want to delete a comment?", + "Rating": "Rating", + "Write a comment": "Write a comment" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/cookies.json b/save-cosv-frontend/src/main/resources/locales/en/cookies.json new file mode 100644 index 0000000000..7bfd2f2375 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/cookies.json @@ -0,0 +1,9 @@ +{ + "What are cookies": "### What are cookies\n\nThis Cookie Policy explains what cookies are, how we use them, the types of cookies we use (i.e., the information we collect using cookies and how we use that information), and how to manage the cookie settings.\n\nCookies are small text files used to store small pieces of information. They are stored on your device when the website is loaded in your browser. These cookies help us ensure the proper functioning of the website, enhance its security, provide a better user experience, understand how the website performs, analyze what works, and identify areas for improvement.", + "How do we use cookies": "### How do we use cookies\n\nLike most online services, our website utilizes first-party cookies for various purposes. These cookies are primarily essential for the proper functioning of the website and do not collect any of your personally identifiable data.", + "What exactly do we store": "### What exactly do we store\n\n* We store information on your **preferred language**, which expires in **1 year**. This data is required your convenience, it is not passed anywhere.\n\n* If you once accepted cookies on our website, we save that in our cookies (expires in **1 year**).", + "We value your privacy": "### **We value your privacy**\n\nWe use cookies to enhance your browsing experience, serve personalized ads or content, and analyze our traffic. By clicking \"Accept\", you consent to our use of cookies.", + "Accept": "Accept", + "Decline": "Decline", + "Read more": "Read more" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/dates.json b/save-cosv-frontend/src/main/resources/locales/en/dates.json new file mode 100644 index 0000000000..1830df7774 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/dates.json @@ -0,0 +1,11 @@ +{ + "Add date": "Add date", + "Disclosed": "Disclosed", + "Fixed": "Fixed", + "Found": "Found", + "Introduced": "Introduced", + "Modified": "Modified", + "Published": "Published", + "Submitted": "Submitted", + "Withdrawn": "Withdrawn" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/index.json b/save-cosv-frontend/src/main/resources/locales/en/index.json new file mode 100644 index 0000000000..d0ecd65235 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/index.json @@ -0,0 +1,32 @@ +{ + "Cloud Platform for CI and Benchmarking of Code Analyzers": "Cloud Platform for CI and Benchmarking of Code Analyzers", + "Archive of 1-Day Vulnerabilities Aggregated from Various Sources": "Archive of 1-Day Vulnerabilities Aggregated from Various Sources", + "Non-profit Opensource Ecosystem with a focus on finding code bugs": "Non-profit Opensource Ecosystem with a focus on finding code bugs", + "Welcome": "Welcome", + "Registered since": "Registered since", + "Profile Settings": "Profile Settings", + "Vulnerabilities Archive": "Vulnerabilities Archive", + "Save Cloud Platform": "Save Cloud Platform", + "Your organizations": "Your organizations", + "Create": "Create", + "Your statistics": "Your statistics", + "Vulnerabilities": "Vulnerabilities", + "Top rating": "Top rating", + "For the better User Experience we recommend you to register.": "For the better User Experience we recommend you to register or sign into the SaveOurTool platform using one of the supported Oauth Providers. Anyway, you can proceed without registration, but functionality will be limited.", + "The easiest way to start working with our Ecosystem is to create your organization.": "The easiest way to start working with our Ecosystem is to create your organization, invite collaborators and start working with services that you like.", + "Multiple different services": "Multiple different services", + "provides Intelligent Services for developers of code analysis tools. Our two main directions:": "provides Intelligent Services for developers of code analysis tools. Our two main directions:", + "- a platform for a distributed Cloud CI of code analyzers with a special test framework. With SAVE you can:": "- a platform for a distributed Cloud CI of code analyzers with a special test framework. With SAVE you can:", + "quickly establish testing and CI of your analyzer": "|- quickly establish testing and CI of your analyzer; \n |- share your tests with community to compare other tools with your tool;\n |- using SAVE you can even create an online demo for your analyzer and setup it for your community.", + "- a platform for reporting, aggregation and deduplication of 1-day Vulnerabilities.": "- a platform for reporting, aggregation and deduplication of 1-day Vulnerabilities.", + "Also we establish contests in the area of code analysis.": "Also we establish contests in the area of code analysis where you can propose your automated solutions for finding bugs and compete with other projects.", + "We are just a group of several developers working on this community project.": "We are just a group of several developers working on this community project. Our main idea is that we can unify together all routine work that is done in the area of code analysis and help developers of analyzers focus on their primary work: find bugs in code.", + "About Us": "About Us", + "We kindly ask you not to break this service and report any problems that you will find to our Github.": "We kindly ask you not to break this service and report any problems that you will find to our Github.", + "Please also read our": "Please also read our", + "Terms of Usage": "Terms of Usage", + "and": "and", + "Cookie policy": "Cookie policy", + "Your notifications will be located here.": "Your notifications will be located here.", + "Notifications": "Notifications" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/organization.json b/save-cosv-frontend/src/main/resources/locales/en/organization.json new file mode 100644 index 0000000000..9f8a07b72d --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/organization.json @@ -0,0 +1,4 @@ +{ + "No vulnerabilities were found for this organization.": "No vulnerabilities were found for this organization.", + "You can be the first one to create vulnerability.": "You can be the first one to create vulnerability." +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/profile.json b/save-cosv-frontend/src/main/resources/locales/en/profile.json new file mode 100644 index 0000000000..cb8a012380 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/profile.json @@ -0,0 +1,17 @@ +{ + "Add bio and info:": "Add bio and info:", + "Upload or select avatar:": "Upload or select avatar:", + "Save changes": "Save changes", + "400 characters": "400 characters", + "Upload avatar": "Upload avatar", + "profile": "profile", + "Basic Settings": "Basic Settings", + "Profile settings": "Profile settings", + "Login and email": "Login and email", + "Organizations": "Organizations", + "Security Settings": "Security Settings", + "Personal access tokens": "Personal access tokens", + "OAuth accounts": "OAuth accounts", + "Personal Statistics": "Personal Statistics", + "Delete Profile": "Delete Profile" +} diff --git a/save-cosv-frontend/src/main/resources/locales/en/proposing.json b/save-cosv-frontend/src/main/resources/locales/en/proposing.json new file mode 100644 index 0000000000..3038e6a601 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/proposing.json @@ -0,0 +1,26 @@ +{ + "Base Score Calculator": "Base Score Calculator", + "Propose a new vulnerability": "Propose a new vulnerability", + "Vulnerability identifier": "Vulnerability identifier", + "Summary": "Summary", + "Generate identifier": "Generate identifier", + "Details": "Details", + "Identifier": "Identifier", + "Related link": "Related link", + "Severity score vector": "Severity score vector", + "Organization": "Organization", + "Collaborators": "Collaborators", + "Propose a vulnerability": "Propose a vulnerability", + "Project type": "Project type", + "Affected versions": "Affected versions", + "Add": "Add", + "Cancel": "Cancel", + "Project Name": "Project Name", + "Library Name": "Library Name", + "Library Url": "Library Url", + "Commit Url": "Commit Url", + "Commit Hash": "Commit Hash", + "Project Url": "Project Url", + "Name must not be empty": "Name must not be empty", + "Please input a valid name and URL": "Please input a valid name and URL" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/table-headers.json b/save-cosv-frontend/src/main/resources/locales/en/table-headers.json new file mode 100644 index 0000000000..4cd64dd8f4 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/table-headers.json @@ -0,0 +1,13 @@ +{ + "Name": "Name", + "Identifier": "Identifier", + "Organization": "Organization", + "Description": "Description", + "COSV Submitter": "COSV Submitter", + "Criticality": "Criticality", + "Language": "Language", + "Status": "Status", + "Tag": "Tag", + "Version": "Version", + "Updated date": "Updated date" +} diff --git a/save-cosv-frontend/src/main/resources/locales/en/thanks-for-registration.json b/save-cosv-frontend/src/main/resources/locales/en/thanks-for-registration.json new file mode 100644 index 0000000000..7f4925a530 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/thanks-for-registration.json @@ -0,0 +1,5 @@ +{ + "Thank you for registration": "Thank you for registration", + "You need to wait until you will be reviewed and approved by admins": "You need to wait until you will be reviewed and approved by admins", + "Your request is pending review": "Right now the platform is still in development. Our admin team will soon review your request and approve it if everything is fine. Don't hesitate to contact us if you have any question:" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/topbar.json b/save-cosv-frontend/src/main/resources/locales/en/topbar.json new file mode 100644 index 0000000000..46a1f54497 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/topbar.json @@ -0,0 +1,22 @@ +{ + "Main page": "Main page", + "Demo": "Demo", + "CPG": "CPG", + "Awesome Benchmarks": "Awesome Benchmarks", + "Try SAVE format": "Try SAVE format", + "Projects board": "Projects board", + "Contests": "Contests", + "About us": "About us", + "Propose vulnerability": "Propose vulnerability", + "Vulnerabilities": "Vulnerabilities", + "Top Rating": "Top Rating", + "SAVE Projects list": "SAVE Projects list", + "Vulnerabilities list": "Vulnerabilities list", + "Super user": "Super user", + "User settings": "User settings", + "Profile": "Profile", + "Settings": "Settings", + "Manage organizations": "Manage organizations", + "Log out": "Log out", + "Log in": "Log in" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/vulnerability-collection.json b/save-cosv-frontend/src/main/resources/locales/en/vulnerability-collection.json new file mode 100644 index 0000000000..0bf68a5018 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/vulnerability-collection.json @@ -0,0 +1,7 @@ +{ + "Introducing": "Introducing", + "introMd": "|## International Vulnerabilities Archive\n |Current page provides the list of publicly disclosed information security vulnerabilities and exposures.", + "addNewMd": "|### New vulnerability\n |You can propose your own [new vulnerability](/vuln/create-vulnerability), if you didn't find it one in our list.\n |After the review and approval, it will appear in the database under a special identifier.", + "howToMd": "|### How to add vulnerability in project?\n |1. Create saveourtool [organization](/create-organization) and [project](/create-project);\n |2. Go to your project's security tab;\n |3. Create new problem and add vulnerability number;\n |\n |In case of any error feel free to [contact us](https://github.com/saveourtool/save-cloud/issues/new).", + "topRatingMd": "|### Top rating\n |For each approved and accepted vulnerability you will get rating points. Here you can see the [top rating](/vuln/top-rating) of users and organizations." +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/vulnerability-upload.json b/save-cosv-frontend/src/main/resources/locales/en/vulnerability-upload.json new file mode 100644 index 0000000000..7670efb77f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/vulnerability-upload.json @@ -0,0 +1,4 @@ +{ + "Upload COSV files": "Upload COSV files", + "Organization that has permission": "You will only by able to submit if you have an organization that has permission for batch upload. You can request it from administrators: saveourtool@gmail.com" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/en/vulnerability.json b/save-cosv-frontend/src/main/resources/locales/en/vulnerability.json new file mode 100644 index 0000000000..18d116b610 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/vulnerability.json @@ -0,0 +1,59 @@ +{ + "Delete Vulnerability": "Delete Vulnerability", + "Are you sure you want to remove this vulnerability?": "Are you sure you want to delete this vulnerability?", + "Ok": "Оk", + "Close": "Close", + "Reject vulnerability": "Reject vulnerability", + "Ready for review": "Ready for review", + "Are you sure you want to reject this vulnerability?": "Are you sure you want to reject this vulnerability?", + "Are you sure you want to submit this vulnerability for review once again?": "Are you sure you want to submit this vulnerability for review once again?", + "Write a comment": "Write a comment", + "Approved": "Approved", + "Not Approved": "Not Approved", + "Approve": "Approve", + "Reject": "Reject", + "Edit": "Edit", + "Last update time": "Last update time", + "Description": "Description", + "CWE Names": "CWE Names", + "CWE IDs": "CWE IDs", + "Aliases": "Aliases", + "Tags": "Tags", + "Related Vulnerabilities": "Related Vulnerabilities", + "Confirm type": "Confirm type", + "Related link": "Related link", + "COSV Submitter": "COSV Submitter", + "Organization": "Organization", + "Collaborators": "Collaborators", + "Add a new tag...": "Add a new tag...", + "Tag should not have commas, length should be more than 2 and less than 16.": "Tag should not have commas, length should be more than 2 and less than 16.", + "Vulnerability identifier": "Vulnerability identifier", + "Date type": "Date type", + "Date": "Date", + "Add": "Add", + "Cancel": "Cancel", + "Are you sure you want to delete a date?": "Are you sure you want to delete a date?", + "Affected libraries": "Affected libraries", + "Commits with fix": "Commits with fix", + "Affected open source projects": "Affected open source projects", + "No version information": "No version information", + "Versions": "Versions", + "Name": "Name", + "No information": "No information", + "Criticality Scoring": "Criticality Scoring", + "Delete project": "Delete project", + "Are you sure you want to delete this project?": "Are you sure you want to delete this project?", + "Delete vulnerability": "Delete vulnerability", + "Download vulnerability in COSV": "Download vulnerability in COSV", + "Failed to download": "Failed to download", + "Commit": "Commit", + "Library": "Library", + "Project": "Project", + "Project type": "Project type", + "Hash": "Hash", + "must not be empty": "must not be empty", + "Please input a valid hash and URL": "Please input a valid hash and URL", + "Please input a valid name and URL": "Please input a valid name and URL", + "Change to card mode": "Change to card mode", + "Change to table mode": "Change to table mode" +} diff --git a/save-cosv-frontend/src/main/resources/locales/en/welcome.json b/save-cosv-frontend/src/main/resources/locales/en/welcome.json new file mode 100644 index 0000000000..01afbfcee2 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/en/welcome.json @@ -0,0 +1,27 @@ +{ + "Sign in with": "Sign in with", + "Don't have an account?": "Don't have an account?", + "with limited functionality": "with limited functionality", + "Continue": "Continue", + "Notifications": "Notifications", + "Total number of submitted vulnerabilities": "Total number of submitted vulnerabilities", + "Vulnerability database": "Vulnerability database", + "Propose vulnerability": "Propose vulnerability", + "Top rating": "Top rating", + "What is vulnerability?": "What is vulnerability?", + "COSV Platform": "COSV Platform", + "Useful links": "Useful links", + "Vulnerability is a weakness or flaw in a system, network, software, or hardware.": "Vulnerability is a weakness or flaw in a system, network, software, or hardware that can be exploited by unauthorized individuals or malicious software to gain unauthorized access, disrupt operations, or steal sensitive information. Vulnerabilities can arise from programming errors, misconfigurations, outdated software, or design flaws.", + "Archive importance": "This service operates as a centralized repository, crucial for proposing, and reviewing one-day vulnerabilities. It features an aggregated database that allows security professionals to filter and search for vulnerabilities. Beyond this, our platform provides an API, enabling automated services to disclose, upload, and even download vulnerabilities using automated code analysis tools. Additionally, we introduce a specific Cosv extension to the osv schema. This extension is backward-compatible and offers detailed information vital for vulnerability identification and mitigation. This standard is driven by the China Computer Federation.", + "OSV Schema": "offers a data format interpretable by\nhumans and machines.\n\n", + "COSV Schema": "enhances open-source vulnerability\ndescriptions, promotes standardized data sharing for\nsupply chain security, and operational efficiency.\n\n", + "cosv4k": "Kotlin and Java model for the serialization\nand deserialization of COSV Schema.", + "Go to main page": "Go to main page", + "Welcome": "Welcome", + "Contests": "Contests", + "List of Projects": "List of Projects", + "Benchmarks Archive": "Benchmarks Archive", + "Create new organization": "Create new organization", + "Manage organizations": "Manage organizations", + "New project in organization": "New project in organization" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/comments.json b/save-cosv-frontend/src/main/resources/locales/ru/comments.json new file mode 100644 index 0000000000..e7301dd758 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/comments.json @@ -0,0 +1,7 @@ +{ + "Authorize in order to write comments": "Авторизируйтесь, чтобы отправлять комментарии", + "Unknown": "Неизвестно", + "Are you sure you want to delete a comment?": "Вы уверены, что хотите удалить комментарий?", + "Rating": "Рейтинг", + "Write a comment": "Напишите комментарий" +} diff --git a/save-cosv-frontend/src/main/resources/locales/ru/cookies.json b/save-cosv-frontend/src/main/resources/locales/ru/cookies.json new file mode 100644 index 0000000000..9f7405a133 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/cookies.json @@ -0,0 +1,9 @@ +{ + "What are cookies": "### Что такое Cookie\n\nЭта Политика использования файлов cookie объясняет, что такое файлы cookie и как мы их используем, информацию, которую мы собираем с помощью файлов cookie, и как эта информация используется.\n\nФайлы cookie - это небольшие текстовые файлы, использующиеся для хранения небольших фрагментов информации. Они сохраняются на Вашем устройстве, когда веб-сайт загружается в браузере. Эти файлы cookie помогают правильно функционировать веб-сайту, обеспечивать его безопасность, повышать качество сервиса.", + "How do we use cookies": "### Как мы используем Cookie\n\nКак и большинство онлайн-сервисов, наш веб-сайт использует файлы cookie для правильного функционирования веб-сайта, они собирают никакие персональные данные, идентифицирующие Вас.", + "What exactly do we store": "### Что конкретно мы храним\n\n* Мы храним **предпочитаемый язык**, cookie-файлы с которым устаревают через **1 год**. Эта информация необходима исключительно для Вашего удобства, она никуда не передается.\n\n* Если вы согласились с данной Политикой использования файлов cookie, мы сохраним это в cookie-файле (устаревает через **1 год**).", + "We value your privacy": "### **Мы уважаем Вашу конфиденциальность**\n\nМы используем cookie-файлы для Вашего комфорта. Нажимая на кнопку \"Принять\", вы соглашаетесь с нашей Политикой использования cookie-файлов.", + "Accept": "Принять", + "Decline": "Отклонить", + "Read more": "Узнать больше" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/dates.json b/save-cosv-frontend/src/main/resources/locales/ru/dates.json new file mode 100644 index 0000000000..dfdf75f14f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/dates.json @@ -0,0 +1,11 @@ +{ + "Add date": "Добавить дату", + "Disclosed": "Раскрыта", + "Fixed": "Исправлена", + "Found": "Найдена", + "Introduced": "Представлена", + "Modified": "Изменена", + "Published": "Опубликована", + "Submitted": "Отправлена", + "Withdrawn": "Отозвана" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/index.json b/save-cosv-frontend/src/main/resources/locales/ru/index.json new file mode 100644 index 0000000000..d01cf49ec6 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/index.json @@ -0,0 +1,32 @@ +{ + "Cloud Platform for CI and Benchmarking of Code Analyzers": "Облачная платформа для CI и сравнения Анализаторов Кода", + "Archive of 1-Day Vulnerabilities Aggregated from Various Sources": "Архив уязвимостей первого дня, собранный из различных источников", + "Non-profit Opensource Ecosystem with a focus on finding code bugs": "Некоммерческая Opensource-экосистема, сфокусированная на поиске багов в коде", + "Welcome": "Добро пожаловать", + "Registered since": "Зарегистрирован с", + "Profile Settings": "Настройки пользователя", + "Vulnerabilities Archive": "Архив уязвимостей", + "Save Cloud Platform": "Платформа Save Cloud", + "Your organizations": "Ваши организации", + "Create": "Создать", + "Your statistics": "Ваша статистика", + "Vulnerabilities": "Уязвимости", + "Top rating": "Рейтинг", + "For the better User Experience we recommend you to register.": "Для повышения качества пользования мы рекомендуем Вам зарегистрироваться или войти в SaveOurTool при помощи одного из поддерживаемого сервиса при помощи Oauth. Вы можете продолжить без регистрации, но функционал будет ограничен.", + "The easiest way to start working with our Ecosystem is to create your organization.": "Самый простой способ начать работать с нашей экосистемой - создать вашу организацию, пригласить единомышленников и начать использовать сервисы, интересующие вас.", + "Multiple different services": "Несколько различных микросервисов", + "provides Intelligent Services for developers of code analysis tools. Our two main directions:": "предоставляют инновационный сервис для разработчиков инструментов анализа кода. Два наших основных направления:", + "- a platform for a distributed Cloud CI of code analyzers with a special test framework. With SAVE you can:": "- платформа распределенного облачного CI анализаторов кода на базе особого тестового фреймворка SAVE. При помощи SAVE вы можете:", + "quickly establish testing and CI of your analyzer": "|- легко и быстро организовать тестирование и CI вашего анализатора; \n |- поделиться Вашими тестами с сообществом чтобы сравнивать инструменты других разработчиков с Вашим;\n |- при помощи SAVE вы можете создать онлайн демо вашего анализатора и настроить его для сообщества.", + "- a platform for reporting, aggregation and deduplication of 1-day Vulnerabilities.": "- платформа репортинга, агрегации и дедубликации уязвимостей первого дня.", + "Also we establish contests in the area of code analysis.": "Мы также проводим соревнования в сфере анализа кода, в которых Вы можете поучаствовать, отправив выши автоматизированные решения поиска багов и превзойти проекты других разработчиков.", + "We are just a group of several developers working on this community project.": "Мы - группа разработчиков, работающих над этим проектом сообщества. Наша основная идея - объединить всю рутинную работу в сфере анализа кода и помочь разработчикам анализаторов сфокусироваться на их основной работе - поиску багов в коде.", + "About Us": "О нас", + "We kindly ask you not to break this service and report any problems that you will find to our Github.": "Мы убедительно просим Вас не ломать сервис и уведомлять нас на GitHub о любых найденных ошибках.", + "Please also read our": "Пожалуйста ознакомьтесь с", + "Terms of Usage": "Правилами сервиса", + "and": "и", + "Cookie policy": "Политикой Cookie", + "Notifications": "Уведомления", + "Your notifications will be located here.": "Ваши уведомления будут отображаться здесь." +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/organization.json b/save-cosv-frontend/src/main/resources/locales/ru/organization.json new file mode 100644 index 0000000000..cfddce20a5 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/organization.json @@ -0,0 +1,4 @@ +{ + "No vulnerabilities were found for this organization.": "Данная организация не предлагала уязвимости.", + "You can be the first one to create vulnerability.": "Вы можете быть первым, кто предложил уязвимость." +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/profile.json b/save-cosv-frontend/src/main/resources/locales/ru/profile.json new file mode 100644 index 0000000000..0aea3f6f5f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/profile.json @@ -0,0 +1,20 @@ +{ + "Add bio and info:": "Биография", + "Upload or select avatar:": "Загрузите или выберите аватар", + "Save changes": "Сохранить", + "400 characters": "400 символов", + "Upload avatar": "Загрузите аватар", + "profile": "Профиль", + "Basic Settings": "Базовые настройки", + "Profile settings": "Настройки профиля", + "Your name": "Ваше имя", + "Login and email": "Логин и e-mail", + "Organizations": "Организации", + "Security Settings": "Настройки безопасности", + "Personal access tokens": "Личный токен", + "OAuth accounts": "Oauth настройки", + "Personal Statistics": "Личная статистика", + "Delete Profile": "Удалить профиль", + "Company/Affiliation": "Компания", + "Website": "Сайт" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/proposing.json b/save-cosv-frontend/src/main/resources/locales/ru/proposing.json new file mode 100644 index 0000000000..840f2d88c3 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/proposing.json @@ -0,0 +1,26 @@ +{ + "Base Score Calculator": "Калькулятор базовой оценки", + "Propose a new vulnerability": "Предложить уязвимость", + "Vulnerability identifier": "Идентификатор уязвимости", + "Summary": "Краткое описание", + "Generate identifier": "Сгенерировать идентификатор", + "Details": "Подробности", + "Identifier": "Идентификатор", + "Related link": "Ссылка", + "Severity score vector": "Вектор оценки уязвимости", + "Organization": "Организация", + "Collaborators": "Соавторы", + "Propose a vulnerability": "Предложить уязвимость", + "Project type": "Тип проекта", + "Affected versions": "Затронутые версии", + "Add": "Добавить", + "Cancel": "Отменить", + "Project Name": "Имя проекта", + "Library Name": "Имя библиотеки", + "Library Url": "URL библиотеки", + "Commit Url": "URL коммита", + "Commit Hash": "Хэш коммита", + "Project Url": "URL проекта", + "Name must not be empty": "Имя должно быть непустым", + "Please input a valid name and URL": "Пожалуйста введите корректное имя и URL" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/table-headers.json b/save-cosv-frontend/src/main/resources/locales/ru/table-headers.json new file mode 100644 index 0000000000..ca32cd024b --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/table-headers.json @@ -0,0 +1,13 @@ +{ + "Name": "Имя", + "Identifier": "Идентификатор", + "Organization": "Организация", + "Description": "Описание", + "COSV Submitter": "Автор отчёта в COSV", + "Criticality": "Критичность", + "Language": "Язык", + "Status": "Статус", + "Tag": "Тег", + "Version": "Версия", + "Updated date": "Дата обновления" +} diff --git a/save-cosv-frontend/src/main/resources/locales/ru/thanks-for-registration.json b/save-cosv-frontend/src/main/resources/locales/ru/thanks-for-registration.json new file mode 100644 index 0000000000..01137839c8 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/thanks-for-registration.json @@ -0,0 +1,5 @@ +{ + "Thank you for registration": "Спасибо за регистрацию!", + "You need to wait until you will be reviewed and approved by admins": "Вам необходимо подождать пока ваш аккаунт будет проверен и одобрен администратором", + "Your request is pending review": "SaveOurTool все еще находится на этапе разработки. Наша команда админов в скором времени рассмотрит Вашу заявку и одобрит ее в случае если все в порядке. Не стесняйтесь обращаться к нам по всем вопросам:" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/topbar.json b/save-cosv-frontend/src/main/resources/locales/ru/topbar.json new file mode 100644 index 0000000000..d506891b8e --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/topbar.json @@ -0,0 +1,22 @@ +{ + "Main page": "Главная страница", + "Demo": "Демо", + "CPG": "CPG", + "Awesome Benchmarks": "Awesome Benchmarks", + "Try SAVE format": "Попробуйте SAVE-формат", + "Projects board": "Проекты", + "Contests": "Соревнования", + "About us": "О нас", + "Propose vulnerability": "Предложить Уязвимость", + "Vulnerabilities": "Архив Уязвимостей", + "Top Rating": "Рейтинг", + "SAVE Projects list": "SAVE-проекты", + "Vulnerabilities list": "Список Уязвимостей", + "Super user": "Администратор", + "User settings": "Настройки", + "Profile": "Профиль", + "Settings": "Настройки", + "Manage organizations": "Управлять организациями", + "Log out": "Выйти из аккаунта", + "Log in": "Войти в аккаунт" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/vulnerability-collection.json b/save-cosv-frontend/src/main/resources/locales/ru/vulnerability-collection.json new file mode 100644 index 0000000000..6d9c8d0c27 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/vulnerability-collection.json @@ -0,0 +1,7 @@ +{ + "Introducing": "Представляем", + "introMd": "|## Международный Архив Уязвимостей\n |Данная страница предоставляет список публично раскрытых уязвимостей и инцидентов в области информационной безопасности.", + "addNewMd": "|### Новые уязвимости\n |Вы можете предложить свою [новую уязвимость](/vuln/create-vulnerability), если ее нет в нашем списке.\n |После подтверждения со стороны администрации, она появится в архиве под уникальным ID.", + "howToMd": "|### Как добавить уязвимость в проект?\n |1. Создайте saveourtool [организацию](/create-organization) и [проект](/create-project);\n |2. Откройте вкладку security вашего проекта;\n |3. Создайте новую проблему и добавьте ее уникальный ID;\n |\n |В случае любых ошибок не стесняйтесь [связываться с нами](https://github.com/saveourtool/save-cloud/issues/new).", + "topRatingMd": "|### Рейтинговая система\n |За каждую принятую уязвимость вы получаете очки рейтинга. Здесь вы можете ознакомиться с [рейтингом](/vuln/top-rating) пользователей и организаций." +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/vulnerability-upload.json b/save-cosv-frontend/src/main/resources/locales/ru/vulnerability-upload.json new file mode 100644 index 0000000000..a19a9e0cc5 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/vulnerability-upload.json @@ -0,0 +1,4 @@ +{ + "Upload COSV files": "Загрузить COSV файлы", + "Organization that has permission": "Вы сможете загружать файлы только в том случае, если у вас есть организация, имеющая разрешение на пакетную загрузку. Запросить его можно у администраторов: saveourtool@gmail.com." +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/locales/ru/vulnerability.json b/save-cosv-frontend/src/main/resources/locales/ru/vulnerability.json new file mode 100644 index 0000000000..ca50188f46 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/vulnerability.json @@ -0,0 +1,59 @@ +{ + "Delete Vulnerability": "Удаление Уязвимости", + "Are you sure you want to remove this vulnerability?": "Вы уверены, что хотите удалить эту Уязвимость?", + "Ok": "Ок", + "Close": "Закрыть", + "Reject vulnerability": "Отклонить уязвимость", + "Ready for review": "Готово к ревью", + "Are you sure you want to reject this vulnerability?": "Вы уверены, что хотите отклонить эту Уязвимость?", + "Are you sure you want to submit this vulnerability for review once again?": "Вы уверены, что хотите еще раз отправить эту Уязвимость на ревью?", + "Write a comment": "Напишите комментарий", + "Approved": "Принято", + "Not Approved": "Не принято", + "Approve": "Принять", + "Reject": "Отклонить", + "Edit": "Изменить", + "Last update time": "Изменено", + "Details": "Описание", + "CWE Names": "CWE Имена", + "CWE IDs": "CWE Идентификаторы", + "Aliases": "Псевдонимы", + "Related Vulnerabilities": "Связанные уязвимости", + "Confirm type": "Тип подтверждения записи об уязвимости", + "Tags": "Теги", + "References": "Связанная ссылка", + "COSV Submitter": "Автор отчёта в COSV", + "Organization": "Организация", + "Credits": "Соавторы", + "Add a new tag...": "Добавить тег...", + "Tag should not have commas, length should be more than 2 and less than 16.": "В названии тега не должно быть запятых, длина тега должна быть больше 2 и меньше 16 символов.", + "Vulnerability identifier": "Идентификатор Уязвимости", + "Date type": "Тип даты", + "Date": "Дата", + "Add": "Добавить", + "Cancel": "Отменить", + "Are you sure you want to delete a date?": "Вы уверены, что хотите удалить дату?", + "Affected libraries": "Затронутые библиотеки", + "Commits with fix": "Коммиты с исправлениями", + "Affected open source projects": "Затронутые opensource проекты", + "No version information": "Нет информации о версиях", + "Versions": "Версии", + "Name": "Название", + "No information": "Нет информации", + "Criticality Scoring": "Критичность", + "Delete project": "Удалить проект", + "Are you sure you want to remove this project?": "Вы уверены, что хотите удалить этот проект?", + "Delete vulnerability": "Удалить Уязвимость", + "Download vulnerability in COSV": "Скачать уязвимость в COSV", + "Failed to download": "Не удалось скачать", + "Commit": "Коммит", + "Library": "Библиотека", + "Project": "Проект", + "Project type": "Тип проекта", + "Hash": "Hash", + "must not be empty": "должен быть непустым", + "Please input a valid hash and URL": "Пожалуйста, введите корректный хэш и URL", + "Please input a valid name and URL": "Пожалуйста, введите корректное название и URL", + "Change to card mode": "Отобразить в виде карточек", + "Change to table mode": "Отобразить в виде таблиц" +} diff --git a/save-cosv-frontend/src/main/resources/locales/ru/welcome.json b/save-cosv-frontend/src/main/resources/locales/ru/welcome.json new file mode 100644 index 0000000000..949397d59f --- /dev/null +++ b/save-cosv-frontend/src/main/resources/locales/ru/welcome.json @@ -0,0 +1,27 @@ +{ + "Sign in with": "Войдите через", + "Don't have an account?": "Нет аккаунта?", + "with limited functionality": "с ограниченной функциональностью", + "Continue": "Продолжить", + "Notifications": "Уведомления", + "Total number of submitted vulnerabilities": "Количество заявленных Уязвимостей", + "Vulnerability database": "Архив Уязвимостей", + "Propose vulnerability": "Предложить Уязвимость", + "Top rating": "Рейтинг", + "What is vulnerability?": "Что такое Уязвимость?", + "COSV Platform": "Архив COSV", + "Useful links": "Полезные ссылки", + "Vulnerability is a weakness or flaw in a system, network, software, or hardware.": "Уязвимость — это слабое место или дефект в системе, сети, программном обеспечении или аппаратном обеспечении, который может быть использован третьими лицами или вредоносным программным обеспечением для получения несанкционированного доступа, нарушения работы или кражи конфиденциальной информации. Уязвимости могут возникать из-за программных ошибок, неправильной конфигурации, устаревшего программного обеспечения или конструктивных недостатков.", + "Archive importance": "Архив уязвимостей жизненно важен как централизованное хранилище документированных уязвимостей. Он предоставляет ценные данные для специалистов в области безопасности, способствует прогнозированию рисков и позволяет своевременно определять и устранять уязвимости. Также он расширяет понимание тенденций, моделей и типичных уязвимостей, повышая сохранность от будущих угроз.", + "OSV Schema": "предлагает формат данных, интерпретируемый\nкак людьми, так и машинами.\n\n", + "COSV Schema": "улучшает систему описания уязвимостей\nс открытым исходным кодом, способствует\nстандартизированному обмену данными для безопасности\nцепочки поставок и операционной эффективности.\n\n", + "cosv4k": "Kotlin и Java модель для сериализации и\nдесериализации COSV-схемы.", + "Go to main page": "Вернуться на главную страницу", + "Welcome": "Добро пожаловать", + "Contests": "Соревнования", + "List of Projects": "Список Проектов", + "Benchmarks Archive": "Архив Бенчмарков", + "Create new organization": "Создать Организацию", + "Manage organizations": "Управлять Организациями", + "New project in organization": "Новый Проект в Организации" +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/particles.json b/save-cosv-frontend/src/main/resources/particles.json new file mode 100644 index 0000000000..acace2c350 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/particles.json @@ -0,0 +1,101 @@ +{ + "fullScreen": { + "enable": false, + "zIndex": 0 + }, + "fpsLimit": 120, + "particles": { + "number": { + "value": 70, + "density": { + "enable": true, + "value_area": 700 + } + }, + "color": { + "value": "#ffffff" + }, + "shape": { + "type": "circle" + }, + "opacity": { + "value": 0.1, + "random": true, + "anim": { + "enable": true, + "speed": 3, + "opacity_min": 0.1, + "sync": false + } + }, + "size": { + "value": 10, + "random": true, + "anim": { + "enable": true, + "speed": 20, + "size_min": 0.1, + "sync": false + } + }, + "line_linked": { + "enable": true, + "distance": 150, + "color": "#ffffff", + "opacity": 0.3, + "width": 1 + }, + "move": { + "enable": true, + "speed": 1.5, + "direction": "none", + "random": false, + "straight": false, + "out_mode": "out", + "attract": { + "enable": false, + "rotateX": 600, + "rotateY": 1200 + } + } + }, + "interactivity": { + "events": { + "onhover": { + "enable": false + }, + "onclick": { + "enable": false + }, + "resize": true + }, + "modes": { + "grab": { + "distance": 400, + "line_linked": { + "opacity": 0.6 + } + }, + "bubble": { + "distance": 400, + "size": 40, + "duration": 2, + "opacity": 0.1, + "speed": 3 + }, + "repulse": { + "distance": 200 + }, + "push": { + "particles_nb": 4 + }, + "remove": { + "particles_nb": 2 + } + } + }, + "retina_detect": true, + "background": { + "opacity": 1 + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/_buttons.scss b/save-cosv-frontend/src/main/resources/scss/_buttons.scss new file mode 100644 index 0000000000..cfbf7c5bb5 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_buttons.scss @@ -0,0 +1,124 @@ +// solution from https://stackoverflow.com/questions/2812770/add-centered-text-to-the-middle-of-a-horizontal-rule +// ======= separator ======== +.separator { + display: flex; + align-items: center; + text-align: center; +} + +.separator::before, +.separator::after { + content: ''; + flex: 1; + border-bottom: 0.07rem solid #000000; +} + +.separator:not(:empty)::before { + margin-right: .4em; +} + +.separator:not(:empty)::after { + margin-left: .4em; +} + +// ======= buttons ======== + +.input-group { + z-index: 0 +} + +.btn-circle { + border-radius: 100%; + height: 2.5rem; + width: 2.5rem; + font-size: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + &.btn-sm { + height: 1.8rem; + width: 1.8rem; + font-size: 0.75rem; + } + &.btn-lg { + height: 3.5rem; + width: 3.5rem; + font-size: 1.35rem; + } +} + +.btn-icon-split { + padding: 0; + overflow: hidden; + display: inline-flex; + align-items: stretch; + justify-content: center; + .icon { + background: fade-out($black, .85); + display: inline-block; + padding: $btn-padding-y $btn-padding-x; + } + .text { + display: inline-block; + padding: $btn-padding-y $btn-padding-x; + } + &.btn-sm { + .icon { + padding: $btn-padding-y-sm $btn-padding-x-sm; + } + .text { + padding: $btn-padding-y-sm $btn-padding-x-sm; + } + } + &.btn-lg { + .icon { + padding: $btn-padding-y-lg $btn-padding-x-lg; + } + .text { + padding: $btn-padding-y-lg $btn-padding-x-lg; + } + } +} + +.btn.btn-no-outline { + :focus,:active { + outline: none !important; + box-shadow: none; + } +} + +#main-body.vuln { + .btn-primary, + .btn-outline-primary { + color: #fff; + background-color: $vuln-primary; + border-color: $vuln-primary; + &:hover, + &:focus, + &:active { + background-color: darken($vuln-primary, 10%); + border-color: darken($vuln-primary, 15%); + } + } + + .btn-outline-primary { + background-color: transparent; + color: $vuln-primary; + border-color: $vuln-primary; + &:hover, + &:focus, + &:active { + color: #fff; + } + &.disabled, &:disabled { + color: $vuln-primary; + background-color: transparent; + &:hover, + &:focus, + &:active { + color: $vuln-primary; + background-color: transparent; + } + } + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/_cards.scss b/save-cosv-frontend/src/main/resources/scss/_cards.scss new file mode 100644 index 0000000000..1116cfed70 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_cards.scss @@ -0,0 +1,72 @@ +.card { + .card-header { + // Format Dropdowns in Card Headings + .dropdown { + line-height: 1; + .dropdown-menu { + line-height: 1.5; + } + } + } + // Collapsable Card Styling + .card-header[data-toggle="collapse"] { + text-decoration: none; + position: relative; + padding: 0.75rem 3.25rem 0.75rem 1.25rem; + &::after { + position: absolute; + right: 0; + top: 0; + padding-right: 1.725rem; + line-height: 51px; + font-weight: 900; + content: '\f107'; + font-family: 'Font Awesome 5 Free'; + color: $gray-400; + } + &.collapsed { + border-radius: $card-border-radius; + &::after { + content: '\f105'; + } + } + } +} + +.vulnerability-placeholder svg { + transition: 0.4s; +} + +.vulnerability-placeholder:hover { + border: #5711d9; + border-style: solid; +} + +.vulnerability-placeholder:hover svg { + transition: 0.4s; + transform: scale(1.4); + color: #5711d9; +} + + +// ============ sticky (fixed to scroll) cards ======== +.sidebar-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.make-me-sticky { + position: -webkit-sticky; + position: sticky; + top: 0; + + padding: 0 15px; +} + +// ============= card ====================== +.icon-2-5rem { + width: 2.5rem; +} diff --git a/save-cosv-frontend/src/main/resources/scss/_charts.scss b/save-cosv-frontend/src/main/resources/scss/_charts.scss new file mode 100644 index 0000000000..81248146d6 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_charts.scss @@ -0,0 +1,29 @@ +// Area Chart +.chart-area { + position: relative; + height: 10rem; + width: 100%; + @include media-breakpoint-up(md) { + height: 20rem; + } +} + +// Bar Chart +.chart-bar { + position: relative; + height: 10rem; + width: 100%; + @include media-breakpoint-up(md) { + height: 20rem; + } +} + +// Pie Chart +.chart-pie { + position: relative; + height: 15rem; + width: 100%; + @include media-breakpoint-up(md) { + height: calc(20rem - 43px) !important; + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/_dropdowns.scss b/save-cosv-frontend/src/main/resources/scss/_dropdowns.scss new file mode 100644 index 0000000000..de455c54b3 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_dropdowns.scss @@ -0,0 +1,53 @@ +// Custom Dropdown Styling + +.dropdown { + .dropdown-menu { + font-size: $dropdown-font-size; + .dropdown-header { + @extend .text-uppercase; + font-weight: 800; + font-size: 0.65rem; + color: $gray-500; + } + } +} + +// Utility class to hide arrow from dropdown + +.dropdown.no-arrow { + .dropdown-toggle::after { + display: none; + } +} + +/** + * Someone might need it. Usage: + * div { + * className = ClassName("expandable") + * +"Text" + * div { + * className = ClassName("expand") + * +"CONTENT" + * } + */ +.expandable { + align-self: center; + .expand { + display: none; + height: 0; + width: 0; + } + + &:hover { + .expand { + display: block; + position: absolute; + z-index: 50; + height: auto; + width: auto; + animation: growOut 300ms ease-in-out forwards; + transform-origin: top center; + } + } +} + diff --git a/save-cosv-frontend/src/main/resources/scss/_error.scss b/save-cosv-frontend/src/main/resources/scss/_error.scss new file mode 100644 index 0000000000..66cbedc69c --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_error.scss @@ -0,0 +1,52 @@ +// Lucas Bebber's Glitch Effect +// Tutorial and CSS from CSS Tricks +// https://css-tricks.com/glitch-effect-text-images-svg/ + +.error { + color: $gray-800; + font-size: 7rem; + position: relative; + line-height: 1; + width: 12.5rem; +} +@keyframes noise-anim { + $steps: 20; + @for $i from 0 through $steps { + #{percentage($i*(1/$steps))} { + clip: rect(random(100)+px,9999px,random(100)+px,0); + } + } +} +.error:after { + content: attr(data-text); + position: absolute; + left: 2px; + text-shadow: -1px 0 $red; + top: 0; + color: $gray-800; + background: $gray-100; + overflow: hidden; + clip: rect(0,900px,0,0); + animation: noise-anim 2s infinite linear alternate-reverse; +} + +@keyframes noise-anim-2 { + $steps: 20; + @for $i from 0 through $steps { + #{percentage($i*(1/$steps))} { + clip: rect(random(100)+px,9999px,random(100)+px,0); + } + } +} +.error:before { + content: attr(data-text); + position: absolute; + left: -2px; + text-shadow: 1px 0 $blue; + top: 0; + color: $gray-800; + background: $gray-100; + overflow: hidden; + clip: rect(0,900px,0,0); + animation: noise-anim-2 3s infinite linear alternate-reverse; +} diff --git a/save-cosv-frontend/src/main/resources/scss/_footer.scss b/save-cosv-frontend/src/main/resources/scss/_footer.scss new file mode 100644 index 0000000000..99d9dcf28d --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_footer.scss @@ -0,0 +1,14 @@ +footer.sticky-footer { + padding: 2rem 0; + flex-shrink: 0; + .copyright { + line-height: 1; + font-size: 0.8rem; + } +} + +body.sidebar-toggled { + footer.sticky-footer { + width: 100%; + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/_global.scss b/save-cosv-frontend/src/main/resources/scss/_global.scss new file mode 100644 index 0000000000..fc3cbadffd --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_global.scss @@ -0,0 +1,64 @@ +// Global component styles + +html { + position: relative; + min-height: 100%; +} + +body { + overflow-y: scroll; + height: 100%; +} + +a { + &:focus { + outline: none; + } +} + +// Main page wrapper +#wrapper { + display: flex; + #content-wrapper { + background-color: $gray-100; + width: 100%; + overflow-x: hidden; + #content { + flex: 1 0 auto; + } + } +} + +#sticky-sidebar { + position:fixed; +} + +// Set container padding to match gutter width instead of default 15px +.container, +.container-fluid { + padding-left: $grid-gutter-width; + padding-right: $grid-gutter-width; +} + +// Scroll to top button +.scroll-to-top { + position: fixed; + right: 1rem; + bottom: 1rem; + width: 2.75rem; + height: 2.75rem; + text-align: center; + color: $white; + background: fade-out($gray-800, .5); + line-height: 46px; + &:focus, + &:hover { + color: white; + } + &:hover { + background: $gray-800; + } + i { + font-weight: 800; + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/_glow.scss b/save-cosv-frontend/src/main/resources/scss/_glow.scss new file mode 100644 index 0000000000..67c4355789 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_glow.scss @@ -0,0 +1,262 @@ +@import url("https://fonts.googleapis.com/css?family=Raleway"); + +:root { + --glow-color: hsl(186 100% 69%); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +.glowing-btn { + position: relative; + color: var(--glow-color); + cursor: pointer; + padding: 0.35em 1em; + border: 0.1em solid var(--glow-color); + border-radius: 0.45em; + background: none; + perspective: 2em; + font-family: "Raleway", sans-serif; + font-size: 1.5em; + font-weight: 900; + letter-spacing: 1em; + + -webkit-box-shadow: inset 0px 0px 0.5em 0px var(--glow-color), + 0px 0px 0.5em 0px var(--glow-color); + -moz-box-shadow: inset 0px 0px 0.5em 0px var(--glow-color), + 0px 0px 0.5em 0px var(--glow-color); + box-shadow: inset 0px 0px 0.5em 0px var(--glow-color), + 0px 0px 0.5em 0px var(--glow-color); +} + +.glowing-txt { + float: left; + margin-right: -0.8em; + -webkit-text-shadow: 0 0 0.125em hsl(0 0% 100% / 0.3), + 0 0 0.45em var(--glow-color); + -moz-text-shadow: 0 0 0.125em hsl(0 0% 100% / 0.3), + 0 0 0.45em var(--glow-color); + text-shadow: 0 0 0.125em hsl(0 0% 100% / 0.3), 0 0 0.45em var(--glow-color); + animation: text-flicker 6s linear infinite; +} + +.faulty-letter { + opacity: 0.5; + animation: faulty-flicker 5s linear infinite; +} + +.glowing-btn::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + opacity: 0.7; + filter: blur(1em); + transform: translateY(120%) rotateX(95deg) scale(1, 0.35); + background: var(--glow-color); + pointer-events: none; +} + +.glowing-btn::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; + z-index: -1; + background-color: var(--glow-color); + box-shadow: 0 0 2em 0.2em var(--glow-color); + transition: opacity 100ms linear; +} + +.logo-main:hover .glowing-btn { + color: rgba(0, 0, 0, 0.8); + text-shadow: none; + animation: none; +} + +.logo-main:hover .glowing-txt { + animation: none; +} + +.logo-main:hover .faulty-letter { + animation: none; + text-shadow: none; + opacity: 1; +} + +.logo-main:hover .glowing-btn:before{ + filter: blur(1.5em); + opacity: 1; +} + +.logo-main:hover .glowing-btn:after { + opacity: 1; +} + +@keyframes faulty-flicker { + 0% { + opacity: 0.1; + } + 2% { + opacity: 0.1; + } + 4% { + opacity: 0.5; + } + 19% { + opacity: 0.5; + } + 21% { + opacity: 0.1; + } + 23% { + opacity: 1; + } + 80% { + opacity: 0.5; + } + 83% { + opacity: 0.4; + } + + 87% { + opacity: 1; + } +} + +@keyframes text-flicker { + 0% { + opacity: 0.1; + } + + 2% { + opacity: 1; + } + + 8% { + opacity: 0.1; + } + + 9% { + opacity: 1; + } + + 12% { + opacity: 0.1; + } + 20% { + opacity: 1; + } + 25% { + opacity: 0.3; + } + 30% { + opacity: 1; + } + + 70% { + opacity: 0.7; + } + 72% { + opacity: 0.2; + } + + 77% { + opacity: 0.9; + } + 100% { + opacity: 0.9; + } +} + +@media only screen and (max-width: 600px) { + .glowing-btn{ + font-size: 1em; + } +} + +// ============= this is a greyscale effect for pictures +a:hover { + text-decoration: none; +} + +.logo-main img { + -webkit-filter: brightness(90%); + transition: 0.7s +} + +.logo-main:hover img { + -webkit-filter: brightness(160%); + transition: 0.7s +} + +.logo-main h4 { + opacity: 0.5; + transition: 0.7s +} + +.logo-main:hover h4 { + opacity: 1; + transition: 0.7s +} + +// ======= oauth providers ====== +.animated-provider svg { + transition: 0.4s; + color: white; +} + +.animated-provider img { + transition: 0.4s; + color: white; +} + +.animated-provider .text { + color: gray; + font-weight: 600; + transition: 0.4s; +} + +.animated-provider:hover svg { + transition: 0.4s; + transform: scale(1.1); +} + +.animated-provider:hover img { + transition: 0.4s; + transform: scale(1.1); +} + +.animated-provider:hover .text { + transition: 0.4s; + transform: scale(1.3); + color: var(--glow-color); + -webkit-text-shadow: 0 0 0.125em hsl(0 0% 100% / 0.3), + 0 0 0.45em var(--glow-color); + -moz-text-shadow: 0 0 0.125em hsl(0 0% 100% / 0.3), + 0 0 0.45em var(--glow-color); + text-shadow: 0 0 0.125em hsl(0 0% 100% / 0.3), 0 0 0.45em var(--glow-color); +} + +// ============= card button border change ============== +.button_animated_card { + transition: 0.4s; + border: 0.2rem solid; + border-color: #0d6efd; + color: #0d6efd; +} + +.button_animated_card:hover { + transition: 0.4s; + border: 0.2rem solid; + border-color: #102A71; + color: #102A71; +} diff --git a/save-cosv-frontend/src/main/resources/scss/_login.scss b/save-cosv-frontend/src/main/resources/scss/_login.scss new file mode 100644 index 0000000000..572c167e92 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_login.scss @@ -0,0 +1,36 @@ + +.bg-primary { + color: #fff; + background-color: #53669c; + border-color: #53669c; +} + +form.user { + + .custom-checkbox.small { + label { + line-height: 1.5rem; + } + } + + .form-control-user { + font-size: 0.8rem; + border-radius: 10rem; + padding: 1.5rem 1rem; + } + + .btn-user { + font-size: 0.8rem; + border-radius: 10rem; + padding: 0.75rem 1rem; + } + +} + +.btn-google { + @include button-variant($brand-google, $white); +} + +.btn-facebook { + @include button-variant($brand-facebook, $white); +} diff --git a/save-cosv-frontend/src/main/resources/scss/_mixins.scss b/save-cosv-frontend/src/main/resources/scss/_mixins.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_mixins.scss @@ -0,0 +1 @@ + diff --git a/save-cosv-frontend/src/main/resources/scss/_navs.scss b/save-cosv-frontend/src/main/resources/scss/_navs.scss new file mode 100644 index 0000000000..af517b5199 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_navs.scss @@ -0,0 +1,3 @@ +@import "navs/global.scss"; +@import "navs/topbar.scss"; +@import "navs/sidebar.scss"; diff --git a/save-cosv-frontend/src/main/resources/scss/_root.scss b/save-cosv-frontend/src/main/resources/scss/_root.scss new file mode 100644 index 0000000000..4297ec8e46 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_root.scss @@ -0,0 +1,4 @@ +:root { + font-size: 0.81vw; + font-weight: 900 +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/scss/_team.scss b/save-cosv-frontend/src/main/resources/scss/_team.scss new file mode 100644 index 0000000000..037b08fd13 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_team.scss @@ -0,0 +1,204 @@ +.profile-page .profile-header { + box-shadow: 0 0 10px 0 rgba(183, 192, 206, 0.2); + border: 1px solid #f2f4f9; +} + +.profile-page .profile-header .cover { + position: relative; + border-radius: .25rem .25rem 0 0; +} + +.profile-page .profile-header .cover figure { + margin-bottom: 0; +} + +@media (max-width: 767px) { + .profile-page .profile-header .cover figure { + height: 110px; + overflow: hidden; + } +} + +@media (min-width: 2400px) { + .profile-page .profile-header .cover figure { + height: 280px; + overflow: hidden; + } +} + +.profile-page .profile-header .cover figure img { + border-radius: .25rem .25rem 0 0; + width: 100%; +} + +@media (max-width: 767px) { + .profile-page .profile-header .cover figure img { + -webkit-transform: scale(2); + transform: scale(2); + margin-top: 15px; + } +} + +@media (min-width: 2400px) { + .profile-page .profile-header .cover figure img { + margin-top: -55px; + } +} + +.profile-page .profile-header .cover .gray-shade { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + background: linear-gradient(rgba(255, 255, 255, 0.1), #fff 99%); +} + +.profile-page .profile-header .cover .cover-body { + position: absolute; + bottom: -20px; + left: 0; + z-index: 2; + width: 100%; + padding: 0 20px; +} + +.profile-page .profile-header .cover .cover-body .profile-pic { + border-radius: 50%; + width: 100px; +} + +@media (max-width: 767px) { + .profile-page .profile-header .cover .cover-body .profile-pic { + width: 70px; + } +} + +.profile-page .profile-header .cover .cover-body .profile-name { + font-size: 20px; + font-weight: 600; + margin-left: 17px; +} + +.profile-page .profile-header .header-links { + padding: 15px; + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + background: #fff; + border-radius: 0 0 .25rem .25rem; +} + +.profile-page .profile-header .header-links ul { + list-style-type: none; + margin: 0; + padding: 0; +} + +.profile-page .profile-header .header-links ul li a { + color: #000; + -webkit-transition: all .2s ease; + transition: all .2s ease; +} + +.profile-page .profile-header .header-links ul li:hover, +.profile-page .profile-header .header-links ul li.active { + color: #727cf5; +} + +.profile-page .profile-header .header-links ul li:hover a, +.profile-page .profile-header .header-links ul li.active a { + color: #727cf5; +} + +.profile-page .profile-body .left-wrapper .social-links a { + width: 30px; + height: 30px; +} + +.profile-page .profile-body .right-wrapper .latest-photos > .row { + margin-right: 0; + margin-left: 0; +} + +.profile-page .profile-body .right-wrapper .latest-photos > .row > div { + padding-left: 3px; + padding-right: 3px; +} + +.profile-page .profile-body .right-wrapper .latest-photos > .row > div figure { + -webkit-transition: all .3s ease-in-out; + transition: all .3s ease-in-out; + margin-bottom: 6px; +} + +.profile-page .profile-body .right-wrapper .latest-photos > .row > div figure:hover { + -webkit-transform: scale(1.06); + transform: scale(1.06); +} + +.profile-page .profile-body .right-wrapper .latest-photos > .row > div figure img { + border-radius: .25rem; +} + +.rtl .profile-page .profile-header .cover .cover-body .profile-name { + margin-left: 0; + margin-right: 17px; +} +.img-xs { + width: 37px; + height: 37px; +} +.rounded-circle { + border-radius: 50% !important; +} +img { + vertical-align: middle; + border-style: none; +} + +.card-header:first-child { + border-radius: 0 0 0 0; +} +.card-header { + padding: 0.875rem 1.5rem; + margin-bottom: 0; + background-color: rgba(0, 0, 0, 0); + border-bottom: 1px solid #f2f4f9; +} + +.card-footer:last-child { + border-radius: 0 0 0 0; +} +.card-footer { + padding: 0.875rem 1.5rem; + background-color: rgba(0, 0, 0, 0); + border-top: 1px solid #f2f4f9; +} + +.grid-margin { + margin-bottom: 1rem; +} + +.card { + box-shadow: 0 0 10px 0 rgba(183, 192, 206, 0.2); + -webkit-box-shadow: 0 0 10px 0 rgba(183, 192, 206, 0.2); + -moz-box-shadow: 0 0 10px 0 rgba(183, 192, 206, 0.2); + -ms-box-shadow: 0 0 10px 0 rgba(183, 192, 206, 0.2); +} +.rounded { + border-radius: 0.25rem !important; +} +.card { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + word-wrap: break-word; + background-color: #fff; + background-clip: border-box; + border: 1px solid #f2f4f9; + border-radius: 0.25rem; +} diff --git a/save-cosv-frontend/src/main/resources/scss/_utilities.scss b/save-cosv-frontend/src/main/resources/scss/_utilities.scss new file mode 100644 index 0000000000..160e4dbe37 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_utilities.scss @@ -0,0 +1,11 @@ +@import "utilities/animation.scss"; +@import "utilities/background.scss"; +@import "utilities/display.scss"; +@import "utilities/text.scss"; +@import "utilities/border.scss"; +@import "utilities/progress.scss"; +@import "utilities/rotate.scss"; +@import "utilities/particles.scss"; +@import "utilities/_animated-card.scss"; +@import "utilities/_timeline.scss"; +@import "utilities/_ribbon.scss"; diff --git a/save-cosv-frontend/src/main/resources/scss/_variables.scss b/save-cosv-frontend/src/main/resources/scss/_variables.scss new file mode 100644 index 0000000000..d22edc72e5 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/_variables.scss @@ -0,0 +1,84 @@ +// Override Bootstrap default variables here + +// Color Variables +// Bootstrap Color Overrides + +$white: #fff !default; +$gray-100: #f8f9fc !default; +$gray-200: #eaecf4 !default; +$gray-300: #dddfeb !default; +$gray-400: #d1d3e2 !default; +$gray-500: #b7b9cc !default; +$gray-600: #858796 !default; +$gray-700: #6e707e !default; +$gray-800: #5a5c69 !default; +$gray-900: #3a3b45 !default; +$black: #000 !default; + +$blue: #4e73df !default; +$indigo: #6610f2 !default; +$purple: #6f42c1 !default; +$pink: #e83e8c !default; +$red: #e74a3b !default; +$orange: #fd7e14 !default; +$yellow: #f6c23e !default; +$green: #1cc88a !default; +$teal: #20c9a6 !default; +$cyan: #36b9cc !default; + +// Custom Colors +$brand-google: #ea4335 !default; +$brand-facebook: #3b5998 !default; + +// Set Contrast Threshold +$yiq-contrasted-threshold: 195 !default; + +// Typography +$body-color: $gray-600 !default; + +$font-family-sans-serif: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", 'Noto Color Emoji' !default; + +$font-weight-light: 300 !default; +// $font-weight-base: 400; +$headings-font-weight: 400 !default; + +// Shadows +$box-shadow-sm: 0 0.125rem 0.25rem 0 rgba($gray-900, .2) !default; +$box-shadow: 0 0.15rem 1.75rem 0 rgba($gray-900, .15) !default; +// $box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default; + +// Borders Radius +$border-radius: 0.35rem !default; +$border-color: darken($gray-200, 2%) !default; + +// Spacing Variables +// Change below variable if the height of the navbar changes +$topbar-base-height: 4.375rem !default; +// Change below variable to change the width of the sidenav +$sidebar-base-width: 14rem !default; +// Change below variable to change the width of the sidenav when collapsed +$sidebar-collapsed-width: 6.5rem !default; + +// Card +$card-cap-bg: $gray-100 !default; +$card-border-color: $border-color !default; + +// Adjust column spacing for symmetry +$spacer: 1rem !default; +$grid-gutter-width: $spacer * 1.5 !default; + +// Transitions +$transition-collapse: height .15s ease !default; + +// Dropdowns +$dropdown-font-size: 0.85rem !default; +$dropdown-border-color: $border-color !default; + +// Images +$login-image: 'https://source.unsplash.com/K4mSJ7kc0As/600x800' !default; +$register-image: 'https://source.unsplash.com/Mv9hjnEUHR4/600x800' !default; +$password-image: 'https://source.unsplash.com/oWTW-jNGl9I/600x800' !default; + +// Vuln colors +$vuln-primary: #7952b3 !default; +// #5f264a \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/scss/navs/_global.scss b/save-cosv-frontend/src/main/resources/scss/navs/_global.scss new file mode 100644 index 0000000000..a52d8b84c2 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/navs/_global.scss @@ -0,0 +1,42 @@ +// Global styles for both custom sidebar and topbar compoments + +.sidebar, +.topbar { + .nav-item { + // Customize Dropdown Arrows for Navbar + &.dropdown { + .dropdown-toggle { + &::after { + width: 1rem; + text-align: center; + float: right; + vertical-align: 0; + border: 0; + font-weight: 900; + content: '\f105'; + font-family: 'Font Awesome 5 Free'; + } + } + &.show { + .dropdown-toggle::after { + content: '\f107'; + } + } + } + // Counter for nav links and nav link image sizing + .nav-link { + position: relative; + .badge-counter { + position: absolute; + transform: scale(0.7); + transform-origin: top right; + right: .25rem; + margin-top: -.25rem; + } + .img-profile { + height: 2rem; + width: 2rem; + } + } + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/navs/_sidebar.scss b/save-cosv-frontend/src/main/resources/scss/navs/_sidebar.scss new file mode 100644 index 0000000000..03bbb669cd --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/navs/_sidebar.scss @@ -0,0 +1,477 @@ +// Sidebar +.sidebar { + width: $sidebar-collapsed-width; + min-height: 100vh; + + .nav-item { + position: relative; + + &:last-child { + margin-bottom: 1rem; + } + + .nav-link { + text-align: center; + padding: 0.75rem 1rem; + width: $sidebar-collapsed-width; + + span { + font-size: 0.65rem; + display: block; + } + } + + &.active { + .nav-link { + font-weight: 700; + } + } + + // Accordion + .collapse { + position: absolute; + left: calc(#{$sidebar-collapsed-width} + #{$grid-gutter-width} / 2); + z-index: 1; + top: 2px; + // Grow In Animation + @extend .animated--grow-in; + + .collapse-inner { + border-radius: $border-radius; + box-shadow: $box-shadow; + } + } + + .collapsing { + display: none; + transition: none; + } + + .collapse, + .collapsing { + .collapse-inner { + padding: .5rem 0; + min-width: 10rem; + font-size: $dropdown-font-size; + margin: 0 0 1rem 0; + + .collapse-header { + margin: 0; + white-space: nowrap; + padding: .5rem 1.5rem; + text-transform: uppercase; + font-weight: 800; + font-size: 0.65rem; + color: $gray-500; + } + + .collapse-item { + padding: 0.5rem 1rem; + margin: 0 0.5rem; + display: block; + color: $gray-900; + text-decoration: none; + border-radius: $border-radius; + white-space: nowrap; + + &:hover { + background-color: $gray-200; + } + + &:active { + background-color: $gray-300; + } + + &.active { + color: $primary; + font-weight: 700; + } + } + } + } + } + + #sidebarToggle { + width: 2.5rem; + height: 2.5rem; + text-align: center; + margin-bottom: 1rem; + cursor: pointer; + + &::after { + font-weight: 900; + content: '\f104'; + font-family: 'Font Awesome 5 Free'; + margin-right: 0.1rem; + } + + &:hover { + text-decoration: none; + } + + &:focus { + outline: none; + } + } + + &.toggled { + width: 0 !important; + overflow: hidden; + + #sidebarToggle::after { + content: '\f105'; + font-family: 'Font Awesome 5 Free'; + margin-left: 0.25rem; + } + + .sidebar-card { + display: none; + } + } + + .sidebar-brand { + height: $topbar-base-height; + text-decoration: none; + font-size: 1rem; + font-weight: 800; + padding: 1.5rem 1rem; + text-align: center; + text-transform: uppercase; + letter-spacing: 0.05rem; + z-index: 1; + + .sidebar-brand-icon i { + font-size: 2rem; + } + + .sidebar-brand-text { + display: none; + } + } + + hr.sidebar-divider { + margin: 0 1rem 1rem; + } + + .sidebar-heading { + text-align: center; + padding: 0 1rem; + font-weight: 800; + font-size: 0.65rem; + @extend .text-uppercase; + } + + .sidebar-card { + display: flex; + flex-direction: column; + align-items: center; + font-size: $font-size-sm; + border-radius: $border-radius; + color: fade-out($white, 0.2); + margin-left: 1rem; + margin-right: 1rem; + margin-bottom: 1rem; + padding: 1rem; + background-color: fade-out($black, 0.9); + .sidebar-card-illustration { + height: 3rem; + display: block; + } + .sidebar-card-title { + font-weight: bold; + } + p { + font-size: 0.75rem; + color: fade-out($white, 0.5); + } + } +} + +@include media-breakpoint-up(md) { + .sidebar { + width: $sidebar-base-width !important; + + .nav-item { + + // Accordion + .collapse { + position: relative; + left: 0; + z-index: 1; + top: 0; + animation: none; + + .collapse-inner { + border-radius: 0; + box-shadow: none; + } + } + + .collapsing { + display: block; + transition: $transition-collapse; + } + + .collapse, + .collapsing { + margin: 0 1rem; + } + + .nav-link { + display: block; + width: 100%; + text-align: left; + padding: 1rem; + width: $sidebar-base-width; + + i { + font-size: 0.85rem; + margin-right: 0.25rem; + } + + span { + font-size: 0.85rem; + display: inline; + } + + // Accordion Arrow Icon + &[data-toggle="collapse"] { + &::after { + width: 1rem; + text-align: center; + float: right; + vertical-align: 0; + border: 0; + font-weight: 900; + content: '\f107'; + font-family: 'Font Awesome 5 Free'; + } + + &.collapsed::after { + content: '\f105'; + } + } + } + } + + .sidebar-brand { + .sidebar-brand-icon i { + font-size: 2rem; + } + + .sidebar-brand-text { + display: inline; + } + } + + .sidebar-heading { + text-align: left; + } + + &.toggled { + overflow: visible; + width: $sidebar-collapsed-width !important; + + .nav-item { + + // Accordion + .collapse { + position: absolute; + left: calc(#{$sidebar-collapsed-width} + #{$grid-gutter-width} / 2); + z-index: 1; + top: 2px; + // Grow In Animation for Toggled State + animation-name: growIn; + animation-duration: 200ms; + animation-timing-function: transform cubic-bezier(.18, 1.25, .4, 1), opacity cubic-bezier(0, 1, .4, 1); + + .collapse-inner { + box-shadow: $box-shadow; + border-radius: $border-radius; + } + } + + .collapsing { + display: none; + transition: none; + } + + .collapse, + .collapsing { + margin: 0; + } + + &:last-child { + margin-bottom: 1rem; + } + + .nav-link { + text-align: center; + padding: 0.75rem 1rem; + width: $sidebar-collapsed-width; + + span { + font-size: 0.65rem; + display: block; + } + + i { + margin-right: 0; + } + + &[data-toggle="collapse"]::after { + display: none; + } + } + } + + .sidebar-brand { + .sidebar-brand-icon i { + font-size: 2rem; + } + + .sidebar-brand-text { + display: none; + } + } + + .sidebar-heading { + text-align: center; + } + } + } +} + +// Sidebar Color Variants + +// Sidebar Light +.sidebar-light { + .sidebar-brand { + color: $gray-700; + } + + hr.sidebar-divider { + border-top: 1px solid $gray-200; + } + + .sidebar-heading { + color: $gray-500; + } + + .nav-item { + .nav-link { + color: $gray-600; + + i { + color: $gray-400; + } + + &:active, + &:focus, + &:hover { + color: $gray-700; + + i { + color: $gray-700; + } + } + + // Accordion + &[data-toggle="collapse"]::after { + color: $gray-500; + } + } + + &.active { + .nav-link { + color: $gray-700; + + i { + color: $gray-700; + } + } + } + } + + // Color the sidebar toggler + #sidebarToggle { + background-color: $gray-200; + + &::after { + color: $gray-500; + } + + &:hover { + background-color: $gray-300; + } + } +} + +// Sidebar Dark +.sidebar-dark { + .sidebar-brand { + color: $white; + } + + hr.sidebar-divider { + border-top: 1px solid fade-out($white, 0.85); + } + + .sidebar-heading { + color: fade-out($white, 0.6); + } + + .nav-item { + .nav-link { + color: fade-out($white, 0.2); + + i { + color: fade-out($white, 0.7); + } + + &:active, + &:focus, + &:hover { + color: $white; + + i { + color: $white; + } + } + + // Accordion + &[data-toggle="collapse"]::after { + color: fade-out($white, 0.5); + } + } + + &.active { + .nav-link { + color: $white; + + i { + color: $white; + } + } + } + } + + // Color the sidebar toggler + #sidebarToggle { + background-color: fade-out($white, 0.8); + + &::after { + color: fade-out($white, 0.5); + } + + &:hover { + background-color: fade-out($white, 0.75); + } + } + + &.toggled { + #sidebarToggle::after { + color: fade-out($white, 0.5); + } + } +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/scss/navs/_topbar.scss b/save-cosv-frontend/src/main/resources/scss/navs/_topbar.scss new file mode 100644 index 0000000000..5b5a4db524 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/navs/_topbar.scss @@ -0,0 +1,149 @@ +// Topbar +.topbar { + z-index: 1; + height: $topbar-base-height; + #sidebarToggleTop { + height: 2.5rem; + width: 2.5rem; + &:hover { + background-color: $gray-200; + } + &:active { + background-color: $gray-300; + } + } + .breadcrumb { + background-color: #646570; + color: white; + } + .navbar-search { + width: 50rem; + input { + font-size: 0.85rem; + height: auto; + } + } + .topbar-divider { + width: 0; + border-right: 1px solid $border-color; + height: calc(#{$topbar-base-height} - 2rem); + margin: auto 1rem; + } + .nav-item { + .nav-link { + height: $topbar-base-height; + display: flex; + align-items: center; + padding: 0 0.75rem; + &:focus { + outline: none; + } + } + &:focus { + outline: none; + } + } + .dropdown { + position: static; + .dropdown-menu { + width: calc(100% - #{$grid-gutter-width}); + right: $grid-gutter-width / 2; + } + } + .dropdown-list { + padding: 0; + border: none; + overflow: hidden; + .dropdown-header { + background-color: $primary; + border: 1px solid $primary; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + color: $white; + } + .dropdown-item { + white-space: normal; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + border-left: 1px solid $border-color; + border-right: 1px solid $border-color; + border-bottom: 1px solid $border-color; + line-height: 1.3rem; + .dropdown-list-image { + position: relative; + height: 2.5rem; + width: 2.5rem; + img { + height: 2.5rem; + width: 2.5rem; + } + .status-indicator { + background-color: $gray-200; + height: 0.75rem; + width: 0.75rem; + border-radius: 100%; + position: absolute; + bottom: 0; + right: 0; + border: .125rem solid $white; + } + } + .text-truncate { + max-width: 10rem; + } + &:active { + background-color: $gray-200; + color: $gray-900; + } + } + } + @include media-breakpoint-up(sm) { + .dropdown { + position: relative; + .dropdown-menu { + width: auto; + right: 0; + } + } + .dropdown-list { + width: 20rem !important; + .dropdown-item { + .text-truncate { + max-width: 13.375rem; + } + } + } + } +} + +.topbar.navbar-dark { + .navbar-nav { + .nav-item { + .nav-link { + color: fade-out($white, 0.2); + &:hover { + color: $white; + } + &:active { + color: $white; + } + } + } + } +} + +.topbar.navbar-light { + .navbar-nav { + .nav-item { + .nav-link { + color: $gray-400; + &:hover { + color: $gray-500; + } + &:active { + color: $gray-600; + } + } + } + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/save-cosv-frontend.scss b/save-cosv-frontend/src/main/resources/scss/save-cosv-frontend.scss new file mode 100644 index 0000000000..af3b5338ba --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/save-cosv-frontend.scss @@ -0,0 +1,42 @@ +// Import Custom Variables (Overrides Default Bootstrap Variables) +@import "variables.scss"; + +@import "root.scss"; + +// Import npm dependencies; path will be resolved relative to node_modules in rootProject.buildDir (kotlin js plugin adds `resolve` directive to webpack config) +@import "bootstrap/scss/bootstrap.scss"; + +// Import Custom SB Admin 2 Mixins and Components +@import "mixins.scss"; +@import "global.scss"; +@import "utilities.scss"; +@import "animate.css"; + +// Custom Components +@import "dropdowns.scss"; +@import "navs.scss"; +@import "buttons.scss"; +@import "cards.scss"; +@import "charts.scss"; +@import "login.scss"; +@import "error.scss"; +@import "footer.scss"; +@import "team.scss"; +@import "glow.scss"; + + +// do not use name `.popover`, because it's used in popover library and it breaks position +.form-popover { + cursor: pointer; +} + +// Override bootstrap's attribute, that makes buttons in modal-dialog disabled +.modal-dialog { + pointer-events: all; +} + +.unsaved-marker { + position: absolute; + background: lightgoldenrodyellow; + z-index: 0; +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_animated-card.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_animated-card.scss new file mode 100644 index 0000000000..2c6278edca --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_animated-card.scss @@ -0,0 +1,128 @@ +.link-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + position: relative; +} + +.link-three { + color: #0275d8; + text-decoration: none; +} + +.link-three::after { + content: ""; + position: absolute; + z-index: 2; + width: 50%; + height: 100%; + top: 0%; + left: 0%; + transform: translate(0, -50%) scaleY(0); + transition: transform 1s ease; + mix-blend-mode: difference; + + clip-path: polygon( + 20% 60%, + 100% 60%, + 100% 40%, + 20% 40%, + 20% 0%, + 60% 0%, + 60% 20%, + 20% 20% + ); + + background-color: #eb7132; +} + +.link-three:hover::after { + text-decoration: none; + transform: translate(0, 0%) scaleY(1); + border-color: #5cb85c; +} + +.link-three::before { + content: ""; + position: absolute; + z-index: 2; + width: 50%; + height: 100%; + bottom: 0%; + right: 0%; + transform: translate(0, 50%) scaleY(0); + transition: transform 1s ease; + mix-blend-mode: difference; + + clip-path: polygon( + 80% 40%, + 0% 40%, + 0% 60%, + 80% 60%, + 80% 100%, + 40% 100%, + 40% 80%, + 80% 80% + ); + + background-color: #eb7132; +} + +.link-three:hover::before { + text-decoration: none; + transform: translate(0%, 0%) scaleY(1); + border-color: #5cb85c; +} + +.bordered-div:hover { + border-color: #5cb85c; +} + +.btn-hov { + cursor: pointer; + position: relative; + border: solid #777 0; + color: $blue; + z-index: 1; + transition: all 0.3s ease-in-out; + border-radius: 0; + background: transparent; +} + +.btn-hov:before { + content: ""; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + background: #b7d7f7; + transform: scale(0); + transition: 0.3s ease-in-out; + z-index: -1; +} + +.btn-hov:hover:before { + transform: scale(1); +} + +.btn-hov:hover { + color: #224abe; + border-color: #b7d7f7; +} + +.btn-hov:active:before { + background-color: #a7c7e7; +} + +.btn-hov:active { + border-color: #a7c7e7; + color: #224abe; + background-color: #a7c7e7; +} + +.btn-hov:focus { + outline: none +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_animation.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_animation.scss new file mode 100644 index 0000000000..8956bf5975 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_animation.scss @@ -0,0 +1,59 @@ +// Animation Utilities + +// Grow In Animation + +@keyframes growIn { + 0% { + transform: scale(0.9); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.animated--grow-in { + animation-name: growIn; + animation-duration: 200ms; + animation-timing-function: transform cubic-bezier(.18,1.25,.4,1), opacity cubic-bezier(0,1,.4,1); +} + +// Fade In Animation + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.animated--fade-in { + animation-name: fadeIn; + animation-duration: 200ms; + animation-timing-function: opacity cubic-bezier(0,1,.4,1); +} + +/** + * Someone might need it + * Usage: + * ```animation: growOut 300ms ease-in-out forwards;``` + */ +@keyframes growOut { + 0% { transform: scale(0) } + 80% { transform: scale(1.1) } + 100% { transform: scale(1) } +} + +@keyframes slide-up { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_background.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_background.scss new file mode 100644 index 0000000000..6fdc60da89 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_background.scss @@ -0,0 +1,29 @@ +// Background Gradient Utilities + +@each $color, $value in $theme-colors { + .bg-gradient-#{$color} { + background-color: $value; + background-image: linear-gradient(180deg, $value 10%, darken($value, 15%) 100%); + background-size: cover; + } +} + +// Grayscale Background Utilities + +@each $level, $value in $grays { + .bg-gray-#{$level} { + background-color: $value !important; + } +} + +#main-body.vuln { + .border-primary { + border-color: $vuln-primary; + } + + .bg-primary { + background-color: $vuln-primary; + border-color: $vuln-primary; + } +} + diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_border.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_border.scss new file mode 100644 index 0000000000..cede0e4dd3 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_border.scss @@ -0,0 +1,7 @@ +@each $color, $value in $theme-colors { + @each $position in ['left', 'bottom'] { + .border-#{$position}-#{$color} { + border-#{$position}: .25rem solid $value !important; + } + } +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_display.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_display.scss new file mode 100644 index 0000000000..410b9ad657 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_display.scss @@ -0,0 +1,4 @@ +// Overflow Hidden +.o-hidden { + overflow: hidden !important; +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_particles.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_particles.scss new file mode 100644 index 0000000000..683a2c32f3 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_particles.scss @@ -0,0 +1,8 @@ +// this style is needed to show particles only in particular block +#tsparticles { + position:fixed !important; + left:0; + top:0; + width:100%; + height:100%; +} \ No newline at end of file diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_progress.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_progress.scss new file mode 100644 index 0000000000..0c2eb63ec2 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_progress.scss @@ -0,0 +1,3 @@ +.progress-sm { + height: .5rem; +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_ribbon.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_ribbon.scss new file mode 100644 index 0000000000..393bf3e583 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_ribbon.scss @@ -0,0 +1,63 @@ +.ribbon-parent { + overflow: hidden; +} + +.ribbon-approved { + margin: 0; + padding: 0; + background: green; + color:white; + padding:1em 0; + position: absolute; + top:0; + right:0; + transform: translateX(30%) translateY(0%) rotate(45deg); + transform-origin: top left; +} +.ribbon-approved:before, +.ribbon-approved:after { + content: ''; + position: absolute; + top:0; + margin: 0 -1px; + width: 100%; + height: 100%; + background: green; +} +.ribbon-approved:before { + right:100%; +} + +.ribbon-approved:after { + left:100%; +} + +.ribbon-not-approved { + margin: 0; + padding: 0; + background: #dc3545; + color:white; + padding:1em 0; + position: absolute; + top:0; + right:0; + transform: translateX(30%) translateY(0%) rotate(45deg); + transform-origin: top left; +} +.ribbon-not-approved:before, +.ribbon-not-approved:after { + content: ''; + position: absolute; + top:0; + margin: 0 -1px; + width: 100%; + height: 100%; + background: #dc3545; +} +.ribbon-not-approved:before { + right:100%; +} + +.ribbon-not-approved:after { + left:100%; +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_rotate.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_rotate.scss new file mode 100644 index 0000000000..7e33d441a6 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_rotate.scss @@ -0,0 +1,7 @@ +.rotate-15 { + transform: rotate(15deg); +} + +.rotate-n-15 { + transform: rotate(-15deg); +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_text.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_text.scss new file mode 100644 index 0000000000..4fb78afb40 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_text.scss @@ -0,0 +1,54 @@ +// Grayscale Text Utilities + +.text-xs { + font-size: .7rem; +} + +.text-lg { + font-size: 1.2rem; +} + +.text-gray-100 { + color: $gray-100 !important; +} + +.text-gray-200 { + color: $gray-200 !important; +} + +.text-gray-300 { + color: $gray-300 !important; +} + +.text-gray-400 { + color: $gray-400 !important; +} + +.text-gray-500 { + color: $gray-500 !important; +} + +.text-gray-600 { + color: $gray-600 !important; +} + +.text-gray-700 { + color: $gray-700 !important; +} + +.text-gray-800 { + color: $gray-800 !important; +} + +.text-gray-900 { + color: $gray-900 !important; +} + +.icon-circle { + height: 2.5rem; + width: 2.5rem; + border-radius: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/save-cosv-frontend/src/main/resources/scss/utilities/_timeline.scss b/save-cosv-frontend/src/main/resources/scss/utilities/_timeline.scss new file mode 100644 index 0000000000..798346da00 --- /dev/null +++ b/save-cosv-frontend/src/main/resources/scss/utilities/_timeline.scss @@ -0,0 +1,105 @@ +$color-step: #4b1bc4; +$color-line: #563d7c; +$color-label-default: #0275d8; + +.text-primary-blue { + color: $color-label-default; +} + +.timeline-container { + display: flex; + flex-direction: column; + justify-content: center; + transition: all 200ms ease; + box-shadow: none; + flex-grow: 1; + border-radius: 1rem; + + .steps-container { + padding-top: 2.5rem; + padding-bottom: 2.5rem; + position: relative; + display: flex; + align-items: center; + justify-content: center; + + + .step-non-editable { + transition: 0.4s; + z-index: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-grow: 0; + height: 1.5rem; + width: 1.5rem; + border: 0.2rem solid $color-line; + border-radius: 50%; + background: $color-line; + } + + .step { + transition: 0.4s; + z-index: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-grow: 0; + height: 1.5rem; + width: 1.5rem; + border: 0.2rem solid $color-step; + border-radius: 50%; + background: white; + } + + .date-label { + transition: 0.4s; + position: absolute; + top: 1.75rem; + filter: none; + z-index: 3; + width: 6.5rem; + text-align: center; + color: black; + font-weight: 5; + pointer-events: none; + } + + .text-label { + transition: 0.4s; + position: absolute; + filter: none; + bottom: 1.75rem; + z-index: 3; + color: $color-label-default; + font-weight: 700; + width: 6.5rem; + text-align: center; + pointer-events: none; + } + + .step.hoverable:hover { + transform: scale(1.1); + transition: 0.4s; + background: red; + cursor: pointer; + } + + .line { + transition: all 200ms ease; + height: 0.2rem; + flex-grow: 1; + max-width: 100%; + background: $color-line; + } + + .line-end { + color: $color-line; + border-top: 0.5rem solid transparent; + border-bottom: 0.5rem solid transparent; + border-left: 1.2rem solid; + } + } +} diff --git a/save-cosv-frontend/src/main/resources/site.webmanifest b/save-cosv-frontend/src/main/resources/site.webmanifest new file mode 100644 index 0000000000..1613f396fd --- /dev/null +++ b/save-cosv-frontend/src/main/resources/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "SAVE", + "short_name": "SAVE", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-384x384.png", + "sizes": "384x384", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/save-cosv-frontend/webpack.config.d/css.js b/save-cosv-frontend/webpack.config.d/css.js new file mode 100644 index 0000000000..0dc288fc7f --- /dev/null +++ b/save-cosv-frontend/webpack.config.d/css.js @@ -0,0 +1,35 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +config.module.rules.push( + { + test: /\.scss$/, + use: [ + MiniCssExtractPlugin.loader, // creates CSS files from css-loader's output + 'css-loader', // translates CSS into CommonJS + { + loader: 'postcss-loader', // Run postcss actions + options: { + postcssOptions: { + plugins: [ + "autoprefixer", + ], + }, + }, + }, + 'sass-loader', // compiles Sass to CSS, using Node Sass by default + ] + }, + { + // loader for fonts + test: /\.(eot|ttf|woff|woff2)$/, + use: { + loader: 'file-loader', + } + }, +); + + + +config.plugins.push( + new MiniCssExtractPlugin() +) \ No newline at end of file diff --git a/save-cosv-frontend/webpack.config.d/dev-server.js b/save-cosv-frontend/webpack.config.d/dev-server.js new file mode 100644 index 0000000000..574c4ba722 --- /dev/null +++ b/save-cosv-frontend/webpack.config.d/dev-server.js @@ -0,0 +1,57 @@ +config.devServer = Object.assign( + {}, + config.devServer || {}, + { + setupMiddlewares: (middlewares, devServer) => { + devServer.app.get("/sec/oauth-providers", (req, res) => { return res.send([]); }); + return middlewares; + }, + proxy: [ + { + context: ["/api/sandbox/**"], + target: 'http://localhost:5400', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + }, + { + context: ["/api/demo/**"], + target: 'http://localhost:5421', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + }, + { + context: ["/api/cpg/**"], + target: 'http://localhost:5500', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + }, + { + context: ["/api/**"], + target: 'http://localhost:5800', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + } + ], + historyApiFallback: true + } +); \ No newline at end of file diff --git a/save-cosv-frontend/webpack.config.d/optimizations.js b/save-cosv-frontend/webpack.config.d/optimizations.js new file mode 100644 index 0000000000..6ecf4c8263 --- /dev/null +++ b/save-cosv-frontend/webpack.config.d/optimizations.js @@ -0,0 +1,23 @@ +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +config.plugins.push( + new HtmlWebpackPlugin({ + template: 'index.html', + publicPath: '/', + }) +); + +if (config.mode === "production") { + config.optimization = { + // todo: use https://webpack.js.org/guides/output-management/ instead of manually adding js files into html + splitChunks: { + cacheGroups: { + commons: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all' + } + } + } + }; +} diff --git a/save-cosv-frontend/webpack.config.d/plugins.js b/save-cosv-frontend/webpack.config.d/plugins.js new file mode 100644 index 0000000000..39f3fe76c6 --- /dev/null +++ b/save-cosv-frontend/webpack.config.d/plugins.js @@ -0,0 +1,10 @@ +const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; + +//config.plugins.push( +// new BundleAnalyzerPlugin() +//); + +config.resolve.fallback = { + "os": require.resolve("os-browserify/browser"), + "path": require.resolve("path-browserify") +} \ No newline at end of file diff --git a/save-frontend-common/README.md b/save-frontend-common/README.md new file mode 100644 index 0000000000..906098999c --- /dev/null +++ b/save-frontend-common/README.md @@ -0,0 +1,159 @@ +# Frontend + +### `nginx` configuration +The most interesting part of `nginx.conf` is here: +```nginx configuration +location / { + index index.html; + try_files $uri $uri/ /index.html; + add_header Cache-Control "private; no-store"; + etag off; + add_header Last-Modified ""; + if_modified_since off; +} +``` + +Here we define a configuration for the `location /` block, which is the default location block in `nginx` and matches any +request that doesn't match other specific location blocks. + +Let's break down each directive and explain what happens: + +1. `index index.html`; + This directive sets the default file to serve when a **directory** is requested. + In this case, if a request is made to a directory (e.g., http://example.com/), Nginx will try to serve the `index.html` + file from that directory. If the file doesn't exist, Nginx will move to the next directive. +2. `try_files $uri $uri/ /index.html;` + This directive specifies a series of files and locations that Nginx should try to serve. + It acts as a fallback mechanism when the requested file is not found in the specified locations. +- `$uri` represents the requested URI. +- `$uri/` represents the URI followed by a trailing slash, typically used for directories. +- `/index.html` is the last fallback option. +3. `add_header Cache-Control "private; no-store";` + This directive adds an HTTP response header named `Cache-Control` to the server's responses. + `"private; no-store"` sets the `Cache-Control` header value, which tells the client and intermediate caching servers not to store any cached copies of the response. + It ensures that the content is not stored in any cache, making every request hit the server directly. +4. `etag off;` + `ETag` is an identifier that helps with caching, but disabling it ensures that the server doesn't use `ETag` for caching validation. + This can be useful for certain scenarios where `ETags` are not necessary or could cause caching issues. +5. `add_header Last-Modified "";` + The `Last-Modified` header is used for caching validation, but setting it to an empty value indicates that the server does not want to participate in any caching validation based on the last modification date of the resource. +6. `if_modified_since off;` + The `if_modified_since` directive controls the behavior of the `If-Modified-Since` request header. + Disabling it ensures that clients won't use conditional requests, which can be helpful in certain caching scenarios or when caching behavior needs to be controlled explicitly. + +### `webpack-dev-server` configuration for no `api-gateway` run +Here is a `webpack-dev-server` configuration for running without `api-gateway` on: +```javascript +config.devServer = Object.assign( + {}, + config.devServer || {}, + { + setupMiddlewares: (middlewares, devServer) => { + devServer.app.get("/sec/oauth-providers", (req, res) => { return res.send([]); }); + return middlewares; + }, + proxy: [ + { + context: ["/api/sandbox/**"], + target: 'http://localhost:5400', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + }, + { + context: ["/api/demo/**"], + target: 'http://localhost:5421', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + }, + { + context: ["/api/cpg/**"], + target: 'http://localhost:5500', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + }, + { + context: ["/api/**"], + target: 'http://localhost:5800', + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); + } + } + ], + historyApiFallback: true + } +); +``` + +`setupMiddlewares` sets a stub for `/sec/oauth-providers` endpoint. + +`historyApiFallback` makes `webpack-dev-server` return `index.html` in case of `404 Not Found`. + +Sometimes it is useful to add authorization headers when proxying to some Spring services e.g. `save-backend`. +It can be done with setting `onPorxyReq`: +```javascript +onProxyReq: (proxyReq, req, res) => { + proxyReq.setHeader("X-Authorization-Id", "1"); + proxyReq.setHeader("X-Authorization-Name", "admin"); + proxyReq.setHeader("X-Authorization-Roles", "ROLE_SUPER_ADMIN"); + proxyReq.setHeader("X-Authorization-Status", "ACTIVE"); +} +``` +Thus, we add `Authorization` and `X-Authorization-Source` headers that correspond with `admin` user headers. + +### Using OAuth with a local deployment (`api-gateway` on) + +* When the default [`dev-server.js`](../save-frontend/webpack.config.d/dev-server.js) + is used, the front-end is expected to communicate directly with the back-end, + omitting any gateway. When enabling OAuth, make sure the gateway is contacted + instead: + + * `context`: add `/sec/**, /oauth2/**, /login/oauth2/**` to the list; + * `target`: change to [`http://localhost:5300`](http://localhost:5300) (the + default gateway URL); + * `onProxyReq`: drop the entire callback, since all auth headers (`X-Authorization-Id`, + `X-Authorization-Name`, `X_Authorization-Status` and `X-Authorization-Roles`) will be set by the gateway now + (the gateway acts as a reverse proxy); + * `bypass`: drop the entire callback. + + The resulting `dev-server.js` should look like this: + ```javascript + config.devServer = Object.assign( + {}, + config.devServer || {}, + { + proxy: [ + { + context: ["/api/**", "/sec/**", "/oauth2/**", "/logout/**", "/login/oauth2/**"], + target: 'http://localhost:5300', + logLevel: 'debug', + } + ], + historyApiFallback: true + } + ) + ``` + Notice that `historyApiFallback` is required for `BrowserRouter` work fine. + +* Avoid potential name conflicts between local users (those authenticated using + _HTTP Basic Auth_) and users created via an external _OAuth_ provider. For + example, if you have a local user named `torvalds`, don't try to authenticate + as a [_GitHub_ user with the same name](https://github.com/torvalds). \ No newline at end of file diff --git a/save-frontend-common/build.gradle.kts b/save-frontend-common/build.gradle.kts new file mode 100644 index 0000000000..9565d356a1 --- /dev/null +++ b/save-frontend-common/build.gradle.kts @@ -0,0 +1,268 @@ +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin +import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest + +@Suppress("DSL_SCOPE_VIOLATION", "RUN_IN_SCRIPT") // https://github.com/gradle/gradle/issues/22797 +plugins { + kotlin("js") + id("com.saveourtool.save.buildutils.build-frontend-image-configuration") + id("com.saveourtool.save.buildutils.code-quality-convention") + id("com.saveourtool.save.buildutils.save-cloud-version-file-configuration") + alias(libs.plugins.kotlin.plugin.serialization) +} + +rootProject.plugins.withType { + rootProject.the().nodeVersion = "16.13.1" +} + +dependencies { + implementation(projects.saveCloudCommon) + + implementation(enforcedPlatform(libs.kotlin.wrappers.bom)) + implementation("org.jetbrains.kotlin-wrappers:kotlin-react") + implementation("org.jetbrains.kotlin-wrappers:kotlin-extensions") + implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom") + implementation("org.jetbrains.kotlin-wrappers:kotlin-react-router-dom") + implementation("org.jetbrains.kotlin-wrappers:kotlin-tanstack-react-table") + implementation("org.jetbrains.kotlin-wrappers:kotlin-mui-icons") + implementation("org.jetbrains.kotlin-wrappers:kotlin-mui") + implementation("io.github.petertrr:kotlin-multiplatform-diff-js:0.4.0") + + implementation(libs.save.common) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + implementation(libs.ktor.http) +} + +val distributionsDirName = "distributions" + +kotlin { + js(IR) { + // as for `-pre.148-kotlin-1.4.21`, react-table gives errors with IR + browser { + distribution( + Action { + // TODO: need to remove this overriding + outputDirectory = layout.buildDirectory.dir(distributionsDirName) + } + ) + testTask { + useKarma { + when (properties["save.profile"]) { + "dev" -> { + useChrome() + // useFirefox() + } + null -> useChromeHeadless() + } + } + } + commonWebpackConfig { + cssSupport { + enabled.set(true) + } + } + } + // kotlin-wrapper migrates to commonjs and missed @JsNonModule annotations + // https://github.com/JetBrains/kotlin-wrappers/issues/1935 + useCommonJs() + binaries.executable() // already default for LEGACY, but explicitly needed for IR + sourceSets.all { + languageSettings.apply { + optIn("kotlin.RequiresOptIn") + optIn("kotlinx.serialization.ExperimentalSerializationApi") + optIn("kotlin.js.ExperimentalJsExport") + } + } + sourceSets["main"].dependencies { + compileOnly(devNpm("sass", "^1.43.0")) + compileOnly(devNpm("sass-loader", "^12.0.0")) + compileOnly(devNpm("style-loader", "^3.3.1")) + compileOnly(devNpm("css-loader", "^6.5.0")) + compileOnly(devNpm("file-loader", "^6.2.0")) + // https://getbootstrap.com/docs/4.0/getting-started/webpack/#importing-precompiled-sass + compileOnly(devNpm("postcss-loader", "^6.2.1")) + compileOnly(devNpm("postcss", "^8.2.13")) + // See https://stackoverflow.com/a/72828500; newer versions are supported only for Bootstrap 5.2+ + compileOnly(devNpm("autoprefixer", "10.4.5")) + compileOnly(devNpm("webpack-bundle-analyzer", "^4.5.0")) + compileOnly(devNpm("mini-css-extract-plugin", "^2.6.0")) + compileOnly(devNpm("html-webpack-plugin", "^5.5.0")) + + // web-specific dependencies + implementation(npm("@fortawesome/fontawesome-svg-core", "^1.2.36")) + implementation(npm("@fortawesome/free-solid-svg-icons", "5.15.3")) + implementation(npm("@fortawesome/free-brands-svg-icons", "5.15.3")) + implementation(npm("@fortawesome/react-fontawesome", "^0.1.16")) + implementation(npm("devicon", "^2.15.1")) + implementation(npm("animate.css", "^4.1.1")) + implementation(npm("react-scroll-motion", "^0.3.0")) + implementation(npm("react-spinners", "0.13.0")) + implementation(npm("react-tsparticles", "1.42.1")) + implementation(npm("tsparticles", "2.1.3")) + implementation(npm("jquery", "3.6.0")) + // BS5: implementation(npm("@popperjs/core", "2.11.0")) + implementation(npm("popper.js", "1.16.1")) + // BS5: implementation(npm("bootstrap", "5.0.1")) + implementation(npm("react-calendar", "^3.8.0")) + implementation(npm("bootstrap", "^4.6.0")) + implementation(npm("react", "^18.0.0")) + implementation(npm("react-dom", "^18.0.0")) + implementation(npm("react-modal", "^3.0.0")) + implementation(npm("os-browserify", "^0.3.0")) + implementation(npm("path-browserify", "^1.0.1")) + implementation(npm("react-minimal-pie-chart", "^8.2.0")) + implementation(npm("lodash.debounce", "^4.0.8")) + implementation(npm("react-markdown", "^8.0.3")) + implementation(npm("rehype-highlight", "^5.0.2")) + implementation(npm("react-ace", "^10.1.0")) + implementation(npm("react-avatar-image-cropper", "^1.4.2")) + implementation(npm("react-circle", "^1.1.1")) + implementation(npm("react-diff-viewer-continued", "^3.2.6")) + implementation(npm("react-json-view", "^1.21.3")) + implementation(npm("multi-range-slider-react", "^2.0.5")) + // react-sigma + implementation(npm("@react-sigma/core", "^3.1.0")) + implementation(npm("sigma", "^2.4.0")) + implementation(npm("graphology", "^0.25.1")) + implementation(npm("graphology-layout", "^0.6.1")) + implementation(npm("graphology-layout-forceatlas2", "^0.10.1")) + implementation(npm("@react-sigma/layout-core", "^3.1.0")) + implementation(npm("@react-sigma/layout-random", "^3.1.0")) + implementation(npm("@react-sigma/layout-circular", "^3.1.0")) + implementation(npm("@react-sigma/layout-forceatlas2", "^3.1.0")) + implementation(npm("react-graph-viz-engine", "^0.1.0")) + implementation(npm("cytoscape", "^3.25.0")) + // translation + implementation(npm("i18next", "^23.4.5")) + implementation(npm("react-i18next", "^13.2.0")) + implementation(npm("i18next-http-backend", "^2.2.2")) + implementation(npm("js-cookie", "^3.0.5")) + // transitive dependencies with explicit version ranges required for security reasons + compileOnly(devNpm("minimist", "^1.2.6")) + compileOnly(devNpm("async", "^2.6.4")) + compileOnly(devNpm("follow-redirects", "^1.14.8")) + } + sourceSets["test"].dependencies { + implementation(kotlin("test-js")) + implementation(devNpm("jsdom", "^19.0.0")) + implementation(devNpm("global-jsdom", "^8.4.0")) + implementation(devNpm("@testing-library/react", "^13.2.0")) + implementation(devNpm("@testing-library/user-event", "^14.0.0")) + implementation(devNpm("karma-mocha-reporter", "^2.0.0")) + implementation(devNpm("istanbul-instrumenter-loader", "^3.0.1")) + implementation(devNpm("karma-coverage-istanbul-reporter", "^3.0.3")) + implementation(devNpm("msw", "^0.40.0")) + } + } +} + +rootProject.plugins.withType(NodeJsRootPlugin::class.java) { + rootProject.the().versions.apply { + // workaround for continuous work of WebPack: (https://github.com/webpack/webpack-cli/issues/2990) + webpackCli.version = "4.9.0" + webpackDevServer.version = "^4.9.0" + // override default version from KGP for security reasons + karma.version = "^6.3.14" + mocha.version = "^9.2.0" + } +} +// store yarn.lock in the root directory +rootProject.extensions.configure { + lockFileDirectory = rootProject.projectDir +} + +val mswScriptTargetPath = file("${rootProject.buildDir}/js/packages/${rootProject.name}-${project.name}-test/node_modules").absolutePath +val mswScriptTargetFile = "$mswScriptTargetPath/mockServiceWorker.js" +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +val installMwsScriptTaskProvider = tasks.register("installMswScript") { + dependsOn(":kotlinNodeJsSetup", ":kotlinNpmInstall", "packageJson") + inputs.dir(mswScriptTargetPath) + outputs.file(mswScriptTargetFile) + // cd to directory where the generated package.json is located. This is required for correct operation of npm/npx + workingDir("$rootDir/build/js") + + val isWindows = DefaultNativePlatform.getCurrentOperatingSystem().isWindows + val nodeJsEnv = NodeJsRootPlugin.apply(project.rootProject).requireConfigured() + val nodeDir = nodeJsEnv.nodeDir + val nodeBinDir = nodeJsEnv.nodeBinDir + listOf( + System.getenv("PATH"), + nodeBinDir.absolutePath, + ) + .filterNot { it.isNullOrEmpty() } + .joinToString(separator = File.pathSeparator) + .let { environment("PATH", it) } + + if (!isWindows) { + doFirst { + // workaround, because `npx` is a symlink but symlinks are lost when Gradle unpacks archive + exec { + commandLine("ln", "-sf", "$nodeDir/lib/node_modules/npm/bin/npx-cli.js", "$nodeBinDir/npx") + } + exec { + commandLine("ln", "-sf", "$nodeDir/lib/node_modules/npm/bin/npm-cli.js", "$nodeBinDir/npm") + } + exec { + commandLine("ln", "-sf", "$nodeDir/lib/node_modules/corepack/dist/corepack.js", "$nodeBinDir/corepack") + } + } + } + + commandLine( + nodeBinDir.resolve(if (isWindows) "npx.cmd" else "npx").canonicalPath, + "msw", + "init", + mswScriptTargetPath, + "--no-save", + ) +} +tasks.named("browserTest").configure { + dependsOn(installMwsScriptTaskProvider) + inputs.file(mswScriptTargetFile) +} + +kotlin.sourceSets.getByName("main") { + kotlin.srcDir( + tasks.named("generateSaveCloudVersionFile").map { + it.outputs.files.singleFile + } + ) +} + +tasks.withType { + // Since we inject timestamp into HTML file, we would like this task to always be re-run. + inputs.property("Build timestamp", System.currentTimeMillis()) + doFirst { + val additionalWebpackResources = fileTree("$buildDir/processedResources/js/main/") { + include("scss/**") + include("index.html") + } + copy { + from(additionalWebpackResources) + into("${rootProject.buildDir}/js/packages/${rootProject.name}-${project.name}") + } + } +} + +val distribution: Configuration by configurations.creating +val distributionJarTask by tasks.registering(Jar::class) { + dependsOn(":save-cosv-frontend:browserDistribution") + archiveClassifier.set("distribution") + from("$buildDir/$distributionsDirName") { + into("static") + exclude("scss") + } + from("$projectDir/nginx.conf") { + into("") + } +} +artifacts.add(distribution.name, distributionJarTask.get().archiveFile) { + builtBy(distributionJarTask) +} + +detekt { + config.setFrom(config.plus(file("detekt.yml"))) +} diff --git a/save-frontend-common/detekt.yml b/save-frontend-common/detekt.yml new file mode 100644 index 0000000000..665dca7995 --- /dev/null +++ b/save-frontend-common/detekt.yml @@ -0,0 +1,8 @@ +style: + MagicNumber: + # There are a lot of magic number in UI code, so it's easier to just suppress the rule + active: false +naming: + MatchingDeclarationName: + # Because common pattern is RProps class and functional component in a single file, which triggers this rule + active: false \ No newline at end of file diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/Footer.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/Footer.kt similarity index 92% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/Footer.kt rename to save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/Footer.kt index 6198d8096f..1f2cc5789a 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/Footer.kt +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/Footer.kt @@ -1,6 +1,6 @@ @file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") -package com.saveourtool.save.frontend.components +package com.saveourtool.save.frontend.common.components import generated.SAVE_CLOUD_VERSION import react.* @@ -24,7 +24,7 @@ val footer: FC = FC { div { className = ClassName("copyright text-center my-auto") span { - +"Copyright ${js("String.fromCharCode(169)")} SAVE 2021-2023" + +"Copyright ${js("String.fromCharCode(169)")} SAVE 2021-2022" br {} +"Version $SAVE_CLOUD_VERSION" } diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/HasErrorModal.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/HasErrorModal.kt new file mode 100644 index 0000000000..f75846980f --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/HasErrorModal.kt @@ -0,0 +1,60 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components + +import com.saveourtool.save.frontend.common.externals.animations.ringLoader + +import js.core.jso +import org.w3c.fetch.Response +import react.* + +/** + * Loader animation + */ +@Suppress("MAGIC_NUMBER", "MagicNumber") +val ringLoader = ringLoader(jso { + this.size = 80 + this.loading = true + this.color = "#3a00c2" +}) + +/** + * Context to store data about current request such as errors and isLoading flag. + */ +@Suppress("TYPE_ALIAS") +val requestStatusContext: Context = createContext() + +/** + * @property setResponse [StateSetter] for response error handler + * @property setLoadingCounter [StateSetter] for active request counter + * @property setRedirectToFallbackView + */ +data class RequestStatusContext( + val setResponse: StateSetter, + val setRedirectToFallbackView: StateSetter, + val setLoadingCounter: StateSetter, +) + +/** + * @property isErrorModalOpen + * @property errorMessage + * @property errorLabel + * @property confirmationText text that will be displayed on modal dismiss button + * @property status + * @property redirectToFallbackView + */ +data class ErrorModalState( + val isErrorModalOpen: Boolean, + val errorMessage: String, + val errorLabel: String, + val confirmationText: String = "Close", + val status: Short?, + val redirectToFallbackView: Boolean = false, +) + +/** + * @property isLoadingModalOpen + */ +data class LoadingModalState( + val isLoadingModalOpen: Boolean, +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AddUserComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AddUserComponent.kt new file mode 100644 index 0000000000..22681249cb --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AddUserComponent.kt @@ -0,0 +1,53 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.components.inputform.inputWithDebounceForUserInfo +import com.saveourtool.save.frontend.common.components.inputform.renderUserWithAvatar +import com.saveourtool.save.frontend.common.utils.apiUrl +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.utils.DATABASE_DELIMITER +import react.FC +import react.Props +import react.dom.html.ReactHTML.div +import react.useState +import web.cssom.ClassName + +/** + * Component for adding user to some group + */ +val addUserComponent: FC = FC { props -> + val (user, setUser) = useState(UserInfo(name = "")) + + div { + className = ClassName("") + inputWithDebounceForUserInfo { + selectedOption = user + setSelectedOption = { setUser(it) } + placeholder = "Input name..." + renderOption = ::renderUserWithAvatar + onOptionClick = props.onUserAdd + maxOptions = 2 + getUrlForOptionsFetch = { prefix -> + props.namesToSkip.joinToString(DATABASE_DELIMITER) + .let { names -> if (names.isNotBlank()) "&namesToSkip=$names" else "" } + .let { names -> "$apiUrl/users/by-prefix/?prefix=$prefix$names" } + } + } + } +} + +/** + * [Props] for [addUserComponent] + */ +external interface AddUserComponentProps : Props { + /** + * Callback invoked on user click + */ + var onUserAdd: (UserInfo) -> Unit + + /** + * [Set] of names to ignore when showing results + */ + var namesToSkip: Set +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AvatarForm.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AvatarForm.kt new file mode 100644 index 0000000000..62c7f75bfd --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AvatarForm.kt @@ -0,0 +1,67 @@ +/** + * Function component for avatar form + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.components.modal.modalAvatarBuilder +import com.saveourtool.save.frontend.common.externals.imageeditor.reactAvatarImageCropper + +import js.core.jso +import react.FC +import react.Props +import react.dom.html.ReactHTML.div +import web.cssom.ClassName +import web.cssom.rem +import web.file.File + +val avatarForm: FC = FC { props -> + modalAvatarBuilder( + isOpen = props.isOpen, + title = props.title, + onCloseButtonPressed = { + props.onCloseWindow() + } + ) { + div { + className = ClassName("shadow") + style = jso { + height = 18.rem + width = 18.rem + } + reactAvatarImageCropper { + apply = { file, _ -> + props.imageUpload(file) + props.onCloseWindow() + } + } + } + } +} + +/** + * AvatarForm component props + */ +external interface AvatarFormProps : Props { + /** + * Flag to handle avatar Window + */ + var isOpen: Boolean + + /** + * Title of window + */ + var title: String + + /** + * Callback to update state for close window. + */ + var onCloseWindow: () -> Unit + + /** + * Callback to upload avatar. + */ + var imageUpload: (File) -> Unit +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AvatarRenderers.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AvatarRenderers.kt new file mode 100644 index 0000000000..3ab532788e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/AvatarRenderers.kt @@ -0,0 +1,219 @@ +/** + * File containing functions to render avatars + */ + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.entities.OrganizationStatus +import com.saveourtool.save.frontend.common.utils.AVATAR_PROFILE_PLACEHOLDER +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.info.UserStatus +import com.saveourtool.save.v1 +import com.saveourtool.save.validation.FrontendRoutes +import js.core.jso +import react.CSSProperties +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import react.router.dom.Link +import web.cssom.ClassName +import web.cssom.rem + +/** + * Placeholder for organization avatar + */ +const val AVATAR_ORGANIZATION_PLACEHOLDER = "/img/company.png" + +/** + * The base URL for uploaded avatars. + */ +const val AVATAR_BASE_URL = "/api/$v1/avatar" + +/** + * links to avatars: `/img` for static resources, `/api` for uploaded. + * + * @receiver the local avatar URL (w/o the host name), either absolute or relative. + * @return the absolute avatar URL (still local to the web server). + */ +fun String.avatarRenderer(): String = + when { + /* + * Static resource, such as `/img/avatar_packs/avatar1.png` + */ + startsWith("/img") -> this + + /* + * Uploaded resource (absolute), the URL is already processed/canonicalized. + */ + startsWith(AVATAR_BASE_URL) -> this + + /* + * Uploaded resource (absolute), such as `/users/admin?1`. + */ + startsWith('/') -> AVATAR_BASE_URL + this + + /* + * Uploaded resource (relative). + */ + else -> "$AVATAR_BASE_URL/$this" + } + +/** + * Render organization avatar or placeholder + * + * @param organizationDto organization to render avatar + * @param classes classes applied to [img] html tag + * @param link link to redirect to if clicked + * @param styleBuilder [CSSProperties] builder + */ +fun ChildrenBuilder.renderAvatar( + organizationDto: OrganizationDto, + classes: String = "", + link: String? = null, + styleBuilder: CSSProperties.() -> Unit = {}, +) = renderAvatar( + organizationDto.avatar?.avatarRenderer() ?: AVATAR_ORGANIZATION_PLACEHOLDER, + classes, + link ?: "/${organizationDto.name}", + styleBuilder +) + +/** + * Render user avatar or placeholder + * + * @param userInfo user to render avatar + * @param classes classes applied to [img] html tag + * @param link link to redirect to if clicked + * @param styleBuilder [CSSProperties] builder + * @param isLinkActive + */ +fun ChildrenBuilder.renderAvatar( + userInfo: UserInfo?, + classes: String = "", + link: String? = null, + isLinkActive: Boolean = true, + styleBuilder: CSSProperties.() -> Unit, +) { + val newLink = (link ?: "/${FrontendRoutes.VULN_PROFILE}/${userInfo?.name}").takeIf { userInfo?.status != UserStatus.DELETED && isLinkActive } + return renderAvatar( + userInfo?.avatar?.avatarRenderer() ?: AVATAR_PROFILE_PLACEHOLDER, + classes, + newLink, + styleBuilder + ) +} + +/** + * @param userInfo + * @param classes + * @param link + * @param styleBuilder + * @param isHorizontal if the avatar shoud be on the same line with text + * @param isCentered + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.renderUserAvatarWithName( + userInfo: UserInfo, + classes: String = "", + link: String? = null, + isCentered: Boolean = true, + isHorizontal: Boolean = false, + styleBuilder: CSSProperties.() -> Unit = {}, +) { + val renderImg: ChildrenBuilder.() -> Unit = { + div { + className = ClassName("col") + if (isHorizontal) { + div { + className = ClassName("row d-flex align-items-center") + renderAvatar(userInfo, classes, link, styleBuilder = styleBuilder) + style = jso { + fontSize = 1.rem + } + +" ${userInfo.name}" + } + } else { + val justify = if (isCentered) "justify-content-center" else "" + div { + className = ClassName("row $justify") + renderAvatar(userInfo, classes, link, styleBuilder = styleBuilder) + } + div { + className = ClassName("row $justify mt-2") + style = jso { + fontSize = 0.8.rem + } + +" ${userInfo.name}" + } + } + } + } + return if (userInfo.status != UserStatus.DELETED) { + Link { + to = "/${FrontendRoutes.VULN_PROFILE}/${userInfo.name}" + renderImg() + } + } else { + renderImg() + } +} + +/** + * @param organizationDto + * @param classes + * @param link + * @param styleBuilder + */ +fun ChildrenBuilder.renderOrganizationWithName( + organizationDto: OrganizationDto, + classes: String = "", + link: String? = null, + styleBuilder: CSSProperties.() -> Unit = {}, +) { + val renderImg: ChildrenBuilder.() -> Unit = { + div { + className = ClassName("col") + div { + className = ClassName("row justify-content-center") + renderAvatar(organizationDto, classes, link, styleBuilder = styleBuilder) + } + div { + className = ClassName("row justify-content-center mt-2") + style = jso { + fontSize = 0.8.rem + } + +" ${organizationDto.name}" + } + } + } + return if (organizationDto.status != OrganizationStatus.DELETED) { + Link { + to = link ?: "/${organizationDto.name}" + renderImg() + } + } else { + renderImg() + } +} + +private fun ChildrenBuilder.renderAvatar( + avatarLink: String, + classes: String, + link: String?, + styleBuilder: CSSProperties.() -> Unit, +) { + val renderImg: ChildrenBuilder.() -> Unit = { + img { + className = ClassName("avatar avatar-user border color-bg-default rounded-circle $classes") + src = avatarLink + style = jso { styleBuilder() } + } + } + link?.let { + Link { + to = it + renderImg() + } + } ?: renderImg() +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Card.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Card.kt new file mode 100644 index 0000000000..cc37a5963a --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Card.kt @@ -0,0 +1,74 @@ +/** + * Components for cards + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS", "MatchingDeclarationName") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.externals.fontawesome.FontAwesomeIconModule +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon + +import js.core.jso +import react.FC +import react.PropsWithChildren +import react.dom.html.ReactHTML.div +import web.cssom.ClassName +import web.cssom.Height +import web.cssom.Width + +/** + * Props for card component + */ +external interface CardProps : PropsWithChildren { + /** + * font-awesome class to be used as an icon + */ + var faIcon: FontAwesomeIconModule +} + +/** + * A functional `Component` for a card. + * + * @param isBordered adds a border to the card + * @param hasBg adds a white background + * @param isPaddingBottomNull disables bottom padding (pb-0) + * @param isNoPadding if true - removes all remaining padding (pt-0 pr-0 pl-0) + * @param isFilling + * @return a functional component representing a card + */ +@Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") +fun cardComponent( + isBordered: Boolean = false, + hasBg: Boolean = false, + isPaddingBottomNull: Boolean = false, + isNoPadding: Boolean = true, + isFilling: Boolean = false, +) = FC { props -> + val boarder = if (isBordered) "border-secondary" else "" + val card = if (hasBg) "card" else "" + val pb = if (isPaddingBottomNull) "pb-0" else "" + val paddingInside = if (isNoPadding) "pt-0 pr-0 pl-0" else "" + div { + className = ClassName("$card card-body mt-0 $paddingInside $pb $boarder") + if (isFilling) { + style = jso { + width = "100%".unsafeCast() + height = "100%".unsafeCast() + } + } + div { + className = ClassName("col pr-0 pl-0") + div { + className = ClassName("mb-0 text-gray-800") + props.children?.let { +it } + } + } + if (props.faIcon != undefined) { + div { + className = ClassName("col-auto") + fontAwesomeIcon(icon = props.faIcon, classes = "fas fa-2x text-gray-300") + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Carousel.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Carousel.kt new file mode 100644 index 0000000000..92ab4d86a2 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Carousel.kt @@ -0,0 +1,112 @@ +/** + * Carousel extension function + */ + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.themes.Colors +import js.core.jso +import react.CSSProperties +import react.ChildrenBuilder +import react.dom.aria.AriaRole +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.ol +import react.dom.html.ReactHTML.span +import web.cssom.* + +private const val INVERT_TO_OPPOSITE = 100 + +/** + * @param items + * @param carouselBodyId + * @param styles + * @param outerClasses + * @param isIndicated if true, carousel card indicator is displayed in a bottom of a carousel + * @param displayItem + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.carousel( + items: List, + carouselBodyId: String, + styles: CSSProperties? = null, + outerClasses: String = "", + isIndicated: Boolean = true, + displayItem: ChildrenBuilder.(T) -> Unit, +) { + div { + className = ClassName("carousel slide flex-md-row box-shadow $outerClasses") + if (isIndicated && items.size > 1) { + ol { + className = ClassName("carousel-indicators mt-2 mb-2") + items.forEachIndexed { index, _ -> + li { + if (index == 0) { + className = ClassName("active") + } + style = jso { this.backgroundColor = Colors.GREY.unsafeCast() } + asDynamic()["data-target"] = "#$carouselBodyId" + asDynamic()["data-slide-to"] = index + style = jso { + borderRadius = 1.em + height = 0.em + width = 0.em + border = "0.25rem solid #808080".unsafeCast() + } + } + } + } + } + style = styles + id = carouselBodyId + asDynamic()["data-ride"] = "carousel" + + div { + className = ClassName("carousel-inner my-auto") + items.forEachIndexed { i, item -> + val classes = if (i == 0) "active" else "" + slide(classes) { displayItem(item) } + } + } + if (items.size > 1) { + carouselArrows(carouselBodyId) + } + } +} + +/** + * @param classes + * @param displayItem + */ +fun ChildrenBuilder.slide(classes: String, displayItem: ChildrenBuilder.() -> Unit) { + div { + className = ClassName("carousel-item $classes") + div { + className = ClassName("row mt-auto") + displayItem() + } + } +} + +/** + * @param carouselBodyId + */ +fun ChildrenBuilder.carouselArrows(carouselBodyId: String) { + a { + style = jso { filter = invert(INVERT_TO_OPPOSITE) } + className = ClassName("carousel-control-prev ") + href = "#$carouselBodyId" + role = "button".unsafeCast() + asDynamic()["data-slide"] = "prev" + span { className = ClassName("carousel-control-prev-icon") } + } + a { + style = jso { filter = invert(INVERT_TO_OPPOSITE) } + className = ClassName("carousel-control-next") + href = "#$carouselBodyId" + role = "button".unsafeCast() + asDynamic()["data-slide"] = "next" + span { className = ClassName("carousel-control-next-icon") } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/CookieBanner.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/CookieBanner.kt new file mode 100644 index 0000000000..a99681db20 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/CookieBanner.kt @@ -0,0 +1,52 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.components.modal.MAX_Z_INDEX +import com.saveourtool.save.frontend.common.externals.cookie.acceptCookies +import com.saveourtool.save.frontend.common.externals.cookie.cookie +import com.saveourtool.save.frontend.common.externals.cookie.declineCookies +import com.saveourtool.save.frontend.common.externals.cookie.isAccepted +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.validation.FrontendRoutes +import js.core.jso +import react.FC +import react.Props +import react.dom.html.ReactHTML.div +import react.router.useNavigate +import react.useState +import web.cssom.ClassName +import web.cssom.ZIndex + +val cookieBanner: FC = FC { + val (isOpen, setIsOpen) = useState(!cookie.isAccepted()) + val navigate = useNavigate() + val (t) = useTranslation("cookies") + + if (isOpen) { + div { + className = ClassName("fixed-bottom bg-light px-4 d-flex justify-content-between align-items-center") + style = jso { + zIndex = (MAX_Z_INDEX - 1).unsafeCast() + } + div { + className = ClassName("pt-2") + markdown("We value your privacy".t().trimIndent()) + } + div { + buttonBuilder("Decline".t(), "secondary", classes = "mx-2") { + cookie.declineCookies() + setIsOpen(false) + } + buttonBuilder("Read more".t(), "info", classes = "mx-2") { + navigate("/${FrontendRoutes.COOKIE}") + } + buttonBuilder("Accept".t(), classes = "mx-2") { + cookie.acceptCookies() + setIsOpen(false) + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Forum.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Forum.kt new file mode 100644 index 0000000000..d93c243852 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Forum.kt @@ -0,0 +1,241 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.entities.CommentDto +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.externals.fontawesome.faPaperPlane +import com.saveourtool.save.frontend.common.externals.fontawesome.faTimes +import com.saveourtool.save.frontend.common.externals.i18next.TranslationFunction +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.utils.toUnixCalendarFormat +import com.saveourtool.save.validation.FrontendRoutes + +import js.core.jso +import react.ChildrenBuilder +import react.FC +import react.PropsWithChildren +import react.dom.aria.ariaDescribedBy +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.textarea +import react.router.dom.Link +import react.useState +import web.cssom.* + +import kotlinx.browser.window +import kotlinx.datetime.TimeZone +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * @return a function component + */ +@Suppress("MAGIC_NUMBER") +val newCommentWindow: FC = FC { props -> + val (comment, setComment) = useState(CommentDto.empty) + val (t) = useTranslation("comments") + + val enrollRequest = useDeferredRequest { + val commentNew = comment.copy(section = window.location.pathname) + val response = post( + url = "$apiUrl/comments/save", + headers = jsonHeaders, + body = Json.encodeToString(commentNew), + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + if (response.ok) { + props.addComment() + setComment(CommentDto.empty) + } + } + + div { + className = ClassName("shadow col mx-auto border border-secondary card card-body p-0") + div { + className = ClassName("row no-gutters mx-auto input-group px-0 shadow-none") + renderLeftColumn( + props.currentUserInfo.avatar, + props.currentUserInfo.name, + props.currentUserInfo.rating, + t, + "#f7f5fb", + ) + div { + className = ClassName("col") + textarea { + className = ClassName("form-control p-3 border-0") + style = jso { + width = "100%".unsafeCast() + height = "100%".unsafeCast() + } + onChange = { event -> setComment { it.copy(message = event.target.value) } } + value = comment.message + ariaDescribedBy = "${InputTypes.COMMENT.name}Span" + rows = 5 + id = InputTypes.COMMENT.name + required = true + placeholder = "Write a comment".t() + } + } + } + div { + className = ClassName("d-flex justify-content-end p-2") + style = jso { background = "#f7f5fb".unsafeCast() } + buttonBuilder( + faPaperPlane, + isDisabled = comment.message.isBlank(), + classes = "rounded-circle btn-sm", + isOutline = true, + ) { + enrollRequest() + } + } + } +} + +/** + * [FC] for comment displaying + */ +@Suppress("MAGIC_NUMBER") +val commentWindow: FC = FC { props -> + val (t) = useTranslation("comments") + div { + className = ClassName("shadow input-group row no-gutters mx-auto border-secondary") + renderLeftColumn( + props.comment.userAvatar, + props.comment.userName, + props.comment.userRating, + t, + ) + div { + className = ClassName("shadow-none card col-10 text-left border-0") + val comment = props.comment + div { + className = ClassName("flex-wrap d-flex justify-content-between") + style = jso { background = "#f1f1f1".unsafeCast() } + span { + className = ClassName("ml-1") + +(comment.createDate?.toUnixCalendarFormat(TimeZone.currentSystemDefault()) ?: "Unknown".t()) + } + div { + if (props.currentUserInfo?.canDelete(props.comment) == true) { + buttonBuilder(faTimes, style = "", classes = "btn-sm") { + if (window.confirm("Are you sure you want to delete a comment?".t())) { + props.setCommentForDeletion(props.comment) + } + } + } + } + } + div { + className = ClassName("shadow-none card card-body border-0") + markdown(comment.message.split("\n").joinToString("\n\n")) + } + } + } +} + +/** + * Props for comment card component + */ +external interface CommentWindowProps : PropsWithChildren { + /** + * User comment + */ + var comment: CommentDto + + /** + * [UserInfo] of current user + */ + var currentUserInfo: UserInfo? + + /** + * Callback invoked to set selected comment for deletion + */ + var setCommentForDeletion: (CommentDto) -> Unit +} + +/** + * Props for new comment card component + */ +external interface NewCommentWindowProps : PropsWithChildren { + /** + * Callback invoked when added new comment + */ + var addComment: () -> Unit + + /** + * Information about current user + */ + var currentUserInfo: UserInfo +} + +private fun UserInfo.canDelete(commentDto: CommentDto) = isSuperAdmin() || name == commentDto.userName + +@Suppress("MAGIC_NUMBER", "IDENTIFIER_LENGTH") +private fun ChildrenBuilder.renderLeftColumn( + userAvatar: String?, + name: String, + rating: Long, + t: TranslationFunction, + color: String = "#f1f1f1", +) { + val (avatar, setAvatar) = useState(userAvatar?.avatarRenderer() ?: AVATAR_PROFILE_PLACEHOLDER) + + div { + className = ClassName("input-group-prepend col-2") + style = jso { + background = color.unsafeCast() + } + div { + className = ClassName("mb-0 font-weight-bold text-gray-800") + form { + div { + className = ClassName("row justify-content-center g-3 ml-3 mr-3 pb-2 pt-2 border-bottom-0") + div { + className = ClassName("md-4 pl-0 pr-0") + Link { + img { + className = ClassName("avatar avatar-user width-full border color-bg-default rounded-circle") + src = avatar + height = 80.0 + width = 80.0 + onError = { setAvatar(AVATAR_PLACEHOLDER) } + } + to = "/${FrontendRoutes.VULN_PROFILE}/$name" + } + } + div { + className = ClassName("row mt-2 md-6 pl-0") + style = jso { + display = Display.flex + alignItems = AlignItems.center + } + div { + className = ClassName("col-12 text-center text-xs font-weight-bold text-info text-uppercase") + +"Rating".t() + } + div { + className = ClassName("col-12 text-center text-xs") + +rating.toString() + } + h1 { + className = ClassName("col-12 text-center font-weight-bold h5") + Link { + to = "/${FrontendRoutes.VULN_PROFILE}/$name" + +name + } + } + } + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/LanguageSelector.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/LanguageSelector.kt new file mode 100644 index 0000000000..7079283d2d --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/LanguageSelector.kt @@ -0,0 +1,68 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.PlatformLanguages +import com.saveourtool.save.frontend.common.externals.cookie.cookie +import com.saveourtool.save.frontend.common.externals.cookie.getLanguageCode +import com.saveourtool.save.frontend.common.externals.cookie.isAccepted +import com.saveourtool.save.frontend.common.externals.i18next.changeLanguage +import com.saveourtool.save.frontend.common.externals.i18next.language +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import js.core.jso +import react.FC +import react.Props +import react.dom.aria.AriaHasPopup +import react.dom.aria.ariaExpanded +import react.dom.aria.ariaHasPopup +import react.dom.aria.ariaLabelledBy +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.span +import react.useEffect +import react.useState +import web.cssom.ClassName +import web.cssom.Cursor + +private const val LANG_DROPDOWN_ID = "lang-dropdown" + +/** + * A [FC] that is responsible for language selection + */ +val languageSelector: FC = FC { + val (_, i18n) = useTranslation("topbar") + val languageFromCookie = if (cookie.isAccepted()) { + PlatformLanguages.getByCodeOrDefault(cookie.getLanguageCode()) + } else { + PlatformLanguages.defaultLanguage + } + val (language, setSelectedLanguage) = useState(languageFromCookie) + + useEffect(language) { i18n.changeLanguage(language) } + + div { + className = ClassName("dropdown") + a { + className = ClassName("dropdown-toggle text-light") + id = LANG_DROPDOWN_ID + asDynamic()["data-toggle"] = "dropdown" + ariaHasPopup = true.unsafeCast() + ariaExpanded = false + style = jso { cursor = "pointer".unsafeCast() } + span { +i18n.language().label } + } + + div { + className = ClassName("dropdown-menu") + ariaLabelledBy = LANG_DROPDOWN_ID + PlatformLanguages.values().map { language -> + a { + className = ClassName("dropdown-item") + style = jso { cursor = "pointer".unsafeCast() } + onClick = { setSelectedLanguage(language) } + span { +language.label } + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ManageUserRoleCard.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ManageUserRoleCard.kt new file mode 100644 index 0000000000..c7be4478b5 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ManageUserRoleCard.kt @@ -0,0 +1,227 @@ +/** + * Components for cards + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS", "MatchingDeclarationName") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.domain.Role.OWNER +import com.saveourtool.save.frontend.common.components.inputform.inputWithDebounceForUserInfo +import com.saveourtool.save.frontend.common.components.inputform.renderUserWithAvatar +import com.saveourtool.save.frontend.common.externals.fontawesome.faTimesCircle +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.permission.SetRoleRequest +import com.saveourtool.save.utils.getHighestRole + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.option +import react.dom.html.ReactHTML.select +import web.cssom.ClassName +import web.cssom.Height +import web.cssom.Width + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * A functional `Component` for a card that shows users from the group and their permissions. + * + * @return a functional component representing a role managing card + */ +@Suppress( + "LongMethod", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "TOO_LONG_FUNCTION", + "MAGIC_NUMBER", +) +val manageUserRoleCardComponent: FC = FC { props -> + val (usersFromGroup, setUsersFromGroup) = useState(emptyList()) + val getUsersFromGroup = useDeferredRequest { + val usersFromDb = get( + url = "$apiUrl/${props.groupType}s/${props.groupPath}/users", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .unsafeMap { + it.decodeFromJsonString>() + } + setUsersFromGroup(usersFromDb) + } + + val (roleChange, setRoleChange) = useState(SetRoleRequest("", Role.NONE)) + val updatePermissions = useDeferredRequest { + val response = post( + "$apiUrl/${props.groupType}s/${props.groupPath}/users/roles", + jsonHeaders, + Json.encodeToString(roleChange), + loadingHandler = ::noopLoadingHandler, + ) + if (response.ok) { + getUsersFromGroup() + } + } + + val (userToAdd, setUserToAdd) = useState(UserInfo(name = "")) + val addUserToGroup = useDeferredRequest { + val response = post( + url = "$apiUrl/${props.groupType}s/${props.groupPath}/users/roles", + headers = jsonHeaders, + body = Json.encodeToString(SetRoleRequest(userToAdd.name, Role.VIEWER)), + loadingHandler = ::loadingHandler, + ) + if (response.ok) { + setUserToAdd(UserInfo(name = "")) + getUsersFromGroup() + } + } + + val (userToDelete, setUserToDelete) = useState(UserInfo(name = "")) + val deleteUser = useDeferredRequest { + val response = delete( + url = "$apiUrl/${props.groupType}s/${props.groupPath}/users/roles/${userToDelete.name}", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + if (response.ok) { + getUsersFromGroup() + } + } + + val (selfRole, setSelfRole) = useState(Role.NONE) + useRequest { + val role = get( + "$apiUrl/${props.groupType}s/${props.groupPath}/users/roles", + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .unsafeMap { it.decodeFromJsonString() } + .toRole() + if (role.isLowerThan(OWNER) && props.selfUserInfo.isSuperAdmin()) { + showGlobalRoleConfirmation() + } + setSelfRole(getHighestRole(role, props.selfUserInfo.globalRole)) + } + + useOnce { getUsersFromGroup() } + + div { + className = ClassName("card border card-body mt-0") + div { + className = ClassName("row mb-2 mx-2 shadow-sm rounded") + inputWithDebounceForUserInfo { + selectedOption = userToAdd + setSelectedOption = { setUserToAdd(it) } + onOptionClick = { + setUserToAdd(it) + addUserToGroup() + } + getUrlForOptionsFetch = { prefix -> "$apiUrl/${props.groupType}s/${props.groupPath}/users/not-from?prefix=$prefix" } + placeholder = "Input name..." + renderOption = ::renderUserWithAvatar + } + } + div { + for (user in usersFromGroup) { + val userName = user.name + val userRole = props.getUserGroups(user)[props.groupPath] ?: Role.VIEWER + val userIndex = usersFromGroup.indexOf(user) + 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") + img { + className = ClassName("avatar avatar-user border color-bg-default rounded-circle pl-0") + src = user.avatar?.let(String::avatarRenderer) + ?: "/img/undraw_profile.svg" + style = jso { + width = "2rem".unsafeCast() + height = "2rem".unsafeCast() + } + } + } + div { + className = ClassName("col-7 text-left align-self-center pl-0") + +userName + } + } + div { + className = ClassName("col-5 align-self-right d-flex align-items-center justify-content-end pr-0") + select { + className = ClassName("custom-select col-9") + onChange = { event -> + setRoleChange { SetRoleRequest(userName, event.target.value.toRole()) } + updatePermissions() + } + value = userRole.formattedName + id = "role-$userIndex" + rolesAssignableBy(selfRole) + .sortedByDescending { + it.priority + } + .map { + option { + value = it.formattedName + +it.formattedName + } + } + disabled = (selfRole == OWNER && isSelfRecord(props.selfUserInfo, user)) || + !(selfRole.isHigherOrEqualThan(OWNER) || userRole.isLowerThan(selfRole)) + } + div { + className = ClassName("col-2 align-items-center mr-2") + val canDelete = selfRole.isSuperAdmin() || + selfRole == OWNER && !isSelfRecord(props.selfUserInfo, user) || + userRole.isLowerThan(selfRole) + if (canDelete) { + buttonBuilder(faTimesCircle, "") { + setUserToDelete(usersFromGroup[userIndex]) + deleteUser() + } + } + } + } + } + } + } + } +} + +/** + * [Props] for card component + */ +external interface ManageUserRoleCardProps : Props { + /** + * Information about user who is seeing the view + */ + var selfUserInfo: UserInfo + + /** + * Full name of a group + */ + var groupPath: String + + /** + * Kind of a group that will be shown ("project" or "organization" for now) + */ + var groupType: String + + /** + * Lambda to get users from project/organization + */ + var getUserGroups: (UserInfo) -> Map +} + +private fun isSelfRecord(selfUserInfo: UserInfo, otherUserInfo: UserInfo) = otherUserInfo.name == selfUserInfo.name + +private fun rolesAssignableBy(role: Role) = Role.values() + .filter { it != Role.NONE } + .filterNot(Role::isSuperAdmin) + .filter { role == OWNER || it.isLowerThan(role) || role == it } diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Markdown.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Markdown.kt new file mode 100644 index 0000000000..c6c0a17069 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/Markdown.kt @@ -0,0 +1,22 @@ +/** + * Simple markdown render + */ + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.externals.markdown.reactMarkdown +import js.core.jso +import react.ChildrenBuilder + +/** + * Simple [ChildrenBuilder] extension function to display [text] as markdown + * + * @param text text that should be interpreted as text in Markdown format + * @param classes class names that should be applied to high-level div + */ +fun ChildrenBuilder.markdown(text: String, classes: String? = null) { + +reactMarkdown(jso { + this.children = text + this.className = classes + }) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ScoreCard.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ScoreCard.kt new file mode 100644 index 0000000000..ea5820761f --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ScoreCard.kt @@ -0,0 +1,120 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", "FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.utils.toFixedStr + +import js.core.jso +import react.FC +import react.Props +import react.dom.aria.AriaRole +import react.dom.aria.ariaValueMax +import react.dom.aria.ariaValueMin +import react.dom.aria.ariaValueNow +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h6 +import react.router.dom.Link +import web.cssom.* + +/** + * Functional component for project score demonstration + * + * @return ReactElement + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod", "MAGIC_NUMBER") +val scoreCard: FC = FC { props -> + div { + className = ClassName("card border-left-info shadow h-70 py-2") + div { + className = ClassName("card-body") + div { + className = ClassName("row no-gutters align-items-center") + div { + className = ClassName("col-12 row mr-2") + style = jso { + justifyContent = JustifyContent.spaceAround + display = Display.flex + alignItems = AlignItems.center + } + div { + className = ClassName("col-1") + div { + className = ClassName("text-xs font-weight-bold text-info text-uppercase mb-1 ml-2 justify-content-center") + style = jso { + display = Display.flex + alignItems = AlignItems.center + alignSelf = AlignSelf.start + } + +"Rating" + } + div { + className = ClassName("text-center h5 mb-0 font-weight-bold text-gray-800 mt-1 ml-2") + style = jso { + justifyContent = JustifyContent.center + display = Display.flex + alignItems = AlignItems.center + alignSelf = AlignSelf.start + } + +props.contestScore.toFixedStr(2) + } + } + div { + className = ClassName("col-10") + div { + h6 { + style = jso { + justifyContent = JustifyContent.center + display = Display.flex + alignItems = AlignItems.center + alignSelf = AlignSelf.center + } + + props.url?.let { link -> + Link { + to = link + +props.name + } + } ?: run { + +props.name + } + } + } + div { + className = ClassName("progress progress-sm mr-2") + div { + className = ClassName("progress-bar bg-info") + role = "progressbar".unsafeCast() + style = jso { + width = "${props.contestScore}%".unsafeCast() + } + ariaValueNow = props.contestScore + ariaValueMin = 0.0 + ariaValueMax = 100.0 + } + } + } + } + } + } + } +} + +/** + * ProjectScoreCardProps component props + */ +external interface ScoreCardProps : Props { + /** + * Name of a current project or contest (acts as a card header) + */ + var name: String + + /** + * Score of a project in a contest + */ + var contestScore: Double + + /** + * Url to the project + */ + var url: String? +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ScrollToTopButton.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ScrollToTopButton.kt new file mode 100644 index 0000000000..e064679126 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/ScrollToTopButton.kt @@ -0,0 +1,44 @@ +/** + * Scroll-to-top button component + */ + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.externals.fontawesome.faAngleUp +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon + +import org.w3c.dom.SMOOTH +import org.w3c.dom.ScrollBehavior +import org.w3c.dom.ScrollToOptions +import react.* +import react.dom.html.ReactHTML.a +import web.cssom.ClassName + +import kotlinx.browser.document +import kotlinx.browser.window + +/** + * Renders scroll to top button + */ +val scrollToTopButton = scrollToTopButton() + +@Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") +private fun scrollToTopButton() = FC { + val (isVisible, setIsVisible) = useState(false) + + useEffect { + document.addEventListener("scroll", callback = { + setIsVisible(window.pageYOffset > 100) + }) + } + + if (isVisible) { + a { + className = ClassName("scroll-to-top rounded") + onClick = { + window.scrollTo(ScrollToOptions(top = 0.0, behavior = ScrollBehavior.SMOOTH)) + } + fontAwesomeIcon(icon = faAngleUp) + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/SdkSelection.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/SdkSelection.kt new file mode 100644 index 0000000000..9601b111ea --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/SdkSelection.kt @@ -0,0 +1,111 @@ +/** + * Component for SDK selection + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS", "WildcardImport", "FILE_WILDCARD_IMPORTS") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.domain.* +import com.saveourtool.save.frontend.common.utils.selectorBuilder +import com.saveourtool.save.frontend.common.utils.useStateFromProps + +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.label +import web.cssom.ClassName +import web.html.HTMLSelectElement + +/** + * Component for sdk selection + */ +val sdkSelection: FC = FC { props -> + val (sdkName, setSdkName) = useStateFromProps(props.selectedSdk.getPrettyName()) + val (sdkVersion, setSdkVersion) = useStateFromProps(props.selectedSdk.version) + + if (props.title.isNotBlank()) { + label { + className = + ClassName("control-label col-auto justify-content-between font-weight-bold text-gray-800 mb-1 pl-0") + +props.title + } + } + div { + className = ClassName("card align-items-left mb-3 pt-0 pb-0") + div { + className = ClassName("card-body align-items-left pb-1 pt-3") + div { + className = ClassName("row no-gutters align-items-left") + selection( + "SDK", + sdkName, + sdks, + isDisabled = props.isDisabled, + ) { element -> + val newSdkName = element.value + val newSdkVersion = newSdkName.getSdkVersions().first() + setSdkName(newSdkName) + setSdkVersion(newSdkVersion) + props.onSdkChange("$newSdkName:$newSdkVersion".toSdk()) + } + } + div { + className = ClassName("row no-gutters align-items-left") + className = ClassName("d-inline") + selection( + "Version", + sdkVersion, + sdkName.getSdkVersions(), + isDisabled = props.isDisabled, + ) { element -> + val newSdkVersion = element.value + setSdkVersion(newSdkVersion) + props.onSdkChange("$sdkName:$newSdkVersion".toSdk()) + } + } + } + } +} + +/** + * Props for SdkSelection component + */ +external interface SdkProps : PropsWithChildren { + /** + * Title for sdk selector + */ + var title: String + + /** + * The selected SDK + */ + var selectedSdk: Sdk + + /** + * Callback invoked when SDK is changed + */ + var onSdkChange: (Sdk) -> Unit + + /** + * Flag to disable sdk selection + */ + var isDisabled: Boolean +} + +private fun ChildrenBuilder.selection( + labelValue: String, + value: String, + options: List, + isDisabled: Boolean, + onChangeFun: (HTMLSelectElement) -> Unit, +) = div { + className = ClassName("input-group mb-3") + div { + className = ClassName("input-group-prepend") + label { + className = ClassName("input-group-text") + +labelValue + } + } + selectorBuilder(value, options, "custom-select", isDisabled) { onChangeFun(it.target) } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/SelectForm.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/SelectForm.kt new file mode 100644 index 0000000000..ab85b45891 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/SelectForm.kt @@ -0,0 +1,190 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.utils.WithRequestStatusContext +import com.saveourtool.save.frontend.common.utils.useRequest + +import react.ChildrenBuilder +import react.FC +import react.Props +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.option +import react.dom.html.ReactHTML.select +import react.dom.html.ReactHTML.small +import react.dom.html.ReactHTML.span +import react.useState +import web.cssom.ClassName + +/** + * SelectFormRequired component props + */ +external interface SelectFormRequiredProps : Props { + /** + * Type of 'select' + */ + var formType: InputTypes + + /** + * Flag to valid select + */ + var validInput: Boolean? + + /** + * classes of 'select' + */ + var classes: String + + /** + * select name + */ + var formName: String? + + /** + * lambda invoked once to fetch data for selection + */ + var getData: suspend (WithRequestStatusContext) -> List + + /** + * Currently chosen field + */ + var selectedValue: String + + /** + * Method to get string that should be shown + */ + var dataToString: (D) -> String + + /** + * Message shown on invalid input + */ + var errorMessage: String? + + /** + * Message shown on no options fetched + */ + var notFoundErrorMessage: String? + + /** + * Flag that disables the form + */ + var disabled: Boolean? + + /** + * Add custom elements under the form label in order to create new item. + */ + var addNewItemChildrenBuilder: ((ChildrenBuilder) -> Unit)? + + /** + * Array of dependencies of [getData] [useRequest] + */ + var getDataRequestDependencies: Array + + /** + * Extra classes that should be under [select] tag + */ + var selectClasses: String + + /** + * Callback invoked when form is changed + */ + @Suppress("TYPE_ALIAS") + var onChangeFun: (D?) -> Unit +} + +/** + * @return [FC] of required selection input form + */ +@Suppress( + "TOO_MANY_PARAMETERS", + "TOO_LONG_FUNCTION", + "LongParameterList", + "TYPE_ALIAS", + "LongMethod", + "ComplexMethod" +) +fun selectFormRequired() = FC> { props -> + val (elements, setElements) = useState(listOf()) + + useRequest(props.getDataRequestDependencies) { + setElements((props.getData)(this)) + } + + div { + className = ClassName("${props.classes} mt-1") + props.formName?.let { formName -> + div { + className = ClassName("d-flex justify-content-between") + label { + className = ClassName("form-label") + htmlFor = props.formType.name + +formName + span { + className = ClassName("text-danger") + id = "${props.formType.name}Span" + +"*" + } + } + } + props.addNewItemChildrenBuilder?.let { addNewItemBuilder -> + small { + className = ClassName("text-right") + addNewItemBuilder(this) + } + } + } + + form { + className = ClassName("input-group needs-validation") + select { + id = props.formType.name + required = true + disabled = props.disabled + elements.find { + props.dataToString(it) == props.selectedValue + } ?: run { + option { + disabled = true + +"" + } + } + value = props.selectedValue + elements.forEach { element -> + option { + +props.dataToString(element) + } + } + className = when { + value == "" || value == null -> ClassName("form-control ${props.selectClasses}") + props.validInput == true -> ClassName("form-control ${props.selectClasses} is-valid") + props.validInput == false -> ClassName("form-control ${props.selectClasses} is-invalid") + else -> ClassName("form-control ${props.selectClasses}") + } + onChange = { event -> + elements.find { + props.dataToString(it) == event.target.value + }?.let { + props.onChangeFun(it) + } + } + } + + if (elements.isEmpty()) { + props.notFoundErrorMessage?.let { notFoundErrorMessage -> + div { + className = ClassName("invalid-feedback d-block") + +notFoundErrorMessage + } + } + } else if (props.validInput == false) { + div { + className = ClassName("invalid-feedback d-block") + +(props.errorMessage ?: "Please input a valid ${props.formType.str}") + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/UserBoard.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/UserBoard.kt new file mode 100644 index 0000000000..5afaed86db --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/UserBoard.kt @@ -0,0 +1,69 @@ +/** + * Components for cards + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS", "MatchingDeclarationName") + +package com.saveourtool.save.frontend.common.components.basic + +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.validation.FrontendRoutes + +import react.FC +import react.Props +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.figure +import web.cssom.ClassName +import web.cssom.Length +import web.cssom.rem + +/** + * A functional component to display users' avatars. + */ +@Suppress("MAGIC_NUMBER") +val userBoard: FC = FC { props -> + div { + className = ClassName("latest-photos") + div { + className = ClassName("row") + props.users.forEach { user -> + div { + className = ClassName(props.avatarOuterClasses.orEmpty()) + figure { + renderAvatar(user, props.avatarInnerClasses.orEmpty(), "/${FrontendRoutes.VULN_PROFILE}/${user.name}") { + // just some default values in case you don't want to provide value + // in this case you will get small avatar + width = props.widthAndHeight ?: 4.rem + height = props.widthAndHeight ?: 4.rem + } + } + } + } + } + } +} + +/** + * [Props] for [userBoard] component + */ +external interface UserBoardProps : Props { + /** + * list of users that should be displayed + */ + var users: List + + /** + * Classes that are applied to [div] that contains img tag + */ + var avatarOuterClasses: String? + + /** + * Classes that are applied to img tag + */ + var avatarInnerClasses: String? + + /** + * Size of avatar or any other properties + */ + var widthAndHeight: Length? +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/DefaultProgressBarComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/DefaultProgressBarComponent.kt new file mode 100644 index 0000000000..7a1be961ac --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/DefaultProgressBarComponent.kt @@ -0,0 +1,83 @@ +/** + * File containing progress bar functional component + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.fileuploader + +import com.saveourtool.save.frontend.common.utils.useTooltip +import js.core.jso +import react.FC +import react.Props +import react.dom.aria.AriaRole +import react.dom.html.ReactHTML.div +import react.useEffect +import web.cssom.ClassName +import web.cssom.Width + +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Progress Bar [FC] + */ +@Suppress("MAGIC_NUMBER") +val defaultProgressBarComponent: FC = FC { props -> + val scope = CoroutineScope(Dispatchers.Main) + useTooltip() + useEffect(props.currentProgress) { + if (props.currentProgress == 100) { + scope.launch { + delay(1500.milliseconds) + props.reset() + } + } + } + + if (props.currentProgress >= 0L) { + div { + className = ClassName("progress text-center") + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + asDynamic()["data-original-title"] = props.currentProgressMessage + title = props.currentProgressMessage + if (props.currentProgress == 100) { + className = ClassName("progress-bar bg-success") + role = "progressbar".unsafeCast() + style = jso { width = "100%".unsafeCast() } + +props.currentProgressMessage + } else if (props.currentProgress >= 0) { + div { + className = ClassName("progress-bar progress-bar-striped progress-bar-animated") + role = "progressbar".unsafeCast() + style = jso { width = "${props.currentProgress}%".unsafeCast() } + +props.currentProgressMessage + } + } + } + } +} + +/** + * [Props] for [defaultProgressBarComponent] + */ +external interface DefaultProgressBarComponent : Props { + /** + * Current progress + */ + var currentProgress: Int + + /** + * Current labelText + */ + var currentProgressMessage: String + + /** + * Callback invoked to reset [currentProgress] + */ + var reset: () -> Unit +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/FileUploaderUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/FileUploaderUtils.kt new file mode 100644 index 0000000000..ca8b19563c --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/FileUploaderUtils.kt @@ -0,0 +1,67 @@ +/** + * This class contains methods for FileUploader + */ + +package com.saveourtool.save.frontend.common.components.basic.fileuploader + +import com.saveourtool.save.frontend.common.externals.fontawesome.faDownload +import com.saveourtool.save.frontend.common.externals.fontawesome.faTrash +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon + +import react.ChildrenBuilder +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.button +import web.cssom.ClassName +import web.html.ButtonType + +import kotlinx.browser.window + +/** + * Creates a button to download a file + * + * @param file + * @param getFileName + * @param getDownloadUrl + */ +fun ChildrenBuilder.downloadFileButton( + file: F, + getFileName: (F) -> String, + getDownloadUrl: (F) -> String, +) { + a { + button { + type = ButtonType.button + className = ClassName("btn") + fontAwesomeIcon(icon = faDownload) + } + download = getFileName(file) + href = getDownloadUrl(file) + } +} + +/** + * Creates a button to delete a file + * + * @param file + * @param getFileName + * @param deleteFile + */ +fun ChildrenBuilder.deleteFileButton( + file: F, + getFileName: (F) -> String, + deleteFile: (F) -> Unit, +) { + button { + type = ButtonType.button + className = ClassName("btn") + fontAwesomeIcon(icon = faTrash) + onClick = { + val confirm = window.confirm( + "Are you sure you want to delete ${getFileName(file)} file?" + ) + if (confirm) { + deleteFile(file) + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/ProgressBarComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/ProgressBarComponent.kt new file mode 100644 index 0000000000..340f5d52f0 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/ProgressBarComponent.kt @@ -0,0 +1,51 @@ +/** + * File containing progress bar functional component + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.fileuploader + +import com.saveourtool.save.utils.toKilobytes +import react.FC +import react.Props + +/** + * Progress Bar [FC] + */ +@Suppress("MAGIC_NUMBER") +val progressBarComponent: FC = FC { props -> + defaultProgressBarComponent { + currentProgress = if (props.total == props.current && props.total == 0L) { + -1 + } else { + (100 * props.current / props.total).toInt() + } + currentProgressMessage = if (props.current == props.total && props.total != 0L) { + "Successfully uploaded ${props.total.toKilobytes()} KB." + } else { + "${props.current.toKilobytes()} / ${props.total.toKilobytes()} KB" + } + reset = props.flushCounters + } +} + +/** + * [Props] for [progressBarComponent] + */ +external interface ProgressBarComponentProps : Props { + /** + * Amount of entity that is already marked as done (uploaded, downloaded, fixed, etc.) + */ + var current: Long + + /** + * Total amount of entity + */ + var total: Long + + /** + * Callback invoked to flush [current] and [total] + */ + var flushCounters: () -> Unit +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/SimpleFileUploader.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/SimpleFileUploader.kt new file mode 100644 index 0000000000..5de924a081 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/fileuploader/SimpleFileUploader.kt @@ -0,0 +1,123 @@ +/** + * Component for uploading files (FileDtos) + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.fileuploader + +import com.saveourtool.save.entities.FileDto +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.fontawesome.faTimes +import com.saveourtool.save.frontend.common.utils.* + +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.ul +import web.cssom.ClassName + +@Suppress( + "TOO_LONG_FUNCTION", + "TYPE_ALIAS", + "LongMethod", + "ComplexMethod", +) +val simpleFileUploader: FC = FC { props -> + useTooltip() + val (selectedFiles, setSelectedFiles) = useState>(emptyList()) + val (availableFiles, setAvailableFiles) = useState>(emptyList()) + + useEffect(selectedFiles) { props.fileDtosSetter { selectedFiles } } + + @Suppress("TOO_MANY_LINES_IN_LAMBDA") + (useRequest { + props.getUrlForSelectedFilesFetch?.let { + val response = get( + it(), + jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + if (response.ok) { + setSelectedFiles(response.decodeFromJsonString>()) + } + } + }) + + useRequest(arrayOf(selectedFiles)) { + props.getUrlForAvailableFilesFetch?.invoke()?.let { url -> + val response = get( + url, + jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + if (response.ok) { + val presentNames = selectedFiles.map { it.name } + response.decodeFromJsonString>() + .let { fileDtos -> fileDtos.filter { fileDto -> fileDto.name !in presentNames }.distinctBy { it.name } } + .let(setAvailableFiles::invoke) + } + } + } + div { + ul { + className = ClassName("list-group") + + // ===== SELECTOR ===== + li { + className = ClassName("list-group-item d-flex justify-content-between align-items-center") + selectorBuilder( + "Select a file from existing", + availableFiles.map { it.name }.plus("Select a file from existing"), + classes = "form-control custom-select", + isDisabled = props.isDisabled, + ) { event -> + val availableFile = availableFiles.first { it.name == event.target.value } + setSelectedFiles { it.plus(availableFile) } + setAvailableFiles { it.minus(availableFile) } + } + } + + // ===== SELECTED FILES ===== + selectedFiles.map { file -> + li { + className = ClassName("list-group-item") + buttonBuilder(faTimes, null, isDisabled = props.isDisabled) { + setSelectedFiles { it.minus(file) } + setAvailableFiles { files -> files.plus(file) } + } + +file.name + } + } + } + } +} + +typealias FileDtosSetter = StateSetter> + +/** + * Props for simpleFileUploader + */ +external interface SimpleFileUploaderProps : Props { + /** + * Callback to get url to get available files + */ + var getUrlForAvailableFilesFetch: (() -> String)? + + /** + * Callback to get url to get files that are already selected + */ + var getUrlForSelectedFilesFetch: (() -> String)? + + /** + * Callback to update list of selected file ids + */ + var fileDtosSetter: FileDtosSetter + + /** + * Flag that defines if the uploader is disabled + */ + var isDisabled: Boolean +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/NameFiltersRow.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/NameFiltersRow.kt new file mode 100644 index 0000000000..6925e1e4ea --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/NameFiltersRow.kt @@ -0,0 +1,81 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.table.filters + +import com.saveourtool.save.frontend.common.externals.fontawesome.faFilter +import com.saveourtool.save.frontend.common.externals.fontawesome.faSearch +import com.saveourtool.save.frontend.common.externals.fontawesome.faWindowClose +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import react.FC +import react.Props +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.useEffect +import react.useState +import web.cssom.ClassName +import web.html.InputType + +val nameFiltersRow: FC = FC { props -> + val (filtersName, setFiltersName) = useState(props.name) + useEffect(props.name) { + if (filtersName != props.name) { + setFiltersName(props.name) + } + } + + div { + className = ClassName("container-fluid") + div { + className = ClassName("row d-flex") + + div { + className = ClassName("col-0 mr-3 align-self-center") + fontAwesomeIcon(icon = faFilter) + } + div { + className = ClassName("col-auto align-self-center") + +"Name: " + } + div { + className = ClassName("col-8") + input { + type = InputType.text + className = ClassName("form-control") + value = filtersName ?: "" + required = false + onChange = { + setFiltersName(it.target.value) + } + } + } + + div { + className = ClassName("col-auto ml-auto d-flex justify-content-between align-items-center") + buttonBuilder(faSearch, "secondary", isOutline = true, classes = "mr-1 btn-sm") { + props.onChangeFilters(filtersName) + } + + buttonBuilder(faWindowClose, "secondary", isOutline = true, classes = "ml-1 btn-sm") { + setFiltersName(null) + props.onChangeFilters(null) + } + } + } + } +} + +/** + * [Props] for filters name + */ +external interface NameFilterRowProps : Props { + /** + * All filters in one class property [name] + */ + var name: String? + + /** + * lambda to change [name] + */ + var onChangeFilters: (String?) -> Unit +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/TestExecutionFilter.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/TestExecutionFilter.kt new file mode 100644 index 0000000000..ab6dfe8a29 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/TestExecutionFilter.kt @@ -0,0 +1,170 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.basic.table.filters + +import com.saveourtool.save.domain.TestResultStatus +import com.saveourtool.save.filters.TestExecutionFilter +import com.saveourtool.save.frontend.common.externals.fontawesome.faFilter +import com.saveourtool.save.frontend.common.externals.fontawesome.faSearch +import com.saveourtool.save.frontend.common.externals.fontawesome.faTrashAlt +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import react.FC +import react.Props +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.option +import react.dom.html.ReactHTML.select +import react.useEffect +import react.useState +import web.cssom.ClassName +import web.html.ButtonType +import web.html.InputType + +const val ANY = "ANY" + +/** + * A row of filter selectors for table with `TestExecutionDto`s. Currently, filters are "status" and "test suite". + * + * @return a function component + */ +@Suppress("LongMethod", "TOO_LONG_FUNCTION") +val testExecutionFiltersRow: FC = FC { props -> + // Store local copy of filters in order to perform searching only by the search button, and not by any change in the filter fields + val (filters, setFilters) = useState(props.filters) + useEffect(props.filters) { + if (filters !== props.filters) { + setFilters(props.filters) + } + } + div { + className = ClassName("container-fluid") + div { + className = ClassName("row d-flex justify-content-between") + div { + className = ClassName("col-0 pr-1 align-self-center") + fontAwesomeIcon(icon = faFilter) + } + div { + className = ClassName("row") + div { + className = ClassName("col-auto align-self-center") + +"Status: " + } + div { + className = ClassName("col-auto") + select { + className = ClassName("form-control custom-select") + val elements = TestResultStatus.values().map { it.name }.toMutableList() + elements.add(0, ANY) + value = filters.status?.name ?: ANY + elements.forEach { element -> + option { + if (element == props.filters.status?.name) { + selected = true + } + +element + } + } + onChange = { + if (it.target.value == ANY) { + setFilters(filters.copy(status = null)) + } else { + setFilters(filters.copy(status = TestResultStatus.valueOf(it.target.value))) + } + } + } + } + } + div { + className = ClassName("row") + div { + className = ClassName("col-auto align-self-center") + +"File name: " + } + div { + className = ClassName("col-auto") + input { + type = InputType.text + className = ClassName("form-control") + value = filters.fileName ?: "" + required = false + onChange = { + setFilters(filters.copy(fileName = it.target.value)) + } + } + } + } + div { + className = ClassName("row") + div { + className = ClassName("col-auto align-self-center") + +"Test suite: " + } + div { + className = ClassName("col-auto") + input { + type = InputType.text + className = ClassName("form-control") + value = filters.testSuite ?: "" + required = false + onChange = { + setFilters(filters.copy(testSuite = it.target.value)) + } + } + } + } + div { + className = ClassName("row") + div { + className = ClassName("col-auto align-self-center") + +"Tags: " + } + div { + className = ClassName("col-auto") + input { + type = InputType.text + className = ClassName("form-control") + value = filters.tag ?: "" + required = false + onChange = { + setFilters(filters.copy(tag = it.target.value)) + } + } + } + } + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary") + fontAwesomeIcon(icon = faSearch, classes = "trash-alt") + onClick = { + props.onChangeFilters(filters) + } + } + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary") + fontAwesomeIcon(icon = faTrashAlt, classes = "trash-alt") + onClick = { + setFilters(TestExecutionFilter.empty) + props.onChangeFilters(TestExecutionFilter.empty) + } + } + } + } +} + +/** + * [Props] for filters value + */ +external interface FiltersRowProps : Props { + /** + * All filters in one class property [filters] + */ + var filters: TestExecutionFilter + + /** + * lambda to change [filters] + */ + var onChangeFilters: (TestExecutionFilter) -> Unit +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/table/filters/VulnerabilitiesFiltersRow.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/VulnerabilitiesFiltersRow.kt similarity index 94% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/table/filters/VulnerabilitiesFiltersRow.kt rename to save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/VulnerabilitiesFiltersRow.kt index 656d12108d..66531c01b5 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/table/filters/VulnerabilitiesFiltersRow.kt +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/basic/table/filters/VulnerabilitiesFiltersRow.kt @@ -1,20 +1,20 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.basic.table.filters +package com.saveourtool.save.frontend.common.components.basic.table.filters import com.saveourtool.save.entities.OrganizationDto import com.saveourtool.save.entities.vulnerability.VulnerabilityLanguage import com.saveourtool.save.entities.vulnerability.VulnerabilityStatus import com.saveourtool.save.filters.VulnerabilityFilter -import com.saveourtool.save.frontend.components.inputform.* -import com.saveourtool.save.frontend.components.inputform.renderUserWithAvatar -import com.saveourtool.save.frontend.components.tables.TABLE_HEADERS_LOCALE_NAMESPACE -import com.saveourtool.save.frontend.components.views.vuln.component.uploadCosvButton -import com.saveourtool.save.frontend.externals.fontawesome.* -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.externals.slider.multiRangeSlider -import com.saveourtool.save.frontend.themes.Colors -import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.common.components.inputform.* +import com.saveourtool.save.frontend.common.components.inputform.renderUserWithAvatar +import com.saveourtool.save.frontend.common.components.tables.TABLE_HEADERS_LOCALE_NAMESPACE +import com.saveourtool.save.frontend.common.components.views.vuln.uploadCosvButton +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.externals.slider.multiRangeSlider +import com.saveourtool.save.frontend.common.themes.Colors +import com.saveourtool.save.frontend.common.utils.* import com.saveourtool.save.info.UserInfo import com.saveourtool.save.validation.FrontendRoutes import js.core.jso diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/DragAndDropForm.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/DragAndDropForm.kt new file mode 100644 index 0000000000..23f21eaed5 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/DragAndDropForm.kt @@ -0,0 +1,100 @@ +/** + * File containing input form for file uploading. + * + * Supports both drag 'n' drop and file browsing (with clicking on it) + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.inputform + +import js.core.asList +import react.* +import react.dom.events.DragEventHandler +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.strong +import web.cssom.ClassName +import web.file.FileList +import web.html.HTMLElement +import web.html.InputType + +val dragAndDropForm: FC = FC { props -> + val (isDragActive, setIsDragActive) = useState(false) + val inputRef: MutableRefObject = useRef(null) + + val dragHandler: DragEventHandler<*> = { + it.preventDefault() + it.stopPropagation() + if (it.type.unsafeCast() == "dragenter" || it.type.unsafeCast() == "dragover") { + setIsDragActive(true) + } else if (it.type.unsafeCast() == "dragleave") { + setIsDragActive(false) + } + } + + val dropHandler: DragEventHandler<*> = { + it.preventDefault() + it.stopPropagation() + setIsDragActive(false) + if (it.dataTransfer.files.asList() + .isNotEmpty()) { + props.onChangeEventHandler(it.dataTransfer.files) + } + } + + val onButtonClick = { inputRef.current?.click() } + + form { + className = ClassName("btn m-0 flex-fill p-0") + div { + val dragActive = if (isDragActive) "drag-active" else "" + className = ClassName("p-3 $dragActive") + id = "drag-file-element" + onDragEnter = dragHandler + onDragLeave = dragHandler + onDragOver = dragHandler + onDrop = dropHandler + input { + ref = inputRef + type = InputType.file + id = "input-file-upload" + multiple = props.isMultipleFilesSupported + hidden = true + onChange = { props.onChangeEventHandler(it.target.files) } + disabled = props.isDisabled + } + strong { +" Click or drag'n'drop a file " } + onClick = { onButtonClick() } + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + props.tooltipMessage?.let { title = it } + } + } +} + +/** + * [Props] for [dragAndDropForm] + */ +external interface DragAndDropFormProps : Props { + /** + * Callback that defines file uploading process + */ + var onChangeEventHandler: (FileList?) -> Unit + + /** + * Flag that defines if multiple files uploading is supported or not + */ + var isMultipleFilesSupported: Boolean + + /** + * Tooltip message that should be displayed + */ + var tooltipMessage: String? + + /** + * Flag that defines if the form is enabled + */ + var isDisabled: Boolean +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputDateFormRequired.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputDateFormRequired.kt new file mode 100644 index 0000000000..4de6dc99c7 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputDateFormRequired.kt @@ -0,0 +1,65 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.inputform + +import react.ChildrenBuilder +import react.dom.events.ChangeEvent +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.span +import web.cssom.ClassName +import web.html.HTMLInputElement +import web.html.InputType + +/** + * @param form + * @param validInput + * @param classes + * @param text + * @param errorMessage + * @param onChangeFun + * @return a [div] with required input form with datepicker + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.inputDateFormRequired( + form: InputTypes, + validInput: Boolean, + classes: String, + text: String, + errorMessage: String = "Please input a valid ${form.str}", + onChangeFun: (ChangeEvent) -> Unit +) = div { + className = ClassName(classes) + label { + className = ClassName("form-label") + htmlFor = form.name + +text + span { + className = ClassName("text-danger text-left") + +"*" + } + } + div { + className = ClassName("input-group needs-validation") + input { + type = InputType.date + onChange = onChangeFun + id = form.name + required = true + className = if ((value as String?).isNullOrEmpty()) { + ClassName("form-control") + } else if (validInput) { + ClassName("form-control is-valid") + } else { + ClassName("form-control is-invalid") + } + } + if (!validInput) { + div { + className = ClassName("invalid-feedback d-block") + +(form.errorMessage ?: errorMessage) + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputForms.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputForms.kt new file mode 100644 index 0000000000..e5cbb3ac6d --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputForms.kt @@ -0,0 +1,178 @@ +/** + * InputForms additionally contains data class Popover + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.inputform + +import com.saveourtool.save.validation.* + +private const val URL_PLACEHOLDER = "https://example.com" +private const val PURL_PLACEHOLDER = "pkg:example/example.com/version@v1.0.0" +private const val EMAIL_PLACEHOLDER = "test@example.com" +private const val SEVERITY_VECTOR_PLACEHOLDER = "CVSS:3.1/AV:_/AC:_/PR:_/UI:_/S:_/C:_/I:_/A:_" + +private const val NAME_TOOLTIP = "Allowed symbols: English letters, digits, dots, hyphens and underscores." + + "No dot, hyphen or underscore at the beginning and at the end of the line." + +private const val NAME_ORG_PROJECT_TOOLTIP = "Name must not be longer than $NAMING_MAX_LENGTH characters." + + "Allowed symbols: English letters, digits, dots, hyphens and underscores." + + "No dot, hyphen or underscore at the beginning and at the end of the line." + +private const val SEVERITY_VECTOR_TOOLTIP = "It's a string representation of the Common Vulnerability Scoring System (CVSS)." + + "If you know it, please indicate in this field." + +/** + * @property str + * @property placeholder + * @property errorMessage + * @property tooltip + * @property popover + */ +@Suppress("WRONG_DECLARATIONS_ORDER") +enum class InputTypes( + val str: String, + val errorMessage: String? = null, + val placeholder: String? = null, + val tooltip: String? = null, + val popover: Popover? = null, +) { + // ==== general + DESCRIPTION("description", null, "description"), + COMMENT("comment", null, "comment"), + + // ==== new project view + // TODO: need to removed or move to new modal window + GIT_BRANCH("git branch", null, placeholder = "leave empty if you would like to use default branch"), + GIT_TOKEN("git token", null, "token"), + GIT_URL("git url", URL_ERROR_MESSAGE, URL_PLACEHOLDER), + GIT_USER("git username", null, "username"), + PROJECT_EMAIL("project email", EMAIL_ERROR_MESSAGE, EMAIL_PLACEHOLDER), + PROJECT_PROBLEM_NAME("project problem name", NAME_ERROR_MESSAGE, placeholder = "name"), + PURL("purl", placeholder = PURL_PLACEHOLDER), + + // ==== signIn view + PASSWORD("password", null, "*****"), + PROJECT_NAME( + "project name", + NAME_ERROR_MESSAGE, + "name", + NAME_ORG_PROJECT_TOOLTIP + ), + PROJECT_URL("project Url", URL_ERROR_MESSAGE, URL_PLACEHOLDER), + PROJECT_VERSION("project version", placeholder = "0.0.1, 0.0.5, 1.0.1.RELEASE, etc."), + VERSION("version", placeholder = "0.0.1"), + + // ==== create organization view + ORGANIZATION_NAME( + "organization name", + NAME_ERROR_MESSAGE, + "name", + NAME_ORG_PROJECT_TOOLTIP + ), + + // ==== user setting view + USER_EMAIL("User Email", EMAIL_ERROR_MESSAGE, EMAIL_PLACEHOLDER), + LOGIN( + "Login", + NAME_ERROR_MESSAGE, + "name", + tooltip = "Name must not be longer than $NAMING_MAX_LENGTH characters" + ), + COMPANY("Company/Affiliation"), + REAL_NAME("Your name"), + LOCATION("Location"), + GITHUB("GitHub", placeholder = "GitHub"), + LINKEDIN("Linkedin"), + TWITTER("Twitter/X"), + WEBSITE("Website", placeholder = "Website"), + FREE_TEXT("Info"), + + // ==== contest creation component + CONTEST_NAME( + "contest name", + NAME_ERROR_MESSAGE, + "name", + NAME_TOOLTIP + ), + CONTEST_TEMPLATE_NAME( + "Contest template name", + NAME_ERROR_MESSAGE, + "name", + NAME_TOOLTIP, + ), + CONTEST_START_TIME("contest starting time", DATE_RANGE_ERROR_MESSAGE), + CONTEST_END_TIME("contest ending time", DATE_RANGE_ERROR_MESSAGE), + CONTEST_DESCRIPTION("contest description"), + CONTEST_SUPER_ORGANIZATION_NAME("contest's super organization's name", NAME_ERROR_MESSAGE), + CONTEST_TEST_SUITE_IDS("contest test suite ids", placeholder = "click to open selector"), + + // ==== test suite source creation + SOURCE_NAME("source name", placeholder = "name", tooltip = NAME_TOOLTIP), + SOURCE_GIT("source git"), + SOURCE_TEST_ROOT_PATH( + "test root path", + placeholder = "leave empty if tests are in repository root", + tooltip = "Relative path to the root directory with tests", + popover = Popover( + title = "Relative path to the root directory with tests", + content = """ + The path you are providing should be relative to the root directory of your repository. + This directory should contain save.properties + or save.toml files. + For example, if the URL to your repo with tests is: + https://github.com/saveourtool/save, then + you need to specify the following directory with 'save.toml': + examples/kotlin-diktat/. + """ + ) + ), + + // ==== test suites source fetcher + SOURCE_TAG("source_tag", placeholder = "select a tag"), + SOURCE_BRANCH("source_branch", placeholder = "select a branch"), + SOURCE_COMMIT("source_commit", placeholder = "select a commit"), + + // ==== execution run + TEST_SUITE_IDS("test suite ids", placeholder = "click to open selector"), + + // ==== ace editor + ACE_THEME_SELECTOR("theme"), + ACE_MODE_SELECTOR("mode"), + + COMMIT_HASH( + "commit hash", + COMMIT_HASH_ERROR_MESSAGE, + "hash", + ), + + // ==== vulnerability + CVE_NAME( + "CVE identifier", + CVE_NAME_ERROR_MESSAGE, + placeholder = "CVE-2023-######, etc.", + tooltip = "If you know the vulnerability identifier, you can enter it here", + ), + CVE_DATE("CVE date"), + COSV_VECTORE( + "Severity score vector", + SEVERITY_VECTOR_ERROR_MESSAGE, + placeholder = SEVERITY_VECTOR_PLACEHOLDER, + tooltip = SEVERITY_VECTOR_TOOLTIP, + ), + VULN_CREDIT_NAME("user name", placeholder = "User name"), + VULN_CREDIT_CONTACTS("contacts", placeholder = "Contacts: url, mail, social networks, etc."), + ; +} + +/** + * Data class to store popover values in a single object + * + * @property title + * @property content + */ +data class Popover( + val title: String, + val content: String, +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormDisabled.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormDisabled.kt new file mode 100644 index 0000000000..b4bff41e98 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormDisabled.kt @@ -0,0 +1,52 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", "") + +package com.saveourtool.save.frontend.common.components.inputform + +import react.ChildrenBuilder +import react.dom.aria.ariaDescribedBy +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.span +import web.cssom.ClassName +import web.html.InputType + +/** + * @param form + * @param classes + * @param name + * @param inputText + * @param isRequired + * @return div with a disabled input form + */ +fun ChildrenBuilder.inputTextDisabled( + form: InputTypes, + classes: String, + name: String, + inputText: String, + isRequired: Boolean = true, +) { + div { + className = ClassName(classes) + label { + className = ClassName("form-label") + htmlFor = form.name + +name + if (isRequired) { + span { + className = ClassName("text-danger text-left") + +"*" + } + } + } + input { + type = InputType.text + ariaDescribedBy = "${form.name}Span" + id = form.name + required = false + className = ClassName("form-control") + disabled = true + value = inputText + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormOptional.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormOptional.kt new file mode 100644 index 0000000000..d8dcc0a6b9 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormOptional.kt @@ -0,0 +1,163 @@ +/** + * Optional form FC + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.inputform + +import com.saveourtool.save.frontend.common.externals.fontawesome.faQuestionCircle +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.useTooltipAndPopover + +import react.ChildrenBuilder +import react.FC +import react.Props +import react.dom.aria.ariaDescribedBy +import react.dom.events.ChangeEvent +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.sup +import web.cssom.ClassName +import web.html.HTMLInputElement +import web.html.InputType + +/** + * constant FC to avoid re-creation + */ +val inputTextFormOptional = inputTextFormOptionalWrapper() + +/** + * Properties for a [inputTextFormOptional] + */ +external interface InputTextFormOptionalProps : Props { + /** + * Type of form + */ + var form: InputTypes + + /** + * Current input value + */ + var textValue: String? + + /** + * HTML bootstrap classes (className) + */ + var classes: String + + /** + * Label that will be displayed as an input title + */ + var name: String? + + /** + * Flag that indicates if current [textValue] is valid or not + */ + var validInput: Boolean? + + /** + * Conflict error message received from backend + */ + var conflictMessage: String? + + /** + * Callback invoked when [textValue] changed + */ + var onChangeFun: (ChangeEvent) -> Unit +} + +/** + * Unfortunately we had to wrap temporary this method with FC because of a useEffect statement + * + * @param form + * @param textValue + * @param classes + * @param name + * @param validInput + * @param onChangeFun + * @return div with an input form + */ +@Suppress( + "TOO_MANY_PARAMETERS", + "LongParameterList", + "TOO_LONG_FUNCTION", + "LongMethod" +) +private fun ChildrenBuilder.inputTextFormOptional( + form: InputTypes, + textValue: String?, + classes: String, + name: String?, + validInput: Boolean?, + conflictErrorMessage: String?, + onChangeFun: (ChangeEvent) -> Unit, +) { + div { + className = ClassName(classes) + + name?.let { name -> + label { + className = ClassName("form-label mb-0") + htmlFor = form.name + +name + } + } + + form.popover?.let { + sup { + className = ClassName("form-popover") + fontAwesomeIcon(icon = faQuestionCircle) + tabIndex = 0 + asDynamic()["popover-placement"] = "left" + asDynamic()["popover-content"] = it.content + asDynamic()["popover-title"] = it.title + asDynamic()["data-trigger"] = "focus" + } + } + input { + type = InputType.text + onChange = onChangeFun + ariaDescribedBy = "${form.name}Span" + id = form.name + required = false + value = textValue + placeholder = form.placeholder + form.tooltip?.let { + title = it + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + } + className = if (textValue.isNullOrEmpty()) { + ClassName("form-control") + } else if (validInput != false) { + ClassName("form-control is-valid") + } else { + ClassName("form-control is-invalid") + } + } + if (conflictErrorMessage == null && validInput == false && !textValue.isNullOrEmpty()) { + div { + className = ClassName("invalid-feedback d-block") + +(form.errorMessage ?: "Please input a valid ${form.str}") + } + } + } +} + +/** + * @return functional component (wrapper for ChildrenBuilder) + */ +private fun inputTextFormOptionalWrapper() = FC { props -> + useTooltipAndPopover() + inputTextFormOptional( + props.form, + props.textValue, + props.classes, + props.name, + props.validInput, + props.conflictMessage, + props.onChangeFun, + ) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormRequired.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormRequired.kt new file mode 100644 index 0000000000..d909202151 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputTextFormRequired.kt @@ -0,0 +1,150 @@ +/** + * Optional form FC + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.inputform + +import com.saveourtool.save.frontend.common.utils.useTooltip + +import react.ChildrenBuilder +import react.FC +import react.Props +import react.dom.events.ChangeEvent +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.span +import web.cssom.ClassName +import web.html.HTMLInputElement +import web.html.InputType + +val inputTextFormRequired = inputTextFormRequiredWrapper() + +/** + * Properties for a [inputTextFormRequired] + */ +external interface InputTextFormRequiredProps : Props { + /** + * Type of form + */ + var form: InputTypes + + /** + * Current input value + */ + var textValue: String? + + /** + * HTML bootstrap classes (className) + */ + var classes: String + + /** + * Label that will be displayed as an input title + */ + var name: String + + /** + * Flag that indicates if current [textValue] is valid or not + */ + var validInput: Boolean + + /** + * Conflict error message received from backend + */ + var conflictMessage: String? + + /** + * Callback invoked when a form is clicked + */ + var onClickFun: () -> Unit + + /** + * Callback invoked when [textValue] changed + */ + var onChangeFun: (ChangeEvent) -> Unit +} + +/** + * @param form + * @param validInput + * @param classes + * @param name + * @param onChangeFun + * @param textValue + * @param onClickFun + * @return div with an input form + */ +@Suppress( + "TOO_LONG_FUNCTION", + "TOO_MANY_PARAMETERS", + "LongParameterList", +) +private fun ChildrenBuilder.inputTextFormRequired( + form: InputTypes, + textValue: String?, + validInput: Boolean, + classes: String, + name: String, + conflictMessage: String?, + onClickFun: () -> Unit = { }, + onChangeFun: (ChangeEvent) -> Unit = { } +) { + div { + className = ClassName(classes) + label { + className = ClassName("form-label mb-0") + htmlFor = form.name + +name + span { + className = ClassName("text-danger text-left") + +"*" + } + } + + div { + val inputType = if (form == InputTypes.PASSWORD) InputType.password else InputType.text + input { + type = inputType + onChange = onChangeFun + onClick = { onClickFun() } + id = form.name + required = true + value = textValue + placeholder = form.placeholder + className = if (textValue.isNullOrEmpty()) { + ClassName("form-control") + } else if (validInput) { + ClassName("form-control is-valid") + } else { + ClassName("form-control is-invalid") + } + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "right" + title = conflictMessage ?: form.tooltip + } + if (conflictMessage == null && !validInput && !textValue.isNullOrEmpty()) { + div { + className = ClassName("invalid-feedback d-block") + +(form.errorMessage ?: "Input a valid ${form.str}, please.") + } + } + } + } +} + +private fun inputTextFormRequiredWrapper() = FC { props -> + useTooltip() + inputTextFormRequired( + props.form, + props.textValue, + props.validInput, + props.classes, + props.name, + props.conflictMessage, + props.onClickFun, + props.onChangeFun, + ) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputWithDebounce.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputWithDebounce.kt new file mode 100644 index 0000000000..85a700510f --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/inputform/InputWithDebounce.kt @@ -0,0 +1,256 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.inputform + +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.frontend.common.components.basic.renderAvatar +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.utils.DEFAULT_DEBOUNCE_PERIOD + +import js.core.jso +import org.w3c.fetch.Response +import react.* +import react.dom.html.AutoComplete +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h6 +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.option +import web.cssom.* +import web.html.InputType +import web.timers.setTimeout + +import kotlin.time.Duration.Companion.milliseconds + +private const val DROPDOWN_ID = "option-dropdown" + +/** + * Component that encapsulates debounced prefix autocompletion over [UserInfo.name] + */ +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +val inputWithDebounceForUserInfo = inputWithDebounce( + asOption = { UserInfo(name = this) }, + asString = { name }, + // for Vulnerability Collection View this index should be bigger than for Organizations + zindexShift = 1, + decodeListFromJsonString = { decodeFromJsonString() }, +) + +/** + * Component that encapsulates debounced prefix autocompletion over [OrganizationDto.name] + */ +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +val inputWithDebounceForOrganizationDto = inputWithDebounce( + asOption = { OrganizationDto(name = this) }, + asString = { name }, + decodeListFromJsonString = { decodeFromJsonString() }, +) + +/** + * Component that encapsulates debounced prefix autocompletion over [String] + */ +@Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") +val inputWithDebounceForString = inputWithDebounce( + asOption = { this }, + asString = { this }, + decodeListFromJsonString = { decodeFromJsonString() }, +) + +/** + * [Props] for [inputWithDebounce] component + */ +external interface InputWithDebounceProps : PropsWithChildren { + /** + * Callback to get url for options fetch + */ + var getUrlForOptionsFetch: (prefix: String) -> String + + /** + * Currently selected option + */ + var selectedOption: T + + /** + * Callback to set selected option + */ + var setSelectedOption: (option: T) -> Unit + + /** + * Callback to create [option] tag from [T] + */ + @Suppress("VARIABLE_NAME_INCORRECT_FORMAT", "TYPE_ALIAS") + var renderOption: (ChildrenBuilder, option: T) -> Unit + + /** + * Debounce period, equals to [DEFAULT_DEBOUNCE_PERIOD] by default + */ + var debouncePeriod: Int? + + /** + * Placeholder for form + */ + var placeholder: String + + /** + * Callback invoked on option click + */ + var onOptionClick: (option: T) -> Unit + + /** + * Maximum amount of options to display + */ + var maxOptions: Int? + + /** + * Flag that defines if input form is disabled or not + */ + var isDisabled: Boolean? +} + +/** + * Default renderer for [inputWithDebounceForUserInfo] + * + * @param childrenBuilder [ChildrenBuilder] instance + * @param userInfo user's [UserInfo] + */ +fun renderUserWithAvatar(childrenBuilder: ChildrenBuilder, userInfo: UserInfo) { + with(childrenBuilder) { + div { + className = ClassName("row d-flex align-items-center") + renderAvatar(userInfo) { + width = 2.rem + height = 2.rem + } + h6 { + className = ClassName("col-auto mb-0") + +userInfo.name + } + } + } +} + +/** + * Default renderer for [inputWithDebounceForOrganizationDto] + * + * @param childrenBuilder [ChildrenBuilder] instance + * @param organizationDto [OrganizationDto] to display + */ +fun renderOrganizationWithAvatar(childrenBuilder: ChildrenBuilder, organizationDto: OrganizationDto) { + with(childrenBuilder) { + div { + className = ClassName("row d-flex align-items-center") + style = jso { + fontSize = 1.2.rem + } + renderAvatar(organizationDto) { + width = 2.rem + height = 2.rem + } + h6 { + className = ClassName("col-auto mb-0") + +organizationDto.name + } + } + } +} + +/** + * Default renderer for [inputWithDebounceForString] + * + * @param childrenBuilder [ChildrenBuilder] instance + * @param stringOption option to display as [String] + */ +fun renderString(childrenBuilder: ChildrenBuilder, stringOption: String) { + with(childrenBuilder) { + h6 { + className = ClassName("text-sm align-middle m-0") + +stringOption + } + } +} + +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +private fun inputWithDebounce( + zindexShift: Int = 0, + asOption: String.() -> T, + asString: T.() -> String, + decodeListFromJsonString: suspend Response.() -> List, +) = FC> { props -> + val (options, setOptions) = useState?>(null) + val getOptions = useDebouncedDeferredRequest(props.debouncePeriod ?: DEFAULT_DEBOUNCE_PERIOD) { + if (props.selectedOption.asString().isNotBlank()) { + val optionsFromBackend: List = get( + url = props.getUrlForOptionsFetch(props.selectedOption.asString()), + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + .unsafeMap { it.decodeListFromJsonString() } + setOptions(optionsFromBackend) + } else { + setOptions(emptyList()) + } + } + + useEffect(props.selectedOption) { getOptions() } + + div { + className = ClassName("") + id = "input-with-$DROPDOWN_ID" + style = jso { + width = "100%".unsafeCast() + position = "relative".unsafeCast() + zIndex = (1 + zindexShift).unsafeCast() + } + onBlur = { setTimeout(ON_BLUR_TIMEOUT_MILLIS.milliseconds) { setOptions(null) } } + div { + className = ClassName("input-group") + input { + className = ClassName("form-control") + id = "input-with-autocompletion" + type = InputType.text + placeholder = props.placeholder + autoComplete = "off".unsafeCast() + value = props.selectedOption.asString() + disabled = props.isDisabled + onChange = { props.setSelectedOption(it.target.value.asOption()) } + } + props.children?.let { +it } + } + if (props.isDisabled != true) { + div { + className = ClassName("list-group") + id = DROPDOWN_ID + style = jso { + position = "absolute".unsafeCast() + top = "100%".unsafeCast() + width = "max(100%, 10rem)".unsafeCast() + zIndex = "3".unsafeCast() + overflowY = "scroll".unsafeCast() + } + if (props.selectedOption.asString().isNotEmpty() && options?.isEmpty() == true) { + div { + className = ClassName("list-group-item") + style = jso { + zIndex = "4".unsafeCast() + } + +"Could not find anything that starts with ${props.selectedOption.asString()}..." + } + } else { + options.let { optionList -> props.maxOptions?.let { optionList?.take(it) } ?: options } + ?.forEachIndexed { idx, option -> + div { + className = ClassName("list-group-item list-group-item-action") + style = jso { + zIndex = "4".unsafeCast() + } + id = "$DROPDOWN_ID-$idx" + onClick = { props.onOptionClick(option) } + props.renderOption(this, option) + } + } + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/mobile/AboutUsMobileView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/mobile/AboutUsMobileView.kt new file mode 100644 index 0000000000..94d01b91cc --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/mobile/AboutUsMobileView.kt @@ -0,0 +1,108 @@ +/** + * View with some info about core team + */ + +package com.saveourtool.save.frontend.common.components.mobile + +import com.saveourtool.save.frontend.common.components.basic.markdown +import com.saveourtool.save.frontend.common.components.views.AboutUsView +import com.saveourtool.save.frontend.common.components.views.Developer +import com.saveourtool.save.frontend.common.externals.fontawesome.faGithub +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.particles +import js.core.jso + +import react.* +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h6 +import react.dom.html.ReactHTML.img +import web.cssom.ClassName +import web.cssom.rem + +/** + * A component representing "About us" page + * If you need mobile version in future - use: + * val isMobile = window.matchMedia("only screen and (max-width:950px)").matches + */ +@JsExport +@OptIn(ExperimentalJsExport::class) +class AboutUsMobileView : AboutUsView() { + override fun ChildrenBuilder.render() { + particles() + renderViewHeader() + renderSaveourtoolInfo() + renderDevelopers(NUMBER_OF_COLUMNS) + } + + override fun ChildrenBuilder.renderSaveourtoolInfo() { + div { + div { + className = ClassName("mt-3 d-flex justify-content-center align-items-center") + div { + className = ClassName("col-6 p-0") + infoCard { + div { + className = ClassName("m-2 d-flex justify-content-around align-items-center") + div { + className = ClassName("m-2 d-flex align-items-center align-self-stretch flex-column") + img { + src = "/img/save-logo-no-bg.png" + @Suppress("MAGIC_NUMBER") + style = jso { + width = 8.rem + } + className = ClassName("img-fluid mt-auto mb-auto") + } + a { + className = ClassName("text-center mt-auto mb-2 align-self-end") + href = "mailto:$SAVEOURTOOL_EMAIL" + +SAVEOURTOOL_EMAIL + } + } + markdown(saveourtoolDescription, "flex-wrap") + } + } + } + } + } + } + + override fun ChildrenBuilder.renderDeveloperCard(developer: Developer) { + devCard { + div { + className = ClassName("p-3") + div { + className = ClassName("d-flex justify-content-center") + img { + src = "$GITHUB_AVATAR_LINK${developer.githubNickname}?size=$DEFAULT_AVATAR_SIZE" + className = ClassName("img-fluid border border-dark rounded-circle m-0") + @Suppress("MAGIC_NUMBER") + style = jso { + width = 10.rem + } + } + } + div { + className = ClassName("mt-2") + style = jso { + fontSize = 2.rem + } + h6 { + className = ClassName("d-flex justify-content-center text-center") + +developer.name + } + a { + className = ClassName("d-flex justify-content-center") + href = "$GITHUB_LINK${developer.githubNickname}" + fontAwesomeIcon(faGithub) + } + } + } + } + } + + companion object { + private const val NUMBER_OF_COLUMNS = 2 + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/mobile/SaveWelcomeMobileView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/mobile/SaveWelcomeMobileView.kt new file mode 100644 index 0000000000..09d5eeda23 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/mobile/SaveWelcomeMobileView.kt @@ -0,0 +1,94 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.mobile + +import com.saveourtool.save.frontend.common.components.views.welcome.chevron +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.allSaveWelcomePagers +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.save.renderReadMorePage +import com.saveourtool.save.frontend.common.components.views.welcome.saveWelcomeMarketingTitle +import com.saveourtool.save.frontend.common.externals.animations.animator +import com.saveourtool.save.frontend.common.externals.animations.scrollContainer +import com.saveourtool.save.frontend.common.externals.animations.scrollPage +import com.saveourtool.save.frontend.common.utils.SAVE_LIGHT_GRADIENT +import com.saveourtool.save.frontend.common.utils.particles + +import js.core.jso +import react.ChildrenBuilder +import react.FC +import react.Props +import react.dom.html.ReactHTML.b +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h4 +import react.dom.html.ReactHTML.img +import web.cssom.* + +/** + * As a temp stub it was decided to make several views to make SAVE looking nice on mobile devices + */ +val saveWelcomeMobileView: FC = FC { + div { + style = jso { + background = SAVE_LIGHT_GRADIENT.unsafeCast() + } + particles() + sorryYourScreenIsTooSmall() + } +} + +private fun ChildrenBuilder.sorryYourScreenIsTooSmall() { + notSupportedMobileYet() + title() + chevron("rgb(6, 7, 89)") + + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + scrollContainer { + scrollPage {} + allSaveWelcomePagers.forEach { pager -> + scrollPage { } + pager.forEach { + scrollPage { + div { + animator { + animation = it.animation + it.renderPage(this) + } + } + } + } + } + } + + renderReadMorePage() +} + +private fun ChildrenBuilder.notSupportedMobileYet() { + div { + className = ClassName("row d-flex justify-content-center mx-auto text-danger mt-2") + h4 { + className = ClassName("text-center") + +"We do not " + b { + +"yet " + } + +"support devices with small screen size. But you can get familiar with SAVE using the information below." + } + } + div { + className = ClassName("row d-flex justify-content-center mx-auto") + img { + src = "/img/sad_cat.png" + @Suppress("MAGIC_NUMBER") + style = jso { + width = 5.rem + } + } + } +} + +private fun ChildrenBuilder.title() { + div { + className = ClassName("row justify-content-center mx-auto") + // Marketing information + saveWelcomeMarketingTitle("text-primary", true) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/LogoutModal.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/LogoutModal.kt new file mode 100644 index 0000000000..2a5c2a498c --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/LogoutModal.kt @@ -0,0 +1,60 @@ +/** + * A component for logout modal window + */ + +package com.saveourtool.save.frontend.common.components.modal + +import com.saveourtool.save.frontend.common.externals.modal.ModalProps +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.loadingHandler +import com.saveourtool.save.frontend.common.utils.post +import com.saveourtool.save.frontend.common.utils.useDeferredRequest + +import org.w3c.fetch.Headers +import react.FC +import react.router.useNavigate + +import kotlinx.browser.window + +/** + * @param closeCallback a callback to call to close the modal + * @return a Component + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +fun logoutModal( + closeCallback: () -> Unit +) = FC { props -> + val navigate = useNavigate() + + val doLogoutRequest = useDeferredRequest { + val replyToLogout = post( + "${window.location.origin}/logout", + Headers(), + "ping", + loadingHandler = ::loadingHandler, + ) + if (replyToLogout.ok) { + // logout went good, need either to reload page or to setUserInfo(null) and use redirection like `window.location.href = window.location.origin` + navigate("/") + window.location.reload() + } else { + // close this modal to allow user to see modal with error description + closeCallback() + } + } + + displayModal( + props.isOpen, + "Ready to Leave?", + "Select \"Logout\" below if you are ready to end your current session.", + mediumTransparentModalStyle, + { closeCallback() } + ) { + buttonBuilder("Logout", "primary") { + doLogoutRequest() + } + buttonBuilder("Cancel", "secondary") { + closeCallback() + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/Modal.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/Modal.kt new file mode 100644 index 0000000000..201c4b7cf7 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/Modal.kt @@ -0,0 +1,25 @@ +/** + * Kotlin/JS-React wrapper for react-modal: kotlin bindings + */ + +package com.saveourtool.save.frontend.common.components.modal + +import com.saveourtool.save.frontend.common.externals.modal.ModalProps +import com.saveourtool.save.frontend.common.externals.modal.ReactModal +import com.saveourtool.save.frontend.common.externals.modal.Styles +import react.ChildrenBuilder +import react.ReactDsl +import react.react + +/** + * @param injectedStyle css style that you can add to the modal window + * @param block invoked properties + */ +fun ChildrenBuilder.modal( + injectedStyle: Styles = defaultModalStyle, + block: @ReactDsl ChildrenBuilder.(ModalProps) -> Unit, +): Unit = ReactModal::class.react.invoke { + style = injectedStyle + shouldCloseOnOverlayClick = true + block.invoke(this, this) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalBuilder.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalBuilder.kt new file mode 100644 index 0000000000..6e8111a181 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalBuilder.kt @@ -0,0 +1,356 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.modal + +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.WindowOpenness +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import js.core.jso +import react.CSSProperties + +import react.ChildrenBuilder +import react.dom.aria.ariaLabel +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.h5 +import react.dom.html.ReactHTML.pre +import react.dom.html.ReactHTML.span +import web.cssom.ClassName +import web.cssom.rem +import web.html.ButtonType + +/** + * Universal function to create modals with bootstrap styles inside react modals. + * + * @param isOpen modal openness indicator - should be in state + * @param title title of the modal that will be shown in top-left corner + * @param bodyBuilder callback that defined modal body content + * @param classes classes that will be applied to bootstrap modal div + * @param modalStyle [Styles] that will be applied to react modal + * @param onCloseButtonPressed callback that will be applied to `X` button in the top-right corner + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + */ +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS", "LAMBDA_IS_NOT_LAST_PARAMETER") +fun ChildrenBuilder.displayModal( + isOpen: Boolean, + title: String, + bodyBuilder: ChildrenBuilder.() -> Unit, + classes: String = "", + modalStyle: Styles = mediumTransparentModalStyle, + onCloseButtonPressed: (() -> Unit)? = null, + buttonBuilder: ChildrenBuilder.() -> Unit, +) { + modal { props -> + props.isOpen = isOpen + props.style = modalStyle + modalBuilder(title, classes, null, onCloseButtonPressed, bodyBuilder, buttonBuilder) + } +} + +/** + * Universal function to create modals with bootstrap styles inside react modals. + * + * @param isOpen modal openness indicator - should be in state + * @param title title of the modal that will be shown in top-left corner + * @param bodyBuilder callback that defined modal body content + * @param classes classes that will be applied to bootstrap modal div + * @param modalStyle [Styles] that will be applied to react modal + * @param onCloseButtonPressed callback that will be applied to `X` button in the top-right corner + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + * @param customWidth + */ +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS", "LAMBDA_IS_NOT_LAST_PARAMETER") +fun ChildrenBuilder.displayModal( + isOpen: Boolean, + title: String, + bodyBuilder: ChildrenBuilder.() -> Unit, + classes: String = "", + modalStyle: Styles = mediumTransparentModalStyle, + onCloseButtonPressed: (() -> Unit)? = null, + customWidth: CSSProperties? = null, + buttonBuilder: ChildrenBuilder.() -> Unit, +) { + modal { props -> + props.isOpen = isOpen + props.style = modalStyle + modalBuilder(title, classes, customWidth, onCloseButtonPressed, bodyBuilder, buttonBuilder) + } +} + +/** + * Universal function to create modals with bootstrap styles inside react modals. + * + * @param isOpen modal openness indicator - should be in state + * @param title title of the modal that will be shown in top-left corner + * @param message main text that will be shown in the center of modal + * @param modalStyle [Styles] that will be applied to react modal + * @param onCloseButtonPressed callback that will be applied to `X` button in the top-right corner + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + * @return modal + */ +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS") +fun ChildrenBuilder.displayModal( + isOpen: Boolean, + title: String, + message: String, + modalStyle: Styles = mediumTransparentModalStyle, + onCloseButtonPressed: (() -> Unit)? = null, + buttonBuilder: ChildrenBuilder.() -> Unit, +) = doCreateDisplayModal(false, isOpen, title, message, modalStyle, onCloseButtonPressed, buttonBuilder) + +/** + * Universal function to create modals with click condition styles inside react modals + * + * @param isOpen modal openness indicator - should be in state + * @param modalStyle that will be applied to react modal + * @param onCloseButtonPressed callback that will be applied to `X` button in the top-right corner + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + * @param title of the modal that will be shown in top-left corner + * @param message main text that will be shown in the center of modal + * @param clickBuilder lambda that generates several click in modal + */ +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS") +fun ChildrenBuilder.displayModalWithCheckBox( + title: String, + message: String, + isOpen: Boolean, + modalStyle: Styles = mediumTransparentModalStyle, + onCloseButtonPressed: (() -> Unit)? = null, + buttonBuilder: ChildrenBuilder.() -> Unit, + clickBuilder: ChildrenBuilder.() -> Unit +) { + modal { props -> + props.isOpen = isOpen + props.style = modalStyle + modalBuilder( + title = title, + onCloseButtonPressed = onCloseButtonPressed, + bodyBuilder = { + h2 { + className = ClassName("h6 text-gray-800 mb-2") + +message + } + div { + className = ClassName("d-sm-flex justify-content-center form-check") + clickBuilder() + } + }, + buttonBuilder = buttonBuilder + ) + } +} + +/** + * Universal function to create modals with bootstrap styles inside react modals. + * + * @param opener [WindowOpenness] + * @param title title of the modal that will be shown in top-left corner + * @param message main text that will be shown in the center of modal + * @param modalStyle [Styles] that will be applied to react modal + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + */ +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS") +fun ChildrenBuilder.displayModal( + opener: WindowOpenness, + title: String, + message: String, + modalStyle: Styles = mediumTransparentModalStyle, + buttonBuilder: ChildrenBuilder.() -> Unit, +) { + displayModal(opener.isOpen(), title, message, modalStyle, opener.closeWindowAction(), buttonBuilder) +} + +/** + * Universal function to create modals for confirmation. + * + * @param windowOpenness + * @param title title of the modal that will be shown in top-left corner + * @param message main text that will be shown in the center of modal + * @param modalStyle [Styles] that will be applied to react modal + */ +fun ChildrenBuilder.displaySimpleModal( + windowOpenness: WindowOpenness, + title: String, + message: String, + modalStyle: Styles = mediumTransparentModalStyle, +) { + displayModal( + isOpen = windowOpenness.isOpen(), + title = title, + message = message, + modalStyle = modalStyle, + onCloseButtonPressed = windowOpenness.closeWindowAction() + ) { + buttonBuilder("Ok") { + windowOpenness.closeWindow() + } + } +} + +/** + * Universal function to create modals with bootstrap styles. + * + * @param usePreTag whether to use `ReactHTML.pre` tag + * @param title title of the modal that will be shown in top-left corner + * @param message main text that will be shown in the center of modal + * @param onCloseButtonPressed callback that will be applied to `X` button in the top-right corner + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + */ +fun ChildrenBuilder.modalBuilder( + usePreTag: Boolean, + title: String, + message: String, + onCloseButtonPressed: (() -> Unit)?, + buttonBuilder: ChildrenBuilder.() -> Unit, +) { + val customWidth: CSSProperties? = if (usePreTag) jso { width = 40.rem } else null + modalBuilder( + title = title, + onCloseButtonPressed = onCloseButtonPressed, + bodyBuilder = { + if (!usePreTag) { + h2 { + className = ClassName("h6 text-gray-800 mb-2") + +message + } + } else { + pre { + className = ClassName("text-gray-800 mb-2 overflow-x:hidden") + +message + } + } + }, + buttonBuilder = buttonBuilder, + customWidth = customWidth + ) +} + +/** + * Universal function to create modals with bootstrap styles. + * + * @param title title of the modal that will be shown in top-left corner + * @param classes + * @param onCloseButtonPressed callback that will be applied to `X` button in the top-right corner + * @param bodyBuilder lambda that generates body of modal + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + * @param customWidth + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.modalBuilder( + title: String, + classes: String = "", + customWidth: CSSProperties? = null, + onCloseButtonPressed: (() -> Unit)?, + bodyBuilder: ChildrenBuilder.() -> Unit, + buttonBuilder: (ChildrenBuilder.() -> Unit)?, +) { + div { + className = ClassName("modal-dialog $classes") + div { + className = ClassName("modal-content") + customWidth.let { + style = it + } + onCloseFun( + title, + onCloseButtonPressed, + ) + div { + className = ClassName("modal-body") + bodyBuilder() + } + buttonBuilder?.let { + div { + className = ClassName("modal-footer") + it() + } + } + } + } +} + +/** + * Creates modals with bootstrap styles for uploading avatars. + * + * @param title title of the modal that will be shown in top-left corner + * @param onCloseButtonPressed callback that will be applied to `X` button in the top-right corner + * @param buttonBuilder lambda that generates several buttons, must contain either [button] or [buttonBuilder] + * @param isOpen + */ +fun ChildrenBuilder.modalAvatarBuilder( + isOpen: Boolean, + title: String, + onCloseButtonPressed: (() -> Unit), + buttonBuilder: ChildrenBuilder.() -> Unit, +) { + modal { props -> + props.isOpen = isOpen + props.style = mediumTransparentModalStyle + div { + className = ClassName("modal-dialog") + div { + className = ClassName("modal-content") + onCloseFun( + title, + onCloseButtonPressed, + ) + div { + className = ClassName("justify-content-center modal-footer") + buttonBuilder() + } + div { + className = ClassName("modal-body") + } + } + } + } +} + +/** + * @param title + * @param onCloseButtonPressed + */ +fun ChildrenBuilder.onCloseFun( + title: String, + onCloseButtonPressed: (() -> Unit)?, +) { + div { + className = ClassName("modal-header") + h5 { + className = ClassName("modal-title") + +title + } + onCloseButtonPressed?.let { + button { + type = ButtonType.button + className = ClassName("close") + asDynamic()["data-dismiss"] = "modal" + ariaLabel = "Close" + span { + fontAwesomeIcon(icon = faTimesCircle) + onClick = { onCloseButtonPressed() } + } + } + } + } +} + +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS") +private fun ChildrenBuilder.doCreateDisplayModal( + usePreTag: Boolean, + isOpen: Boolean, + title: String, + message: String, + modalStyle: Styles = mediumTransparentModalStyle, + onCloseButtonPressed: (() -> Unit)? = null, + buttonBuilder: ChildrenBuilder.() -> Unit, +) { + modal { props -> + props.isOpen = isOpen + props.style = modalStyle + modalBuilder(usePreTag, title, message, onCloseButtonPressed, buttonBuilder) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalDialog.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalDialog.kt new file mode 100644 index 0000000000..969967cfee --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalDialog.kt @@ -0,0 +1,14 @@ +package com.saveourtool.save.frontend.common.components.modal + +import com.saveourtool.save.frontend.common.utils.WindowOpenness + +/** + * The dialog window. + * + * @property strings the string labels of a dialog window. + * @property window the window itself. + */ +internal data class ModalDialog( + val strings: ModalDialogStrings, + val window: WindowOpenness, +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalDialogStrings.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalDialogStrings.kt new file mode 100644 index 0000000000..a733b87c77 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalDialogStrings.kt @@ -0,0 +1,12 @@ +package com.saveourtool.save.frontend.common.components.modal + +/** + * The string labels of a dialog window. + * + * @property title the title of a dialog window. + * @property message the message of a dialog window. + */ +internal data class ModalDialogStrings( + val title: String, + val message: String, +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalStyles.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalStyles.kt new file mode 100644 index 0000000000..5bbb216f2e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/modal/ModalStyles.kt @@ -0,0 +1,101 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.modal + +import com.saveourtool.save.frontend.common.externals.modal.Styles +import js.core.jso +import react.CSSProperties +import web.cssom.BackgroundColor +import web.cssom.ZIndex +import kotlin.js.json + +/** + * Maximum zIndex in the project, should be only used in modal windows + */ +const val MAX_Z_INDEX = 1000 + +private val defaultOverlayProperties: CSSProperties = jso { + zIndex = MAX_Z_INDEX.unsafeCast() + backgroundColor = "rgba(255, 255, 255, 0.8)".unsafeCast() +} + +val defaultModalStyle = Styles( + // make modal window occupy center of the screen + content = json( + "top" to "25%", + "left" to "35%", + "right" to "35%", + "bottom" to "auto", + "overflow" to "hide" + ).unsafeCast(), + overlay = defaultOverlayProperties, +) + +val smallTransparentModalStyle = Styles( + content = json( + "top" to "10%", + "left" to "35%", + "right" to "35%", + "bottom" to "2%", + "overflow" to "hide", + "backgroundColor" to "transparent", + "border" to "1px solid rgba(255, 255, 255, 0.01)" + ).unsafeCast(), + overlay = defaultOverlayProperties, +) + +val mediumTransparentModalStyle = Styles( + content = json( + "top" to "5%", + "left" to "20%", + "right" to "20%", + "bottom" to "2%", + "overflow" to "hide", + "backgroundColor" to "transparent", + "border" to "1px solid rgba(255, 255, 255, 0.01)" + ).unsafeCast(), + overlay = defaultOverlayProperties, +) + +val largeTransparentModalStyle = Styles( + content = json( + "top" to "5%", + "left" to "5%", + "right" to "5%", + "bottom" to "2%", + "overflow" to "hide", + "backgroundColor" to "transparent", + "border" to "1px solid rgba(255, 255, 255, 0.01)" + ).unsafeCast(), + overlay = defaultOverlayProperties, +) + +val calculatorModalStyle = Styles( + content = json( + "top" to "5%", + "left" to "5%", + "right" to "16%", + "bottom" to "2%", + "overflow" to "hide", + "backgroundColor" to "transparent", + "border" to "1px solid rgba(255, 255, 255, 0.01)" + ).unsafeCast(), + overlay = defaultOverlayProperties, +) + +val loaderModalStyle = Styles( + content = json( + "top" to "25%", + "left" to "35%", + "right" to "35%", + "bottom" to "45%", + "overflow" to "hide", + "backgroundColor" to "transparent", + // small hack to remove modal border and make loader prettier + "border" to "1px solid rgba(255, 255, 255, 0.01)" + ).unsafeCast(), + overlay = jso { + zIndex = MAX_Z_INDEX.unsafeCast() + backgroundColor = "rgba(255, 255, 255, 1)".unsafeCast() + }, +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/Builders.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/Builders.kt new file mode 100644 index 0000000000..594b67b541 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/Builders.kt @@ -0,0 +1,70 @@ +@file:Suppress( + "FILE_NAME_MATCH_CLASS", + "GENERIC_NAME", + "TYPE_ALIAS" +) + +package com.saveourtool.save.frontend.common.components.tables + +import js.core.ReadonlyArray +import js.core.jso +import react.ReactNode +import tanstack.table.core.CellContext +import tanstack.table.core.ColumnDef +import tanstack.table.core.ColumnDefTemplate +import tanstack.table.core.RowData +import tanstack.table.core.StringOrTemplateHeader + +/** + * Inspired by (meaning copy-pasted) `ColumnBuilder` from `kotlin-react-table`, + * which doesn't exist in `kotlin-tanstack-react-table`. + */ +@Suppress( + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_ON_FUNCTION", +) +class ColumnBuilder { + private val columns: MutableList> = mutableListOf() + + fun column( + id: String, + header: String, + render: (cellContext: CellContext) -> ReactNode, + ) = column(id, header, { this }, render) + + /** + * Create a column definition ([ColumnDef]) + * + * @param id unique (in this table) ID of a column + * @param header text value of column's header + * @param accessor function to map underlying value into another representation ([ColumnDef.accessorFn] in terms of tanstack-table) + * @param render callback to render a cell based on its value + * @return [ColumnDef] + */ + fun column( + id: String, + header: String, + accessor: TData.() -> TValue, + render: (cellContext: CellContext) -> ReactNode, + ): ColumnDef = jso> { + this.id = id + this.header = StringOrTemplateHeader(header) + this.accessorFn = { data, _ -> accessor(data) } + this.cell = ColumnDefTemplate { cellCtx -> + render(cellCtx) + } + } + .also { columns.add(it) } + + fun build(): ReadonlyArray> = + columns.toTypedArray() +} + +@Suppress( + "MISSING_KDOC_TOP_LEVEL", + "MISSING_KDOC_ON_FUNCTION", +) +fun columns( + block: ColumnBuilder.() -> Unit, +): ReadonlyArray> = + ColumnBuilder().apply(block).build() diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/PagingControl.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/PagingControl.kt new file mode 100644 index 0000000000..e2a8eb896a --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/PagingControl.kt @@ -0,0 +1,254 @@ +/** + * Paging control utils + */ + +package com.saveourtool.save.frontend.common.components.tables + +import react.ChildrenBuilder +import react.StateSetter +import react.dom.aria.ariaDescribedBy +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.em +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.option +import react.dom.html.ReactHTML.select +import tanstack.table.core.RowData +import tanstack.table.core.Table +import tanstack.table.core.Updater +import web.cssom.ClassName +import web.html.ButtonType +import web.html.InputType + +/** + * @param tableInstance + * @param setPageIndex + * @param initialPageSize + * @return set entries block + */ +fun ChildrenBuilder.setEntries(tableInstance: Table, setPageIndex: StateSetter, initialPageSize: Int) = div { + className = ClassName("row mt-3") + div { + className = ClassName("col-0 pt-1 pr-0") + +"Show " + } + div { + className = ClassName("col-5 pr-0") + div { + className = ClassName("input-group-sm input-group") + select { + className = ClassName("form-control custom-select") + val defaultEntriesSizes = listOf(10, 25, 50, 100) + val adjustedEntriesSizes = if (defaultEntriesSizes.contains(initialPageSize)) defaultEntriesSizes else defaultEntriesSizes.plus(initialPageSize).sorted() + adjustedEntriesSizes.forEach { + option { + className = ClassName("list-group-item") + val entries = it.toString() + value = entries + +entries + } + } + defaultValue = initialPageSize.toString() + onChange = { + val entries = it.target.value + setPageIndexAndGoToPage(tableInstance, setPageIndex, 0) + tableInstance.setPageSize( + Updater(entries.toInt()) + ) + } + } + } + } + div { + className = ClassName("col-0 pt-1 pl-2") + +" entries" + } +} + +/** + * @param tableInstance + * @param setPageIndex + * @param pageIndex + * @param pageCount + * @param initialPageSize + * @return paging control block + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +fun ChildrenBuilder.pagingControl( + tableInstance: Table, + setPageIndex: StateSetter, + pageIndex: Int, + pageCount: Int, + initialPageSize: Int, +) = + div { + className = ClassName("row") + // First page + button { + type = ButtonType.button + className = ClassName("btn btn-link") + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, 0) + } + disabled = !tableInstance.canPreviousPage + +js("String.fromCharCode(171)").unsafeCast() + } + // Previous page icon < + button { + type = ButtonType.button + className = ClassName("btn btn-link") + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, pageIndex - 1) + } + disabled = !tableInstance.canPreviousPage + +js("String.fromCharCode(8249)").unsafeCast() + } + // Previous before previous page + button { + type = ButtonType.button + className = ClassName("btn btn-link") + val index = pageIndex - 2 + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, index) + } + hidden = (index < 0) + em { + +"${index + 1}" + } + } + // Previous page number + button { + type = ButtonType.button + className = ClassName("btn btn-link") + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, pageIndex - 1) + } + hidden = !tableInstance.canPreviousPage + em { + +pageIndex.toString() + } + } + // Current page number + button { + type = ButtonType.button + className = ClassName("btn btn-link") + disabled = true + em { + +"${pageIndex + 1}" + } + } + // Next page number + button { + type = ButtonType.button + className = ClassName("btn btn-link") + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, pageIndex + 1) + } + hidden = !tableInstance.canNextPage + em { + +"${pageIndex + 2}" + } + } + // Next after next page + button { + type = ButtonType.button + className = ClassName("btn btn-link") + val index = pageIndex + 2 + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, index) + } + hidden = (index > pageCount - 1) + em { + +"${index + 1}" + } + } + // Next page icon > + button { + type = ButtonType.button + className = ClassName("btn btn-link") + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, pageIndex + 1) + } + disabled = !tableInstance.canNextPage + +js("String.fromCharCode(8250)").unsafeCast() + } + // Last page + button { + type = ButtonType.button + className = ClassName("btn btn-link") + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, pageCount - 1) + } + disabled = !tableInstance.canNextPage + +js("String.fromCharCode(187)").unsafeCast() + } + // Jump to the concrete page + jumpToPage(tableInstance, setPageIndex, pageCount) + + setEntries(tableInstance, setPageIndex, initialPageSize) + } + +/** + * @param tableInstance + * @param setPageIndex + * @param pageCount + * @return jump to page block + */ +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +fun ChildrenBuilder.jumpToPage(tableInstance: Table, setPageIndex: StateSetter, pageCount: Int) = + form { + var number = 0 + div { + className = ClassName("row") + div { + className = ClassName("col-7 pr-0") + div { + className = ClassName("input-group input-group-sm mb-3 mt-3") + input { + type = InputType.text + className = ClassName("form-control") + ariaDescribedBy = "basic-addon2" + placeholder = "Jump to the page" + onChange = { + // TODO: Provide validation of non int types + number = it.target.value.toInt() - 1 + if (number < 0) { + number = 0 + } + if (number > pageCount - 1) { + number = pageCount - 1 + } + } + } + } + } + + div { + className = ClassName("col-sm-offset-10 mr-3 justify-content-start") + div { + className = ClassName("input-group input-group-sm mb-6") + div { + className = ClassName("input-group-append mt-3") + button { + type = ButtonType.button + className = ClassName("btn btn-outline-secondary") + onClick = { + setPageIndexAndGoToPage(tableInstance, setPageIndex, number) + } + +js("String.fromCharCode(10143)").unsafeCast() + } + } + } + } + } + } + +private fun setPageIndexAndGoToPage( + tableInstance: Table, + setPageIndex: StateSetter, + index: Int +) { + setPageIndex(index) + tableInstance.setPageIndex(Updater(index)) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableComponent.kt new file mode 100644 index 0000000000..52b8f20ce2 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableComponent.kt @@ -0,0 +1,356 @@ +/** + * Utilities for react-tables + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.tables + +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.modal.mediumTransparentModalStyle +import com.saveourtool.save.frontend.common.http.HttpStatusException +import com.saveourtool.save.frontend.common.utils.WithRequestStatusContext +import com.saveourtool.save.frontend.common.utils.buttonBuilder +import com.saveourtool.save.frontend.common.utils.spread +import com.saveourtool.save.frontend.common.utils.useRequestStatusContext + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.em +import react.dom.html.ReactHTML.h6 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.table +import react.dom.html.ReactHTML.tbody +import react.dom.html.ReactHTML.th +import react.dom.html.ReactHTML.thead +import react.dom.html.ReactHTML.tr +import react.router.NavigateFunction +import react.router.useNavigate +import tanstack.react.table.renderCell +import tanstack.react.table.renderHeader +import tanstack.react.table.useReactTable +import tanstack.table.core.Column +import tanstack.table.core.ColumnDef +import tanstack.table.core.Header +import tanstack.table.core.Row +import tanstack.table.core.RowData +import tanstack.table.core.SortDirection +import tanstack.table.core.SortingState +import tanstack.table.core.Table +import tanstack.table.core.TableOptions +import tanstack.table.core.TableState +import tanstack.table.core.getCoreRowModel +import tanstack.table.core.getSortedRowModel +import web.cssom.ClassName +import web.cssom.Cursor +import web.cssom.Overflow +import web.cssom.rem + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +const val TABLE_HEADERS_LOCALE_NAMESPACE = "table-headers" +const val INITIAL_TABLE_PAGE_SIZE = 10 +const val VULNERABILITIES_COLLECTION_TABLE_PAGE_SIZE = 7 + +private typealias TableHeaderBuilder = (ChildrenBuilder, Table, NavigateFunction) -> Unit + +/** + * [Props] of a data table + */ +external interface TableProps : Props { + /** + * Table header + */ + var tableHeader: String + + /** + * Lambda to get table data + */ + @Suppress("TYPE_ALIAS") + var getData: suspend WithRequestStatusContext.(pageIndex: Int, pageSize: Int) -> Array + + /** + * Lambda to update number of pages + */ + var getPageCount: (suspend (pageSize: Int) -> Int)? + + /** + * [ClassName] that is applied to card-body section of a table (table itself) + */ + var cardBodyClassName: String + + /** + * Builder function for table common header, which will be placed above individual column headers + */ + var commonHeaderBuilder: TableHeaderBuilder? +} + +/** + * A `Component` for a data table + * + * @param columns columns as an array of [Column] + * @param initialPageSize initial size of table page + * @param getRowProps a function returning `TableRowProps` for customization of table row, defaults to empty + * @param useServerPaging whether data is split into pages server-side or in browser + * @param additionalOptions + * @param renderExpandedRow how to render an expanded row if `useExpanded` plugin is used. Is invoked inside a `` tag. + * @param getAdditionalDependencies allows filter the table using additional components (dependencies) + * @param isTransparentGrid + * @param tableOptionsCustomizer can customize [TableOptions] in scope of [FC]; for example: + * ```kotlin + * { + * val (sorting, setSorting) = useState(emptyArray()) + * it.initialState!!.sorting = sorting + * } + * ``` + * @return a functional react component + */ +@Suppress( + "TOO_LONG_FUNCTION", + "TOO_MANY_PARAMETERS", + "TYPE_ALIAS", + "ComplexMethod", + "ForbiddenComment", + "LongMethod", + "LongParameterList", + "TooGenericExceptionCaught", + "MAGIC_NUMBER", + "LAMBDA_IS_NOT_LAST_PARAMETER", +) +fun > tableComponent( + columns: (P) -> Array>, + initialPageSize: Int = INITIAL_TABLE_PAGE_SIZE, + useServerPaging: Boolean = false, + isTransparentGrid: Boolean = false, + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") tableOptionsCustomizer: ChildrenBuilder.(TableOptions) -> Unit = {}, + additionalOptions: TableOptions.() -> Unit = {}, + getRowProps: (Row) -> PropsWithStyle = { jso() }, + renderExpandedRow: (ChildrenBuilder.(table: Table, row: Row) -> Unit)? = undefined, + getAdditionalDependencies: (P) -> Array = { emptyArray() }, +): FC

= FC { props -> + require(useServerPaging xor (props.getPageCount == null)) { + "Either use client-side paging or provide a function to get page count" + } + val (data, setData) = useState>(emptyArray()) + val (pageCount, setPageCount) = useState(1) + val (pageIndex, setPageIndex) = useState(0) + val (isModalOpen, setIsModalOpen) = useState(false) + val (dataAccessException, setDataAccessException) = useState(null) + val scope = CoroutineScope(Dispatchers.Default) + + val (sorting, setSorting) = useState(emptyArray()) + val tableInstance: Table = useReactTable(options = jso> { + this.columns = useMemo { columns(props) } + this.data = data + this.getCoreRowModel = getCoreRowModel() + this.manualPagination = useServerPaging + if (useServerPaging) { + this.pageCount = pageCount + } + this.initialState = jso { + this.pagination = jso { + this.pageSize = initialPageSize + this.pageIndex = pageIndex + } + this.sorting = sorting + } + this.asDynamic().state = jso { + // Apparently, setting `initialState` is not enough and examples from tanstack-react-table use `state` in `TableOptions`. + // It's not present in kotlin-wrappers v.423 though. + this.sorting = sorting + } + this.onSortingChange = { updater -> + setSorting.invoke(updater) + } + this.getSortedRowModel = getSortedRowModel() + this.getPaginationRowModel = tanstack.table.core.getPaginationRowModel() + additionalOptions() + }.also { tableOptionsCustomizer(it) }) + + // list of entities, updates of which will cause update of the data retrieving effect + val dependencies: Array = if (useServerPaging) { + arrayOf(tableInstance.getState().pagination.pageIndex, tableInstance.getState().pagination.pageSize, pageCount) + } else { + // when all data is already available, we don't need to repeat `getData` calls + emptyArray() + } + getAdditionalDependencies(props) + + useEffect(*dependencies) { + if (useServerPaging) { + scope.launch { + val newPageCount = props.getPageCount!!.invoke(tableInstance.getState().pagination.pageSize) + if (newPageCount != pageCount) { + setPageCount(newPageCount) + } + } + } + } + + val context = useRequestStatusContext() + + useEffect(*dependencies) { + scope.launch { + try { + setData(context.(props.getData)( + tableInstance.getState().pagination.pageIndex, tableInstance.getState().pagination.pageSize + )) + } catch (e: CancellationException) { + // this means, that view is re-rendering while network request was still in progress + // no need to display an error message in this case + } catch (e: HttpStatusException) { + // this is a normal situation which should be handled by responseHandler in `getData` itself. + // no need to display an error message in this case + } catch (e: Exception) { + // other exceptions are not handled by `responseHandler` and should be displayed separately + setIsModalOpen(true) + setDataAccessException(e) + } + } + cleanup { + if (scope.isActive) { + scope.cancel() + } + } + } + + val navigate = useNavigate() + + val commonHeader = useMemo { + Fragment.create { + props.commonHeaderBuilder?.invoke( + this, + tableInstance, + navigate + ) + } + } + + div { + className = ClassName("${if (isTransparentGrid) "" else "card shadow"} mb-4") + if (props.tableHeader != undefined) { + div { + className = ClassName("card-header py-3") + h6 { + className = ClassName("m-0 font-weight-bold text-primary text-center") + +props.tableHeader + } + } + } + div { + className = ClassName("${props.cardBodyClassName} card-body") + div { + className = ClassName("table-responsive") + // we sometimes have strange overflow with some monitor resolution in chrome + style = jso { + overflowX = Overflow.hidden + } + table { + className = ClassName("table ${if (isTransparentGrid) "" else "table-bordered"} mb-0") + width = 100.0 + cellSpacing = "0" + thead { + +commonHeader + tableInstance.getHeaderGroups().map { headerGroup -> + tr { + id = headerGroup.id + headerGroup.headers.map { header: Header -> + val column = header.column + th { + className = ClassName("m-0 font-weight-bold text-center text-nowrap") + +renderHeader(header) + if (column.getCanSort()) { + style = style ?: jso() + style?.cursor = "pointer".unsafeCast() + span { + +when (column.getIsSorted()) { + SortDirection.asc -> " 🔽" + SortDirection.desc -> " 🔼" + else -> "" + } + } + onClick = column.getToggleSortingHandler() + } + } + } + } + } + } + tbody { + tableInstance.getRowModel().rows.map { row -> + tr { + spread(getRowProps(row)) + row.getVisibleCells().map { cell -> + +renderCell(cell) + } + } + if (row.isExpanded) { + requireNotNull(renderExpandedRow) { + "`useExpanded` is used, but no method for expanded row is provided" + } + renderExpandedRow.invoke(this@tbody, tableInstance, row) + } + } + } + } + + if (data.isEmpty()) { + div { + className = ClassName("col mt-4 mb-4") + div { + className = ClassName("row justify-content-center") + h6 { + className = ClassName("m-0 mt-3 font-weight-bold text-primary text-center") + +"Nothing was found" + } + } + div { + className = ClassName("row justify-content-center") + img { + src = "/img/sad_cat.png" + @Suppress("MAGIC_NUMBER") + style = jso { + width = 14.rem + } + } + } + } + } + + if (tableInstance.getPageCount() > 1) { + div { + className = ClassName("wrapper container m-0 p-0 mt-2") + pagingControl(tableInstance, setPageIndex, pageIndex, pageCount, initialPageSize) + + div { + className = ClassName("row ml-1") + +"Page " + em { + className = ClassName("ml-1") + +" ${tableInstance.getState().pagination.pageIndex + 1} of ${tableInstance.getPageCount()}" + } + } + } + } + } + } + } + + displayModal( + isModalOpen, + "Error", + "Error when fetching data: ${dataAccessException?.message}", + mediumTransparentModalStyle, + { setIsModalOpen(false) }, + ) { + buttonBuilder("Close", "secondary") { + setIsModalOpen(false) + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableUtils.kt new file mode 100644 index 0000000000..0f51469dc7 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/tables/TableUtils.kt @@ -0,0 +1,58 @@ +@file:Suppress( + "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", + "CUSTOM_GETTERS_SETTERS", + "MISSING_KDOC_ON_FUNCTION", + "MISSING_KDOC_TOP_LEVEL", +) + +package com.saveourtool.save.frontend.common.components.tables + +import js.core.jso +import react.ChildrenBuilder +import react.StateSetter +import react.useState +import tanstack.table.core.CellContext +import tanstack.table.core.ExpandedState +import tanstack.table.core.Row +import tanstack.table.core.RowData +import tanstack.table.core.Table +import tanstack.table.core.TableOptions +import tanstack.table.core.TableState +import tanstack.table.core.Updater +import tanstack.table.core.getExpandedRowModel + +val CellContext.value: TValue get() = this.getValue() + +val CellContext.pageIndex get() = this.table.getState() + .pagination + .pageIndex + +val CellContext.pageSize get() = this.table.getState() + .pagination + .pageSize + +val Row.isExpanded get() = getIsExpanded() + +val Table.canPreviousPage get() = getCanPreviousPage() + +val Table.canNextPage get() = getCanNextPage() + +fun Table.visibleColumnsCount() = this.getVisibleFlatColumns().size + +fun StateSetter.invoke(updaterOrValue: Updater) = + if (jsTypeOf(updaterOrValue) == "function") { + this.invoke(updaterOrValue.unsafeCast<(T) -> T>()) + } else { + this.invoke(updaterOrValue.unsafeCast()) + } + +fun ChildrenBuilder.enableExpanding(tableOptions: TableOptions) { + val (expanded, setExpanded) = useState(jso()) + tableOptions.initialState!!.expanded = expanded + tableOptions.asDynamic() + .state + .unsafeCast() + .expanded = expanded + tableOptions.onExpandedChange = { setExpanded.invoke(it) } + tableOptions.getExpandedRowModel = getExpandedRowModel() +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AboutUsView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AboutUsView.kt new file mode 100644 index 0000000000..a39eb30db6 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AboutUsView.kt @@ -0,0 +1,240 @@ +/** + * View with some info about core team + */ + +package com.saveourtool.save.frontend.common.components.views + +import com.saveourtool.save.frontend.common.components.RequestStatusContext +import com.saveourtool.save.frontend.common.components.basic.cardComponent +import com.saveourtool.save.frontend.common.components.basic.markdown +import com.saveourtool.save.frontend.common.components.requestStatusContext +import com.saveourtool.save.frontend.common.externals.fontawesome.faGithub +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.particles + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.h4 +import react.dom.html.ReactHTML.h5 +import react.dom.html.ReactHTML.h6 +import react.dom.html.ReactHTML.img +import web.cssom.ClassName +import web.cssom.Color +import web.cssom.rem + +/** + * [Props] of [AboutUsView] + */ +external interface AboutUsViewProps : Props + +/** + * [State] of [AboutUsView] + */ +external interface AboutUsViewState : State + +/** + * A component representing "About us" page + */ +@JsExport +@OptIn(ExperimentalJsExport::class) +open class AboutUsView : AbstractView() { + private val developers = listOf( + Developer("Vlad", "Frolov", "Cheshiriks", "Fullstack"), + Developer("Peter", "Trifanov", "petertrr", "Arch"), + Developer("Andrey", "Shcheglov", "0x6675636b796f75676974687562", "Backend"), + Developer("Sasha", "Frolov", "sanyavertolet", "Fullstack"), + Developer("Andrey", "Kuleshov", "akuleshov7", "Ideas 😎"), + Developer("Nariman", "Abdullin", "nulls", "Fullstack"), + Developer("Alexey", "Votintsev", "Arrgentum", "Frontend"), + Developer("Kirill", "Gevorkyan", "kgevorkyan", "Backend"), + Developer("Dmitriy", "Morozovsky", "icemachined", "Sensei"), + ).sortedBy { it.name } + + /** + * padding is removed for this card, because of the responsive images (avatars) + */ + protected val devCard = cardComponent(hasBg = true, isPaddingBottomNull = true) + + /** + * card with an info about SAVE with padding + */ + protected val infoCard = cardComponent(hasBg = true, isPaddingBottomNull = true, isNoPadding = false) + + override fun ChildrenBuilder.render() { + particles() + renderViewHeader() + renderSaveourtoolInfo() + renderDevelopers(NUMBER_OF_COLUMNS) + } + + /** + * Simple title above the information card + */ + protected fun ChildrenBuilder.renderViewHeader() { + h2 { + className = ClassName("text-center mt-3") + style = jso { + color = Color("#FFFFFF") + } + +"About us" + } + } + + /** + * Info rendering + */ + protected open fun ChildrenBuilder.renderSaveourtoolInfo() { + div { + div { + className = ClassName("mt-3 d-flex justify-content-center align-items-center") + div { + className = ClassName("col-6 p-0") + infoCard { + div { + className = ClassName("m-2 d-flex justify-content-around align-items-center") + div { + className = ClassName("m-2 d-flex align-items-center align-self-stretch flex-column") + img { + src = "/img/save-logo-no-bg.png" + @Suppress("MAGIC_NUMBER") + style = jso { + width = 8.rem + } + className = ClassName("img-fluid mt-auto mb-auto") + } + a { + className = ClassName("text-center mt-auto mb-2 align-self-end") + href = "mailto:$SAVEOURTOOL_EMAIL" + +SAVEOURTOOL_EMAIL + } + } + markdown(saveourtoolDescription, "flex-wrap") + } + } + } + } + } + } + + /** + * @param columns + */ + @Suppress("MAGIC_NUMBER") + protected fun ChildrenBuilder.renderDevelopers(columns: Int) { + div { + h4 { + className = ClassName("text-center mb-1 mt-4 text-white") + +"Active contributors" + } + div { + className = ClassName("mt-3 d-flex justify-content-around align-items-center") + div { + className = ClassName("col-6 p-1") + val numberOfRows = developers.size / columns + for (rowIndex in 0..numberOfRows) { + div { + className = ClassName("row") + for (colIndex in 0 until columns) { + div { + className = ClassName("col-${12 / columns} p-2") + developers.getOrNull(columns * rowIndex + colIndex)?.let { + renderDeveloperCard(it) + } + } + } + } + } + } + } + } + } + + /** + * @param developer + */ + open fun ChildrenBuilder.renderDeveloperCard(developer: Developer) { + devCard { + div { + className = ClassName("p-3") + div { + className = ClassName("d-flex justify-content-center") + img { + src = "$GITHUB_AVATAR_LINK${developer.githubNickname}?size=$DEFAULT_AVATAR_SIZE" + className = ClassName("img-fluid border border-dark rounded-circle m-0") + @Suppress("MAGIC_NUMBER") + style = jso { + width = 10.rem + } + } + } + div { + className = ClassName("mt-2") + h5 { + className = ClassName("d-flex justify-content-center text-center") + +developer.name + } + h5 { + className = ClassName("d-flex justify-content-center text-center") + +developer.surname + } + h6 { + className = ClassName("text-center") + +developer.description + } + a { + style = jso { + fontSize = 2.rem + } + className = ClassName("d-flex justify-content-center") + href = "$GITHUB_LINK${developer.githubNickname}" + fontAwesomeIcon(faGithub) + } + } + } + } + } + + companion object : + RStatics>(AboutUsView::class) { + protected const val DEFAULT_AVATAR_SIZE = "200" + protected const val GITHUB_AVATAR_LINK = "https://avatars.githubusercontent.com/" + protected const val GITHUB_LINK = "https://github.com/" + protected const val MAX_NICKNAME_LENGTH = 15 + protected const val NUMBER_OF_COLUMNS = 3 + protected const val SAVEOURTOOL_EMAIL = "saveourtool@gmail.com" + protected val saveourtoolDescription = """ + # Save Our Tool! + + Our community is mainly focused on Static Analysis tools and the eco-system related to such kind of tools. + We love Kotlin and mostly everything we develop is connected with Kotlin JVM, Kotlin JS or Kotlin Native. + + ### Main Repositories: + - [diktat](${GITHUB_LINK}saveourtool/diktat) - Automated code checker&fixer for Kotlin + - [save-cli](${GITHUB_LINK}saveourtool/save-cli) - Unified test framework for Static Analyzers and Compilers + - [save-cloud](${GITHUB_LINK}saveourtool/save-cloud) - Cloud eco-system for CI/CD and benchmarking of Static Analyzers + - [awesome-benchmarks](${GITHUB_LINK}saveourtool/awesome-benchmarks) - Curated list of benchmarks for different types of testing + + """.trimIndent() + + init { + AboutUsView.contextType = requestStatusContext + } + } +} + +/** + * @property name developer's name + * @property githubNickname nickname of developer on GitHub + * @property description brief developer description + * @property surname + */ +@JsExport +data class Developer( + val name: String, + val surname: String, + val githubNickname: String, + val description: String = "", +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AbstractView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AbstractView.kt new file mode 100644 index 0000000000..1b914f03c2 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/AbstractView.kt @@ -0,0 +1,52 @@ +package com.saveourtool.save.frontend.common.components.views + +import com.saveourtool.save.frontend.common.utils.ComponentWithScope +import com.saveourtool.save.frontend.common.utils.Style + +import react.* + +import kotlinx.browser.document + +/** + * Abstract view class that should be used in all functional views + */ +abstract class AbstractView

(private val style: Style = Style.SAVE_DARK) : ComponentWithScope() { + // A small hack to avoid duplication of main content-wrapper from App.kt + // We will have custom background only for sign-up and sign-in views + override fun componentDidMount() { + document.getElementById("main-body")?.apply { + className = when (style) { + Style.SAVE_DARK, Style.SAVE_LIGHT -> className.replace("vuln", "save") + Style.VULN_DARK, Style.VULN_LIGHT -> className.replace("save", "vuln") + Style.INDEX -> className.replace("vuln", "save") + } + } + + document.getElementById("content-wrapper")?.setAttribute( + "style", + "background: ${style.globalBackground}" + ) + + configureTopBar(style) + } + + private fun configureTopBar(style: Style) { + val topBar = document.getElementById("navigation-top-bar") + topBar?.setAttribute( + "class", + "navbar navbar-expand ${style.topBarBgColor} navbar-dark topbar ${style.marginBottomForTopBar} " + + "static-top shadow mr-1 ml-1 rounded" + ) + + topBar?.setAttribute( + "style", + "background: ${style.topBarTransparency}" + ) + + val container = document.getElementById("common-save-container") + container?.setAttribute( + "class", + "container-fluid ${style.borderForContainer}" + ) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/FallbackView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/FallbackView.kt new file mode 100644 index 0000000000..029df85b93 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/FallbackView.kt @@ -0,0 +1,113 @@ +/** + * Support rendering something as a fallback + */ + +package com.saveourtool.save.frontend.common.components.views + +import com.saveourtool.save.frontend.common.utils.Style +import com.saveourtool.save.frontend.common.utils.buttonBuilder + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.p +import react.router.Navigate +import web.cssom.* + +import kotlinx.browser.document +import kotlinx.browser.window + +/** + * Props of fallback component + */ +external interface FallbackViewProps : Props { + /** + * Text displayed in big letters + */ + var bigText: String? + + /** + * Small text for more vebose description + */ + var smallText: String? + + /** + * Whether link to the start page should be a `` (if false) or react-routers `Link` (if true). + * If this component is placed outside react-router's Router, then `Link` will be inaccessible. + */ + var withRouterLink: Boolean? +} + +/** + * A Component representing fallback page with 404 error + */ +@JsExport +@OptIn(ExperimentalJsExport::class) +class FallbackView : AbstractView(Style.SAVE_LIGHT) { + @Suppress("ForbiddenComment") + override fun ChildrenBuilder.render() { + // FixMe: not able to use "remove()" here due to some internal problem + // FixMe: or fix links + // so removing top bar for fallback view with a small hack + document.getElementById("navigation-top-bar") + ?.setAttribute("style", "opacity: 0; cursor: default") + + div { + className = ClassName("text-center") + style = jso { + height = 40.rem + } + + div { + className = ClassName("error mx-auto mt-5") + props.bigText?.let { + asDynamic()["data-text"] = it + } + +"${props.bigText}" + } + + p { + className = ClassName("lead text-gray-800 mb-3") + +"${props.smallText}" + } + + if (props.withRouterLink == true) { + Navigate { + to = "/" + this@render.buttonBuilder("Back to the main page", style = "info") { } + } + } else { + a { + href = "${window.location.origin}/" + buttonBuilder("Back to the main page", style = "info") { } + } + } + + div { + className = ClassName("row mt-3 justify-content-center") + div { + className = ClassName("col-4") + p { + className = ClassName("lead text-gray-800") + +"Report a problem:" + } + + a { + className = ClassName("mt-3") + href = "https://github.com/saveourtool/save-cloud" + img { + src = "/img/github.svg" + style = jso { + width = 5.rem + height = 5.rem + cursor = "pointer".unsafeCast() + } + } + } + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/RegistrationView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/RegistrationView.kt new file mode 100644 index 0000000000..2fb7247b70 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/RegistrationView.kt @@ -0,0 +1,383 @@ +/** + * A view for registration + */ + +@file:Suppress("FILE_WILDCARD_IMPORTS", "WildcardImport", "FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.views + +import com.saveourtool.save.frontend.common.components.basic.avatarForm +import com.saveourtool.save.frontend.common.components.basic.avatarRenderer +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +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.MAX_Z_INDEX +import com.saveourtool.save.frontend.common.components.views.usersettings.AVATAR_TITLE +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.UserInfoAwareMutablePropsWithChildren +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.info.UserStatus +import com.saveourtool.save.utils.AVATARS_PACKS_DIR +import com.saveourtool.save.utils.AvatarType +import com.saveourtool.save.utils.CONTENT_LENGTH_CUSTOM +import com.saveourtool.save.utils.FILE_PART_NAME +import com.saveourtool.save.validation.FrontendRoutes +import com.saveourtool.save.validation.isValidLengthName +import com.saveourtool.save.validation.isValidName +import com.saveourtool.save.validation.isValidUrl + +import js.core.jso +import org.w3c.fetch.Headers +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.main +import react.dom.html.ReactHTML.span +import react.dom.html.ReactHTML.textarea +import react.router.dom.Link +import react.router.useNavigate +import web.cssom.* +import web.file.File +import web.html.InputType +import web.http.FormData +import web.window.WindowTarget + +import kotlinx.browser.window +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * A Component for registration view + */ +@JsExport +@OptIn(ExperimentalJsExport::class) +val registrationView: FC = FC { props -> + useBackground(Style.INDEX) + particles() + val useNavigate = useNavigate() + + if (props.userInfo?.status == UserStatus.ACTIVE) { + useNavigate(to = "/") + } + + useRedirectToIndexIf(props.userInfo?.status) { + // life hack ot be sure that props are loaded + props.key != null && props.userInfo?.status != UserStatus.CREATED + } + + val avatarWindowOpen = useWindowOpenness() + val (selectedAvatar, setSelectedAvatar) = useState(props.userInfo?.avatar) + val (avatar, setAvatar) = useState(null) + + val (isTermsOfUseOk, setIsTermsOfUseOk) = useState(false) + val (conflictErrorMessage, setConflictErrorMessage) = useState(null) + val (userInfo, setUserInfo) = useStateFromProps(props.userInfo ?: UserInfo(name = "")) { userInfo -> + // weed to process usernames, as some authorization providers pass emails instead of names + val atIndex = userInfo.name.indexOf('@') + val processedName = if (atIndex >= 0) userInfo.name.substring(0, atIndex) else userInfo.name + userInfo.copy(name = processedName) + } + + val saveUser = useDeferredRequest { + val newUserInfo = userInfo.copy( + status = UserStatus.ACTIVE, + ) + val response = post( + "$apiUrl/users/save", + jsonHeaders, + Json.encodeToString(newUserInfo), + loadingHandler = ::loadingHandler, + responseHandler = ::responseHandlerWithValidation, + ) + if (response.ok) { + props.userInfoSetter(userInfo) + useNavigate(to = "/${FrontendRoutes.THANKS_FOR_REGISTRATION}") + window.location.reload() + } else if (response.isConflict()) { + setConflictErrorMessage(response.unpackMessage()) + } + } + + val logOut = useDeferredRequest { + val replyToLogout = post( + "${window.location.origin}/logout", + Headers(), + "ping", + loadingHandler = ::loadingHandler, + ) + if (replyToLogout.ok) { + useNavigate(to = "/") + window.location.reload() + } + } + + val saveAvatar = useDeferredRequest { + avatar?.let { + val response = post( + url = "$apiUrl/avatar/upload", + params = jso { + owner = props.userInfo?.name + this.type = AvatarType.USER + }, + Headers().apply { append(CONTENT_LENGTH_CUSTOM, avatar.size.toString()) }, + FormData().apply { set(FILE_PART_NAME, avatar) }, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + if (response.ok) { + window.location.reload() + } + } + } + + val setAvatarFromResources = useDeferredRequest { + get( + url = "$apiUrl/avatar/avatar-update", + params = jso { + this.type = AvatarType.USER + this.resource = selectedAvatar + }, + jsonHeaders, + loadingHandler = ::loadingHandler, + ) + } + + val isWebsiteValid = userInfo.website?.isValidUrl() ?: true + + avatarForm { + isOpen = avatarWindowOpen.isOpen() + title = AVATAR_TITLE + onCloseWindow = { + saveAvatar() + avatarWindowOpen.closeWindow() + } + imageUpload = { file -> + setAvatar(file) + } + } + + main { + className = ClassName("main-content mt-0 ps") + div { + className = ClassName("page-header align-items-start min-vh-100") + span { + className = ClassName("mask bg-gradient-dark opacity-6") + } + div { + className = ClassName("row justify-content-center") + div { + className = ClassName("col-sm-4") + div { + className = ClassName("container card o-hidden border-0 shadow-lg my-2 card-body p-0") + style = jso { + zIndex = (MAX_Z_INDEX - 1).unsafeCast() + } + div { + className = ClassName("p-5 text-center") + + h1 { + className = ClassName("h4 text-gray-900 mb-4") + +"Set your user name and avatar" + } + + div { + className = ClassName("row") + + div { + className = ClassName("col-3") + div { + className = ClassName("row d-flex justify-content-center") + renderPreparedAvatars( + 1..3, + setSelectedAvatar, + setAvatarFromResources, + ) + } + } + + div { + className = ClassName("col-6") + renderAvatar(avatarWindowOpen, selectedAvatar) + } + + div { + className = ClassName("col-3") + div { + className = ClassName("row d-flex justify-content-center") + renderPreparedAvatars( + 4..6, + setSelectedAvatar, + setAvatarFromResources, + ) + } + } + } + + form { + div { + inputTextFormRequired { + form = InputTypes.LOGIN + textValue = userInfo.name + validInput = userInfo.name.isNotEmpty() && userInfo.name.isValidName() && userInfo.name.isValidLengthName() + classes = "" + name = "User name" + conflictMessage = conflictErrorMessage + onChangeFun = { event -> + setUserInfo { previousUserInfo -> previousUserInfo.copy(name = event.target.value) } + setConflictErrorMessage(null) + } + } + } + + div { + className = ClassName("pt-3 font-weight-bold") + +"Please enter some information about yourself so that it would be easier for us to approve." + } + + div { + className = ClassName("pt-3") + inputTextFormOptional { + form = InputTypes.GITHUB + textValue = userInfo.gitHub + classes = "" + validInput = null + onChangeFun = { event -> + setUserInfo { previousUserInfo -> + previousUserInfo.copy(gitHub = event.target.value.takeIf { it.isNotBlank() }) + } + } + } + } + + div { + className = ClassName("pt-3") + inputTextFormOptional { + form = InputTypes.WEBSITE + textValue = userInfo.website + classes = "" + validInput = userInfo.website?.isValidUrl() + onChangeFun = { event -> + setUserInfo { previousUserInfo -> + previousUserInfo.copy(website = event.target.value.takeIf { it.isNotBlank() }) + } + } + } + } + + div { + className = ClassName("pt-3") + textarea { + className = ClassName("form-control") + value = userInfo.freeText + placeholder = "Additional info" + onChange = { event -> setUserInfo { previousUserInfo -> previousUserInfo.copy(freeText = event.target.value) } } + } + } + + div { + className = ClassName("mt-2 form-check row") + input { + className = ClassName("form-check-input") + type = "checkbox".unsafeCast() + value = "" + id = "terms-of-use" + onChange = { setIsTermsOfUseOk(it.target.checked) } + } + label { + className = ClassName("form-check-label") + +" I agree with " + Link { + to = "/${FrontendRoutes.TERMS_OF_USE}" + target = "_blank".unsafeCast() + +"terms of use" + } + } + } + + buttonBuilder( + "Sign up", + "info", + classes = "mt-4 mr-4", + isDisabled = !isTermsOfUseOk || !isWebsiteValid, + ) { saveUser() } + + buttonBuilder( + "Log out", + "danger", + classes = "mt-4", + ) { logOut() } + + conflictErrorMessage?.let { + div { + className = ClassName("invalid-feedback d-block") + +it + } + } + } + } + } + } + } + } + } +} + +/** + * @param avatarWindowOpen + * @param avatar + */ +fun ChildrenBuilder.renderAvatar( + avatarWindowOpen: WindowOpenness, + avatar: String?, +) { + div { + className = ClassName("animated-provider") + Link { + className = ClassName("btn px-0 pt-0") + title = AVATAR_TITLE + onClick = { + avatarWindowOpen.openWindow() + } + img { + className = ClassName("avatar avatar-user width-full border color-bg-default rounded-circle") + src = avatar?.avatarRenderer() ?: AVATAR_PROFILE_PLACEHOLDER + style = jso { + height = 16.rem + width = 16.rem + } + } + } + } +} + +private fun ChildrenBuilder.renderPreparedAvatars( + avatarsRange: IntRange, + setSelectedAvatar: StateSetter, + setAvatarFromResources: () -> Unit = { }, +) { + for (i in avatarsRange) { + val avatar = "$AVATARS_PACKS_DIR/avatar$i.png" + div { + className = ClassName("animated-provider") + img { + className = + ClassName("avatar avatar-user width-full border color-bg-default rounded-circle mt-1") + src = avatar + style = jso { + height = 5.1.rem + width = 5.1.rem + cursor = Cursor.pointer + } + onClick = { + setSelectedAvatar(avatar) + setAvatarFromResources() + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileNewUsersTab.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileNewUsersTab.kt new file mode 100644 index 0000000000..78f68452bd --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileNewUsersTab.kt @@ -0,0 +1,72 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.components.views.userprofile + +import com.saveourtool.save.frontend.common.components.basic.renderUserAvatarWithName +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 com.saveourtool.save.info.UserInfo +import react.FC +import react.Fragment +import react.Props +import react.create +import react.dom.html.ReactHTML.td +import web.cssom.ClassName +import web.cssom.rem + +val renderNewUsersTableForProfileView: FC = FC { + @Suppress( + "TYPE_ALIAS", + "MAGIC_NUMBER", + ) + val newUsersTable: FC> = tableComponent( + columns = { + columns { + column(id = "name", header = "Name", { name }) { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle") + renderUserAvatarWithName(cellContext.row.original) { + height = 3.rem + width = 3.rem + } + } + } + } + column(id = "originalName", header = "Original login") { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + +cellContext.value.originalLogins.firstNotNullOfOrNull { it.value } + } + } + } + column(id = "source", header = "Source") { cellContext -> + Fragment.create { + td { + className = ClassName("align-middle text-center") + +cellContext.value.originalLogins.firstNotNullOfOrNull { it.key } + } + } + } + } + }, + isTransparentGrid = true, + ) + + newUsersTable { + getData = { _, _ -> + get( + url = "$apiUrl/users/new-users", + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ).unsafeMap { + it.decodeFromJsonString() + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileView.kt new file mode 100644 index 0000000000..6f87572a21 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/userprofile/UserProfileView.kt @@ -0,0 +1,489 @@ +/** + * View for UserProfile + */ + +package com.saveourtool.save.frontend.common.components.views.userprofile + +import com.saveourtool.save.entities.OrganizationDto +import com.saveourtool.save.frontend.TabMenuBar +import com.saveourtool.save.frontend.common.components.basic.renderAvatar +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.modal.mediumTransparentModalStyle +import com.saveourtool.save.frontend.common.components.views.vuln.vulnerabilityTableComponent +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.info.UserStatus +import com.saveourtool.save.utils.* +import com.saveourtool.save.validation.FrontendRoutes + +import js.core.jso +import react.* +import react.dom.aria.ariaDescribedBy +import react.dom.html.AnchorHTMLAttributes +import react.dom.html.HTMLAttributes +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h3 +import react.dom.html.ReactHTML.h5 +import react.dom.html.ReactHTML.hr +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.label +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.textarea +import react.router.dom.Link +import react.router.useNavigate +import web.cssom.* +import web.html.HTMLAnchorElement +import web.html.HTMLHeadingElement +import web.html.InputType + +val userProfileView: FC = FC { props -> + useBackground(Style.SAVE_LIGHT) + + val (userName, _) = useStateFromProps(props.userName) + val (user, setUser) = useState(null) + val (organizations, setOrganizations) = useState>(emptyList()) + val (selectedMenu, setSelectedMenu) = useState(UserProfileTab.VULNERABILITIES) + val (countUsers, setCountUsers) = useState(0) + + useRequest { + val userNew: UserInfo = get( + "$apiUrl/users/$userName", + jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .decodeFromJsonString() + + setUser(userNew) + + val organizationsNew: List = get( + "$apiUrl/organizations/get/list-by-user-name?userName=$userName", + jsonHeaders, + loadingHandler = ::loadingHandler, + ) + .decodeFromJsonString() + + setOrganizations(organizationsNew) + + val count: Int = get( + url = "$apiUrl/users/new-users-count", + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ).unsafeMap { + it.decodeFromJsonString() + } + + setCountUsers(count) + } + + div { + className = ClassName("row justify-content-center") + + // ===================== LEFT COLUMN ======================================================================= + div { + className = ClassName("col-2 mb-4 mt-2") + + renderLeftUserMenu(user, props.currentUserInfo, organizations) + } + + // ===================== RIGHT COLUMN ======================================================================= + div { + className = ClassName("col-7 mb-4 mt-2") + props.currentUserInfo?.globalRole?.let { role -> + if (role.isSuperAdmin() && props.currentUserInfo?.name == user?.name && countUsers > 0) { + @Suppress("MISSING_KDOC_ON_FUNCTION") + fun UserProfileTab.toTabName() = if (this == UserProfileTab.USERS) "${this.name} ($countUsers)" else this.name + + val tabList = UserProfileTab.entries.map { it.toTabName() } + + tab(selectedMenu.toTabName(), tabList, "nav nav-tabs mt-3") { value -> + val newValue = if (value.contains(UserProfileTab.USERS.name)) UserProfileTab.USERS.name else value + setSelectedMenu { UserProfileTab.valueOf(newValue) } + } + } + } + + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + when (selectedMenu) { + UserProfileTab.VULNERABILITIES -> vulnerabilityTableComponent { + this.currentUserInfo = props.currentUserInfo + this.userName = userName + } + UserProfileTab.USERS -> renderNewUsersTableForProfileView {} + } + } + } +} + +/** + * [Props] of user profile view component + */ +@Suppress( + "MISSING_KDOC_TOP_LEVEL", + "TYPE_ALIAS", +) +external interface UserProfileViewProps : Props { + /** + * User name + */ + var userName: String + + /** + * Current logged-in user + */ + var currentUserInfo: UserInfo? +} + +/** + * Enum that contains values for vulnerability + */ +@Suppress("WRONG_DECLARATIONS_ORDER") +enum class UserProfileTab { + VULNERABILITIES, + USERS, + ; + + companion object : TabMenuBar { + override val nameOfTheHeadUrlSection = "" + override val defaultTab: UserProfileTab = VULNERABILITIES + override val regexForUrlClassification = "/${FrontendRoutes.VULN_PROFILE}" + override fun valueOf(elem: String): UserProfileTab = UserProfileTab.valueOf(elem) + override fun values(): Array = entries.toTypedArray() + } +} + +/** + * @param user + * @param organizations + * @param currentUser + */ +@Suppress( + "TOO_LONG_FUNCTION", + "LongMethod", + "CyclomaticComplexMethod", +) +fun ChildrenBuilder.renderLeftUserMenu( + user: UserInfo?, + currentUser: UserInfo?, + organizations: List, +) { + val navigate = useNavigate() + val banUserWindowOpenness = useWindowOpenness() + + val banUser = useDeferredRequest { + user?.name?.let { + val response = get( + url = "$apiUrl/users/ban", + params = jso { + userName = it + }, + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + if (response.ok) { + navigate(to = "/") + } + } + } + + val approveUser = useDeferredRequest { + user?.name?.let { + val response = get( + url = "$apiUrl/users/approve", + params = jso { + userName = it + }, + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + if (response.ok) { + navigate(to = "/") + } + } + } + + // FixMe: Comment and cause is not used anywhere. Need to send email notification + displayModal( + banUserWindowOpenness.isOpen(), + "User profile ban", + bodyBuilder = { + div { + div { + className = ClassName("col-12 form-check-inline mb-2") + input { + className = ClassName("form-check-input") + defaultChecked = true + name = "cause" + type = InputType.radio + value = "public" + } + label { + className = ClassName("form-check-label") + htmlFor = "cause" + +"Violation of the rules for using the service" + } + } + div { + className = ClassName("col-12 form-check-inline mb-3") + input { + className = ClassName("form-check-input") + defaultChecked = false + name = "cause" + type = InputType.radio + value = "public" + } + label { + className = ClassName("form-check-label") + htmlFor = "cause" + +"Other" + } + } + textarea { + className = ClassName("border-secondary form-control p-3 border-1") + ariaDescribedBy = "${InputTypes.COMMENT.name}Span" + rows = 5 + id = InputTypes.COMMENT.name + required = true + placeholder = "Write a comment" + } + } + }, + modalStyle = mediumTransparentModalStyle, + onCloseButtonPressed = banUserWindowOpenness.closeWindowAction(), + ) { + buttonBuilder("Ok", "danger") { + banUser() + banUserWindowOpenness.closeWindow() + } + buttonBuilder("Close", "secondary") { + banUserWindowOpenness.closeWindow() + } + } + + div { + className = ClassName("row justify-content-center") + renderAvatar(user, "mb-4", isLinkActive = false) { + height = 15.rem + width = 15.rem + } + } + + h3 { + className = ClassName("mb-0 text-gray-900 text-center") + shortenLoginWithTooltipIfNecessary(user?.name, this) + } + + h5 { + className = ClassName("mb-0 text-gray-600 text-center") + shortenRealNameWithTooltipIfNecessary(user?.realName, this) + } + + div { + className = ClassName("col text-center mt-2") + Link { + to = "/${FrontendRoutes.VULN_TOP_RATING}" + className = ClassName("row text-xs font-weight-bold text-info justify-content-center text-uppercase mb-1") + +"Rating" + } + div { + className = ClassName("row h5 font-weight-bold justify-content-center text-gray-800 my-1") + +user?.rating.toString() + } + + if (currentUser?.name == user?.name) { + div { + className = ClassName("row h5 font-weight-bold justify-content-center text-gray-800 my-3") + + buttonBuilder(label = "Customize profile", isOutline = true, style = "primary btn-sm") { + navigate(to = "/${FrontendRoutes.SETTINGS_PROFILE}") + } + } + } + + if (currentUser?.isSuperAdmin() == true && currentUser.name != user?.name) { + div { + className = ClassName("row h5 font-weight-bold justify-content-center text-gray-800 my-3") + + buttonBuilder(label = "Ban user", isOutline = true, style = "danger btn-sm") { + banUserWindowOpenness.openWindow() + } + } + if (user?.status == UserStatus.NOT_APPROVED) { + div { + className = ClassName("row h5 font-weight-bold justify-content-center text-gray-800 my-3") + + buttonBuilder(label = "Approve user", isOutline = true, style = "success btn-sm") { + approveUser() + } + } + } + } + } + + user?.freeText?.let { freeText(it) } + + user?.company?.let { company -> + div { + className = ClassName("my-2") + fontAwesomeIcon(icon = faCity) { + it.className = "fas fa-sm fa-fw mr-2" + } + +company + } + } + + user?.location?.let { location -> + div { + className = ClassName("mb-2") + fontAwesomeIcon(icon = faGlobe) { + it.className = "fas fa-sm fa-fw mr-2" + } + +location + } + } + + user?.gitHub?.let { extraLinks(faGithub, it, listOf(UsefulUrls.GITHUB, UsefulUrls.GITEE)) } + + user?.twitter?.let { extraLinks(faTwitter, it, listOf(UsefulUrls.TWITTER, UsefulUrls.XCOM)) } + + user?.linkedin?.let { extraLinks(faLinkedIn, it, listOf(UsefulUrls.LINKEDIN)) } + + user?.website?.let { extraLinks(faLink, it, listOf(UsefulUrls.WEBSITE)) } + + if (organizations.isNotEmpty()) { + div { + className = ClassName("separator") + style = jso { + borderBottom = "0.07rem #000000".unsafeCast() + } + +"Organizations" + } + + div { + className = ClassName("latest-photos mt-3") + div { + className = ClassName("row") + organizations.forEach { organization -> + renderAvatar(organization) { + width = 4.rem + height = 4.rem + } + } + } + } + } +} + +/** + * @param login + * @param htmlAttribute + */ +fun ChildrenBuilder.shortenLoginWithTooltipIfNecessary(login: String?, htmlAttribute: HTMLAttributes) { + login?.let { + if (it.length > LOGIN_MAX_LENGTH) { + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "top" + htmlAttribute.title = it + + +(it.shortenLogin()) + } else { + +it + } + } ?: +"N/A" +} + +/** + * @param realName + * @param htmlAttribute + */ +fun ChildrenBuilder.shortenRealNameWithTooltipIfNecessary(realName: String?, htmlAttribute: HTMLAttributes) { + realName?.let { name -> + if (name.split(" ").any { it.length > REAL_NAME_PART_MAX_LENGTH }) { + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + htmlAttribute.title = name + + +(name.shortenRealName()) + } else { + +name + } + } ?: +"N/A" +} + +/** + * @param url + * @param tooltipUrl + * @param htmlAttribute + */ +fun ChildrenBuilder.shortenUrlWithTooltipIfNecessary(url: String, tooltipUrl: String, htmlAttribute: AnchorHTMLAttributes) { + if (url.length > URL_MAX_LENGTH) { + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "top" + htmlAttribute.title = tooltipUrl + + +(url.shortenUrl()) + } else { + +url + } +} + +/** + * @param icon + * @param info + * @param patterns + */ +fun ChildrenBuilder.extraLinks(icon: FontAwesomeIconModule, info: String, patterns: List) { + val foundPattern = patterns.find { info.matches(it.regex) } + foundPattern?.let { + val trimmedLink = if (foundPattern != UsefulUrls.WEBSITE) { + info.substringAfterLast(".com/") + } else { + info.substringAfter("://") + } + if (trimmedLink.isNotBlank()) { + div { + className = ClassName("mb-2") + fontAwesomeIcon(icon = icon) { + it.className = "fas fa-sm fa-fw mr-2 text-gray-900" + } + a { + href = info + shortenUrlWithTooltipIfNecessary(trimmedLink, info, this) + } + } + } + } +} + +/** + * @param text + */ +fun ChildrenBuilder.freeText(text: String) { + if (text.isNotEmpty()) { + div { + className = ClassName("separator") + style = jso { + borderBottom = "0.07rem #000000".unsafeCast() + } + +"About" + } + + div { + className = ClassName("row justify-content-center") + p { + className = ClassName("mb-0") + style = jso { + textAlign = TextAlign.justify + } + +text + } + } + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + hr {} + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsView.kt new file mode 100644 index 0000000000..9c08ce74ff --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsView.kt @@ -0,0 +1,192 @@ +/** + * A view with settings user + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.views.usersettings + +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.modal.modal +import com.saveourtool.save.frontend.common.components.views.usersettings.right.SettingsInputFields +import com.saveourtool.save.frontend.common.externals.i18next.TranslationFunction +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.validation.FrontendRoutes + +import js.core.jso +import react.* +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.main +import react.router.useNavigate +import web.cssom.* +import web.html.ButtonType +import web.html.InputType + +import kotlinx.browser.window +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +val cardHeight: CSSProperties = jso { + height = 53.rem +} + +val userSettingsView: FC = FC { props -> + val useNavigate = useNavigate() + + modal { modalProps -> + modalProps.isOpen = props.userInfo == null + modalProps.contentLabel = "Unauthenticated" + div { + className = ClassName("row align-items-center justify-content-center") + h2 { + className = ClassName("h6 text-gray-800") + +"You are not logged in" + } + } + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mt-4") + button { + className = ClassName("btn btn-outline-primary") + type = ButtonType.button + onClick = { + useNavigate(to = "/") + window.location.reload() + } + +"Proceed to login page" + } + } + } + + useBackground(Style.SAVE_LIGHT) + main { + className = ClassName("main-content") + div { + className = ClassName("page-header align-items-start min-vh-100") + div { + className = ClassName("row justify-content-center mt-3") + div { + className = ClassName("col-2") + leftSettingsColumn { this.userInfo = props.userInfo } + } + div { + className = ClassName("col-7") + if (props.userInfo?.name?.isNotEmpty() == true) { + rightSettingsColumn { + this.userInfo = props.userInfo + this.type = props.type + this.userInfoSetter = props.userInfoSetter + } + } + } + } + } + } +} + +typealias FieldsStateSetter = StateSetter + +/** + * `Props` retrieved from router + */ +@Suppress("MISSING_KDOC_CLASS_ELEMENTS") +external interface SettingsProps : UserInfoAwareMutablePropsWithChildren { + /** + * just a flag for a factory + */ + var type: FrontendRoutes +} + +/** + * Drawing an input for profile settings (one-liner) + * + * @param previousValue + * @param inputType + * @param setFields + * @param placeholderText + * @param settingsInputFields + * @param colRatio sizes of columns: =====INPUT FIELD===== + * @param validationFunction + * @param translate translation to different languages + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.inputForm( + previousValue: String?, + inputType: InputTypes, + settingsInputFields: SettingsInputFields, + setFields: FieldsStateSetter, + translate: TranslationFunction, + placeholderText: String = "", + colRatio: Pair<String, String> = "col-4" to "col-8", + validationFunction: String.() -> String, +) { + div { + className = ClassName("row justify-content-center") + div { + className = ClassName("${colRatio.first} mt-2 text-left align-self-center") + +"${inputType.str.translate()}:" + } + div { + className = ClassName("${colRatio.second} mt-2 input-group pl-0") + input { + placeholder = placeholderText + type = InputType.text + className = ClassName("form-control shadow") + previousValue.let { + defaultValue = it + } + onChange = { + val textInTheInput = it.target.value + val settingsInputFieldsNew = + settingsInputFields.updateValue(inputType, it.target.value, textInTheInput.validationFunction()) + setFields(settingsInputFieldsNew) + } + } + val validationText = settingsInputFields.getValueByType(inputType).validation + if (validationText.isNotBlank()) { + div { + className = ClassName("invalid-feedback d-block") + +validationText + } + } + } + } +} + +/** + * (!) HOOK WITH REQUEST + * + * @param props + * @param settingsInputFields + * @param setFieldsValidation + * @return callback to a post request that will be executed + */ +fun useSaveUser( + props: SettingsProps, + settingsInputFields: SettingsInputFields, + setFieldsValidation: FieldsStateSetter, +) = useDeferredRequest { + // this new user info will be sent to backend and also will be set in setter, + // so frontend will recalculate it on the fly at least for SettingsView (need to extend it later) + val newUserInfo = settingsInputFields.toUserInfo(props.userInfo!!) + + val response = post( + "$apiUrl/users/save", + jsonHeaders, + Json.encodeToString(newUserInfo), + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler + ) + + if (response.isConflict()) { + val responseText = response.unpackMessage() + val newSettingsInputFields = settingsInputFields.updateValue(InputTypes.LOGIN, null, responseText) + setFieldsValidation(newSettingsInputFields) + } else { + val newSettingsInputFields = settingsInputFields.updateValue(InputTypes.LOGIN, null, "") + setFieldsValidation(newSettingsInputFields) + props.userInfoSetter(newUserInfo) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsViewLeftColumn.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsViewLeftColumn.kt new file mode 100644 index 0000000000..494c7782a2 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsViewLeftColumn.kt @@ -0,0 +1,174 @@ +/** + * In settings view we have two columns: this one is the left one + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings + +import com.saveourtool.save.frontend.common.components.basic.avatarRenderer +import com.saveourtool.save.frontend.common.components.views.userprofile.shortenLoginWithTooltipIfNecessary +import com.saveourtool.save.frontend.common.components.views.userprofile.shortenRealNameWithTooltipIfNecessary +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.validation.FrontendRoutes +import js.core.jso +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.h4 +import react.dom.html.ReactHTML.h6 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.nav +import react.router.dom.Link +import web.cssom.Background +import web.cssom.ClassName +import web.cssom.TextDecoration +import web.cssom.rem + +internal const val AVATAR_TITLE = "Upload avatar" + +val leftSettingsColumn: FC<SettingsProps> = FC { props -> + val (avatarImgLink, setAvatarImgLink) = useState<String?>(null) + val (t) = useTranslation("profile") + + div { + className = ClassName("card card-body pt-0 px-0 shadow") + style = cardHeight + div { + className = ClassName("col mr-2 px-0") + style = jso { + background = "#e1e9ed".unsafeCast<Background>() + } + div { + className = ClassName("mb-0 font-weight-bold text-gray-800") + form { + div { + className = ClassName("row g-3 ml-3 mr-3 pb-2 pt-2 border-bottom") + div { + className = ClassName("col") + div { + className = ClassName("row justify-content-center") + img { + className = ClassName("avatar avatar-user width-full border color-bg-default rounded-circle") + src = avatarImgLink + ?: props.userInfo?.avatar?.avatarRenderer() + ?: AVATAR_PROFILE_PLACEHOLDER + style = jso { + height = 12.rem + width = 12.rem + } + } + } + div { + className = ClassName("col text-center mt-2") + div { + className = ClassName("row justify-content-center") + h4 { + className = ClassName("mb-0 text-gray-800") + shortenLoginWithTooltipIfNecessary(props.userInfo?.name, this) + } + } + div { + className = ClassName("row justify-content-center") + h6 { + className = ClassName("mb-0 text-gray-800") + shortenRealNameWithTooltipIfNecessary(props.userInfo?.realName, this) + } + } + div { + className = ClassName("row justify-content-center") + h6 { + Link { + to = "/${FrontendRoutes.VULN_PROFILE}/${props.userInfo?.name}" + style = jso { + textDecoration = TextDecoration.underline + } + +"profile".t() + } + } + } + } + } + } + } + } + } + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + settingsTabs {} + } +} + +val settingsTabs: FC<Props> = FC { + val (t) = useTranslation("profile") + + div { + className = ClassName("col mr-2 px-0") + nav { + div { + className = ClassName("px-3 mt-3 ui vertical menu profile-setting") + form { + settingMenuHeader("Basic Settings".t()) + div { + className = ClassName("menu") + settingsMenuTab(FrontendRoutes.SETTINGS_PROFILE, "Profile settings".t(), faUser) + settingsMenuTab(FrontendRoutes.SETTINGS_EMAIL, "Login and email".t(), faEnvelope) + settingsMenuTab(FrontendRoutes.SETTINGS_ORGANIZATIONS, "Organizations".t(), faCity) + } + } + form { + div { + className = ClassName("separator mt-3 mb-3") + } + settingMenuHeader("Security Settings".t()) + div { + className = ClassName("menu") + settingsMenuTab(FrontendRoutes.SETTINGS_TOKEN, "Personal access tokens".t(), faKey) + settingsMenuTab(FrontendRoutes.SETTINGS_TOKEN, "OAuth accounts".t(), faGithub) + } + } + form { + div { + className = ClassName("separator mt-3 mb-3") + } + settingMenuHeader("Other".t()) + div { + className = ClassName("menu") + settingsMenuTab(FrontendRoutes.SETTINGS_TOKEN, "Personal Statistics".t(), faPlus) + settingsMenuTab( + FrontendRoutes.SETTINGS_DELETE, + "Delete Profile".t(), + faWindowClose, + "btn-outline-danger" + ) + } + } + } + } + } +} + +private fun ChildrenBuilder.settingMenuHeader(header: String) { + div { + className = ClassName("header") + +header + } +} + +private fun ChildrenBuilder.settingsMenuTab( + link: FrontendRoutes, + text: String, + icon: FontAwesomeIconModule, + style: String = "btn-outline-dark" +) { + div { + className = ClassName("mt-2") + Link { + className = ClassName("btn $style btn-block text-left shadow") + to = "/$link" + fontAwesomeIcon(icon = icon) { + it.className = "fas fa-sm fa-fw mr-2" + } + +text + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsViewRightColumn.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsViewRightColumn.kt new file mode 100644 index 0000000000..8b198cab73 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/SettingsViewRightColumn.kt @@ -0,0 +1,50 @@ +/** + * In settings view we have two columns: this one is the right one + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings + +import com.saveourtool.save.frontend.common.components.views.usersettings.right.deleteSettingsCard +import com.saveourtool.save.frontend.common.components.views.usersettings.right.emailSettingsCard +import com.saveourtool.save.frontend.common.components.views.usersettings.right.organizationsSettingsCard +import com.saveourtool.save.frontend.common.components.views.usersettings.right.profile.profileSettingsCard +import com.saveourtool.save.frontend.common.components.views.usersettings.right.tokenSettingsCard +import com.saveourtool.save.validation.FrontendRoutes.* + +import react.FC +import react.dom.html.ReactHTML.div +import web.cssom.ClassName + +val rightSettingsColumn: FC<SettingsProps> = FC { props -> + div { + className = ClassName("card card-body mt-0 pt-0 px-0 text-gray-800 shadow") + style = cardHeight + when (props.type) { + SETTINGS_PROFILE -> profileSettingsCard { + this.userInfo = props.userInfo + this.type = props.type + this.userInfoSetter = props.userInfoSetter + } + SETTINGS_EMAIL -> emailSettingsCard { + this.userInfo = props.userInfo + this.type = props.type + this.userInfoSetter = props.userInfoSetter + } + SETTINGS_TOKEN -> tokenSettingsCard { + this.userInfo = props.userInfo + this.type = props.type + } + SETTINGS_ORGANIZATIONS -> organizationsSettingsCard { + this.userInfo = props.userInfo + this.type = props.type + } + SETTINGS_DELETE -> deleteSettingsCard { + this.userInfo = props.userInfo + this.type = props.type + } + else -> { + // FixMe: finish stub here + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Delete.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Delete.kt new file mode 100644 index 0000000000..dc677da858 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Delete.kt @@ -0,0 +1,97 @@ +/** + * rendering for Delete User management card + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings.right + +import com.saveourtool.save.frontend.common.components.modal.displayModal +import com.saveourtool.save.frontend.common.components.modal.mediumTransparentModalStyle +import com.saveourtool.save.frontend.common.components.views.usersettings.SettingsProps +import com.saveourtool.save.frontend.common.utils.* + +import js.core.jso +import org.w3c.fetch.Headers +import react.FC +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.img +import web.cssom.ClassName +import web.cssom.rem + +import kotlinx.browser.window + +val deleteSettingsCard: FC<SettingsProps> = FC { props -> + val deleteUserWindowOpenness = useWindowOpenness() + + @Suppress("TOO_MANY_LINES_IN_LAMBDA") + val deleteUser = useDeferredRequest { + props.userInfo?.name?.let { + val response = get( + url = "$apiUrl/users/delete", + params = jso<dynamic> { + userName = it + }, + headers = jsonHeaders, + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ) + if (response.ok) { + val replyToLogout = post( + "${window.location.origin}/logout", + Headers(), + "ping", + loadingHandler = ::loadingHandler, + ) + if (replyToLogout.ok) { + window.location.href = "${window.location.origin}/" + window.location.reload() + } + } + } + } + + displayModal( + deleteUserWindowOpenness.isOpen(), + "Deletion of user profile", + "Are you sure you want to permanently delete your profile? You will never be able to restore it again.", + mediumTransparentModalStyle, + deleteUserWindowOpenness.closeWindowAction(), + ) { + buttonBuilder("Yes", isActive = props.userInfo != null) { + deleteUser() + deleteUserWindowOpenness.closeWindow() + } + buttonBuilder("Cancel", "secondary") { + deleteUserWindowOpenness.closeWindow() + } + } + + div { + className = ClassName("row justify-content-center mt-5") + img { + src = "/img/sad_cat.png" + @Suppress("MAGIC_NUMBER") + style = jso { + width = 14.rem + } + } + } + + div { + className = ClassName("row align-items-center justify-content-center mt-4") + h2 { + className = ClassName("mt-2 mr-2 text-gray-800") + +"Want to leave us?" + } + } + + div { + className = ClassName("row align-items-center justify-content-center") + div { + className = ClassName("col-4 text-center") + buttonBuilder("Delete your profile", style = "danger") { + deleteUserWindowOpenness.openWindow() + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Email.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Email.kt new file mode 100644 index 0000000000..be6afa85c7 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Email.kt @@ -0,0 +1,98 @@ +/** + * rendering for Email and Login management card + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings.right + +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.views.usersettings.SettingsProps +import com.saveourtool.save.frontend.common.components.views.usersettings.inputForm +import com.saveourtool.save.frontend.common.components.views.usersettings.right.validation.validateLogin +import com.saveourtool.save.frontend.common.components.views.usersettings.right.validation.validateUserEmail +import com.saveourtool.save.frontend.common.components.views.usersettings.useSaveUser +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.validation.FrontendRoutes +import js.core.jso +import react.FC +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h3 +import react.dom.html.ReactHTML.hr +import react.dom.html.ReactHTML.img +import react.router.dom.Link +import react.useState +import web.cssom.ClassName +import web.cssom.rem + +val emailSettingsCard: FC<SettingsProps> = FC { props -> + val (settingsInputFields, setSettingsInputFields) = useState(SettingsInputFields()) + val saveUser = useSaveUser(props, settingsInputFields, setSettingsInputFields) + val (t) = useTranslation("profile") + + div { + className = ClassName("row justify-content-center mt-5") + img { + src = "/img/settings_icon1.png" + style = jso { + height = 10.rem + width = 10.rem + } + } + } + + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mb-4 mt-4") + h3 { + className = ClassName("mt-2 mr-2 text-gray-800") + +"Login and Email" + } + + Link { + to = "/${FrontendRoutes.TERMS_OF_USE}" + buttonBuilder( + "terms of usage", + style = "outline-secondary", + classes = "rounded-pill btn-sm", + isOutline = false + ) { + } + } + } + + div { + className = ClassName("col mt-2 px-5") + inputForm( + props.userInfo?.name, + InputTypes.LOGIN, + settingsInputFields, + setSettingsInputFields, + translate = t, + colRatio = "col-2" to "col-6", + ) { validateLogin() } + + inputForm( + props.userInfo?.email, + InputTypes.USER_EMAIL, + settingsInputFields, + setSettingsInputFields, + translate = t, + colRatio = "col-2" to "col-6" + ) { validateUserEmail() } + + div { + className = ClassName("row justify-content-center") + div { + className = ClassName("col-8") + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + hr { } + } + } + + div { + className = ClassName("row justify-content-center") + buttonBuilder("Save changes", style = "primary", isDisabled = settingsInputFields.containsError()) { + saveUser() + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Organizations.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Organizations.kt new file mode 100644 index 0000000000..03bb5aec0e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Organizations.kt @@ -0,0 +1,331 @@ +/** + * rendering for Organization management card + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings.right + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.entities.OrganizationStatus +import com.saveourtool.save.entities.OrganizationWithUsers +import com.saveourtool.save.filters.OrganizationFilter +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.components.views.usersettings.SettingsProps +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.validation.FrontendRoutes + +import js.core.jso +import org.w3c.fetch.Response +import react.ChildrenBuilder +import react.FC +import react.StateSetter +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h3 +import react.dom.html.ReactHTML.h5 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.ul +import react.router.dom.Link +import react.useState +import web.cssom.ClassName +import web.cssom.rem + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * CSS classes of the "delete project" button. + */ +val actionButtonClasses: List<String> = listOf("btn", "btn-small") + +/** + * CSS classes of the "delete project" icon. + */ +val actionIconClasses: List<String> = listOf("trash-alt") + +val organizationsSettingsCard: FC<SettingsProps> = FC { props -> + + val (organizations, setOrganizations) = useState<List<OrganizationWithUsers>>(emptyList()) + + val getOrganizationsForUser = useDeferredRequest { + val organizationDtos = post( + url = "$apiUrl/organizations/by-filters", + headers = jsonHeaders, + body = Json.encodeToString(OrganizationFilter.all), + loadingHandler = ::loadingHandler, + ) + .unsafeMap { it.decodeFromJsonString<List<OrganizationWithUsers>>() } + + setOrganizations(organizationDtos) + } + + useOnce { getOrganizationsForUser() } + + div { + className = ClassName("row justify-content-center") + div { + className = ClassName("col-8 px-5") + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mt-4") + h3 { + className = ClassName("mb-0 mt-2 text-gray-800") + +"Organizations" + } + } + + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mt-1") + Link { + to = "/${FrontendRoutes.CREATE_ORGANIZATION}" + buttonBuilder( + "Create new Organization", + style = "outline-primary rounded-pill btn-sm", + isOutline = false + ) { + } + } + } + + if (organizations.isEmpty()) { + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mt-5") + h5 { + className = ClassName("mt-2 text-gray-800") + +"You are not added to any organization" + } + } + div { + className = ClassName("d-sm-flex align-items-center justify-content-center mt-1") + img { + src = "/img/sad_cat.png" + @Suppress("MAGIC_NUMBER") + style = jso { + width = 14.rem + } + } + } + } else { + renderOrganizations(organizations, setOrganizations, props) + } + } + } +} + +private val comparator: Comparator<OrganizationWithUsers> = + compareBy<OrganizationWithUsers> { it.organization.status.ordinal } + .thenBy { it.organization.name } + +typealias OrganizationSetter = StateSetter<List<OrganizationWithUsers>> + +@Suppress("TOO_LONG_FUNCTION", "CyclomaticComplexMethod", "LongMethod") +private fun ChildrenBuilder.renderOrganizations( + organizations: List<OrganizationWithUsers>, + setOrganizations: OrganizationSetter, + props: SettingsProps +) { + ul { + className = ClassName("list-group list-group-flush") + organizations.forEach { organizationWithUsers -> + val organizationDto = organizationWithUsers.organization + li { + className = ClassName("list-group-item") + div { + className = ClassName("row justify-content-between align-items-center") + div { + val textClassName = when (organizationDto.status) { + OrganizationStatus.CREATED -> "text-primary" + OrganizationStatus.DELETED -> "text-secondary" + OrganizationStatus.BANNED -> "text-danger" + } + className = ClassName("align-items-center ml-3 $textClassName") + img { + className = + ClassName("avatar avatar-user width-full border color-bg-default rounded-circle mr-2") + src = organizationDto.avatar?.avatarRenderer() ?: AVATAR_ORGANIZATION_PLACEHOLDER + height = 60.0 + width = 60.0 + } + when (organizationDto.status) { + OrganizationStatus.CREATED -> Link { + to = "/${organizationDto.name}" + +organizationDto.name + } + + OrganizationStatus.DELETED -> { + +organizationDto.name + spanWithClassesAndText( + "text-secondary", + organizationDto.status.name.lowercase() + ) + } + + OrganizationStatus.BANNED -> { + +organizationDto.name + spanWithClassesAndText("text-danger", organizationDto.status.name.lowercase()) + } + } + } + div { + className = ClassName("col-5 text-right") + val role = + props.userInfo?.name?.let { organizationWithUsers.userRoles[it] } ?: Role.NONE + if (role.isHigherOrEqualThan(Role.OWNER)) { + when (organizationDto.status) { + OrganizationStatus.CREATED -> actionButton { + title = "WARNING: You are about to delete this organization" + errorTitle = "You cannot delete the organization ${organizationDto.name}" + message = + "Are you sure you want to delete the organization ${organizationDto.name}?" + buttonStyleBuilder = { childrenBuilder -> + with(childrenBuilder) { + fontAwesomeIcon( + icon = faTrashAlt, + classes = actionIconClasses.joinToString(" ") + ) + } + } + classes = actionButtonClasses.joinToString(" ") + modalButtons = { action, closeWindow, childrenBuilder, _ -> + with(childrenBuilder) { + buttonBuilder( + label = "Yes, delete ${organizationDto.name}", + style = "danger", + classes = "mr-2" + ) { + action() + closeWindow() + } + buttonBuilder("Cancel") { + closeWindow() + } + } + } + onActionSuccess = { _ -> + updateOrganizationWithUserInOrganizationWithUsersList( + organizationWithUsers, + changeOrganizationWithUserStatus( + organizationWithUsers, + OrganizationStatus.DELETED + ), + organizations, + setOrganizations, + ) + } + conditionClick = false + sendRequest = { _ -> + responseChangeOrganizationStatus( + organizationDto.name, + OrganizationStatus.DELETED + ) + } + } + + OrganizationStatus.DELETED -> actionButton { + title = "WARNING: You are about to recover this organization" + errorTitle = "You cannot recover the organization ${organizationDto.name}" + message = + "Are you sure you want to recover the organization ${organizationDto.name}?" + buttonStyleBuilder = { childrenBuilder -> + with(childrenBuilder) { + fontAwesomeIcon( + icon = faRedo, + classes = actionIconClasses.joinToString(" ") + ) + } + } + classes = actionButtonClasses.joinToString(" ") + modalButtons = { action, closeWindow, childrenBuilder, _ -> + with(childrenBuilder) { + buttonBuilder( + label = "Yes, recover ${organizationDto.name}", + style = "danger", + classes = "mr-2" + ) { + action() + closeWindow() + } + buttonBuilder("Cancel") { + closeWindow() + } + } + } + onActionSuccess = { _ -> + updateOrganizationWithUserInOrganizationWithUsersList( + organizationWithUsers, + changeOrganizationWithUserStatus( + organizationWithUsers, + OrganizationStatus.CREATED + ), + organizations, + setOrganizations, + ) + } + conditionClick = false + sendRequest = { _ -> + responseChangeOrganizationStatus( + organizationDto.name, + OrganizationStatus.CREATED + ) + } + } + + OrganizationStatus.BANNED -> Unit + } + } + div { + className = ClassName("mr-3") + +role.formattedName + } + } + } + } + } + } +} + +/** + * 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, + ) +} + +/** + * Removes [oldOrganizationWithUsers] by [selfOrganizationWithUserList], adds [newOrganizationWithUsers] in [selfOrganizationWithUserList] + * and sorts the resulting list by their status and then by name + * + * @param organizationWithUsers + * @param newStatus + */ +private fun updateOrganizationWithUserInOrganizationWithUsersList( + oldOrganizationWithUsers: OrganizationWithUsers, + newOrganizationWithUsers: OrganizationWithUsers, + organizations: List<OrganizationWithUsers>, + setOrganizations: OrganizationSetter +) = setOrganizations( + organizations.minusElement(oldOrganizationWithUsers) + .plusElement(newOrganizationWithUsers) + .sortedWith(comparator) +) + +/** + * Returned the [organizationWithUsers] with the updated [OrganizationStatus] field to the [newStatus] in the organization field + */ +private fun changeOrganizationWithUserStatus( + organizationWithUsers: OrganizationWithUsers, + newStatus: OrganizationStatus +) = + organizationWithUsers.copy(organization = organizationWithUsers.organization.copy(status = newStatus)) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/SettingsInputFields.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/SettingsInputFields.kt new file mode 100644 index 0000000000..fa0ce20652 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/SettingsInputFields.kt @@ -0,0 +1,137 @@ +/** + * In this file we describe tricky class that is used to store input information + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings.right + +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.info.UserInfo + +/** + * This Data class is used to STORE all input from input view. + * It is updated each time user touches input fields. + * + * And it is updated very carefully: + * 1) Only one field is updated, other fields are saved and used from previous selection + * 2) Each time we create immutable object with copy to prevent any potential problems + * 3) If the value was not changed or touched - we just have null in the corresponding field + * 4) In that case that null value does not break anything - we just do not update the value (user did not change it) + * and work with an old one + * @property userName + * @property userEmail + * @property realName + * @property company + * @property location + * @property website + * @property linkedIn + * @property github + * @property twitter + * @property freeText + */ +data class SettingsInputFields( + val userName: SettingsFromInput = SettingsFromInput(), + val userEmail: SettingsFromInput = SettingsFromInput(), + val realName: SettingsFromInput = SettingsFromInput(), + val company: SettingsFromInput = SettingsFromInput(), + val location: SettingsFromInput = SettingsFromInput(), + val website: SettingsFromInput = SettingsFromInput(), + val linkedIn: SettingsFromInput = SettingsFromInput(), + val github: SettingsFromInput = SettingsFromInput(), + val twitter: SettingsFromInput = SettingsFromInput(), + val freeText: SettingsFromInput = SettingsFromInput(), +) { + /** + * method that indicates that inside some input form we have a validation error + */ + fun containsError() = + listOf(userName, userEmail, realName, company, location, website, linkedIn, github, twitter, freeText) + .map { it.containsError() } + .any { it } + + /** + * Updating some particular field and saving all old fields that were not affected by this change + * + * @param inputType + * @param value + * @param validation + */ + fun updateValue(inputType: InputTypes, value: String?, validation: String) = + when (inputType) { + InputTypes.LOGIN -> this.copy(userName = singleFieldCopy(this.userName, SettingsFromInput(value, validation))) + InputTypes.USER_EMAIL -> this.copy(userEmail = singleFieldCopy(this.userEmail, SettingsFromInput(value, validation))) + InputTypes.REAL_NAME -> this.copy(realName = singleFieldCopy(this.realName, SettingsFromInput(value, validation))) + InputTypes.COMPANY -> this.copy(company = singleFieldCopy(this.company, SettingsFromInput(value, validation))) + InputTypes.LOCATION -> this.copy(location = singleFieldCopy(this.location, SettingsFromInput(value, validation))) + InputTypes.WEBSITE -> this.copy(website = singleFieldCopy(this.website, SettingsFromInput(value, validation))) + InputTypes.LINKEDIN -> this.copy(linkedIn = singleFieldCopy(this.linkedIn, SettingsFromInput(value, validation))) + InputTypes.GITHUB -> this.copy(github = singleFieldCopy(this.github, SettingsFromInput(value, validation))) + InputTypes.TWITTER -> this.copy(twitter = singleFieldCopy(this.twitter, SettingsFromInput(value, validation))) + InputTypes.FREE_TEXT -> this.copy(freeText = singleFieldCopy(this.freeText, SettingsFromInput(value, validation))) + else -> throw IllegalArgumentException("Invalid input type: $inputType") + } + + /** + * @param inputType + */ + fun getValueByType(inputType: InputTypes) = + when (inputType) { + InputTypes.LOGIN -> userName + InputTypes.USER_EMAIL -> userEmail + InputTypes.REAL_NAME -> realName + InputTypes.COMPANY -> company + InputTypes.LOCATION -> location + InputTypes.WEBSITE -> website + InputTypes.LINKEDIN -> linkedIn + InputTypes.GITHUB -> github + InputTypes.TWITTER -> twitter + InputTypes.FREE_TEXT -> freeText + else -> throw IllegalArgumentException("Invalid input type: $inputType") + } + + /** + * If an input field was somehow changed, we reflect it: + * we have both value and error in the same object (to have all logic in the same place) + * If value was not changed in the form (null) -> we save old value and add only change validation part + * If just validation was changed (some problem appeared or was fixed) - it will be also reflected here + */ + private fun singleFieldCopy(old: SettingsFromInput, new: SettingsFromInput): SettingsFromInput = when (Pair(new.value, new.validation)) { + // this scenario is now only used in the response from backend, when the login is duplicated + // in this case the value is not changed, but need to update validation part + Pair(null, new.validation) -> old.copy(validation = new.validation) + else -> new + } + + /** + * @param userInfo + * @return converted input fields to user info + */ + fun toUserInfo(userInfo: UserInfo): UserInfo { + val newName = this.userName.value?.trim() + return userInfo.copy( + name = newName ?: userInfo.name, + email = this.userEmail.value?.trim() ?: userInfo.email, + company = this.company.value?.trim() ?: userInfo.company, + location = this.location.value?.trim() ?: userInfo.location, + linkedin = this.linkedIn.value?.trim() ?: userInfo.linkedin, + gitHub = this.github.value?.trim() ?: userInfo.gitHub, + twitter = this.twitter.value?.trim() ?: userInfo.twitter, + website = this.website.value?.trim() ?: userInfo.website, + realName = this.realName.value?.trim() ?: userInfo.realName, + freeText = this.freeText.value?.trim() ?: userInfo.freeText, + ) + } +} + +/** + * @property value + * @property validation + */ +data class SettingsFromInput( + val value: String? = null, + val validation: String = "", +) { + /** + * @return true is validation is not empty + */ + fun containsError() = validation.isNotBlank() +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Token.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Token.kt new file mode 100644 index 0000000000..5881d37e20 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/Token.kt @@ -0,0 +1,160 @@ +/** + * rendering for Token management card + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings.right + +import com.saveourtool.save.frontend.common.components.views.usersettings.SettingsProps +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.buttonBuilder + +import js.core.jso +import react.FC +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.p +import react.useState +import web.cssom.ClassName +import web.cssom.TextAlign +import web.cssom.rem +import web.html.ButtonType + +// FixMe: add info about last created token + +const val TOKEN_TEXT = """ + This Token serves as an integral part of establishing a secure and stable connection + between your automated API and the SAVE platform. + The purpose of this Token is to provide capability to connect to SAVE Public API from different CI services: + to upload benchmarks, get results, etc. Can be extremely useful for your automated CI and testing process. +""" + +val tokenSettingsCard: FC<SettingsProps> = FC { props -> + val (token, setToken) = useState<String>() + + val postToken = useDeferredRequest { + post( + "$apiUrl/users/${props.userInfo?.name}/save/token", + jsonHeaders, + token, + loadingHandler = ::loadingHandler, + ) + .run { + if (!ok) { + setToken(null) + } + } + } + + div { + className = ClassName("row justify-content-center mt-5") + img { + src = "/img/settings_icon2.png" + style = jso { + height = 10.rem + width = 10.rem + } + } + } + + div { + className = ClassName("row justify-content-center mt-4") + h2 { + className = ClassName("text-gray-800") + +"Personal access token" + } + } + + div { + className = ClassName("row justify-content-center") + div { + className = ClassName("col-2 text-right") + a { + href = "https://github.com/saveourtool/save-cloud/tree/master/save-api" + buttonBuilder( + "API Readme", + style = "outline-secondary", + classes = "rounded-pill btn-sm", + isOutline = false + ) { } + } + } + + div { + className = ClassName("col-2 text-left") + a { + href = "https://github.com/saveourtool/save-cloud/tree/master/save-api-cli" + buttonBuilder( + "Usage example", + style = "outline-secondary", + classes = "rounded-pill btn-sm", + isOutline = false + ) { } + } + } + } + + div { + className = ClassName("row justify-content-center mt-4") + div { + className = ClassName("col-5") + p { + style = jso { + textAlign = TextAlign.justify + } + +TOKEN_TEXT + } + } + } + + div { + className = ClassName("row justify-content-center mt-4") + div { + className = ClassName("col-6 text-center") + button { + type = ButtonType.button + className = ClassName("btn btn-primary") + +"Generate new token" + onClick = { + setToken(generateToken()) + postToken() + } + } + } + } + + token?.let { + div { + className = ClassName("row justify-content-center mt-4") + div { + className = ClassName("col-6") + form { + className = ClassName("form-group text-center") + input { + value = token + required = true + className = ClassName("form-control text-center") + } + } + div { + className = ClassName("invalid-feedback d-block text-center") + +"This is your unique token. It will be shown to you only once! Please save it." + } + } + } + } +} + +@Suppress("MAGIC_NUMBER") +private fun generateToken(): String { + var token = "ghp_" + val charPool = ('a'..'z') + ('A'..'Z') + ('0'..'9') + while (token.length < 40) { + token += charPool.random() + } + return token +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/profile/AvatarSelector.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/profile/AvatarSelector.kt new file mode 100644 index 0000000000..63bd36831e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/profile/AvatarSelector.kt @@ -0,0 +1,161 @@ +/** + * Utilities for rendering avatars + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.components.views.usersettings.right.profile + +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.externals.fontawesome.faCamera +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.frontend.common.utils.UserInfoAwareMutablePropsWithChildren +import com.saveourtool.save.utils.AVATARS_PACKS_DIR +import com.saveourtool.save.utils.AvatarType.USER +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.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import react.router.dom.Link +import web.cssom.* +import web.file.File +import web.http.FormData + +import kotlinx.coroutines.await + +val avatarSelector: FC<UserInfoAwareMutablePropsWithChildren> = FC { props -> + val (avatar, setAvatar) = useState<File?>(null) + val (selectedAvatar, setSelectedAvatar) = useState("") + val avatarWindowOpen = useWindowOpenness() + + val setAvatarFromResources = useDeferredRequest { + val response = get( + url = "$apiUrl/avatar/avatar-update", + params = jso<dynamic> { + this.type = USER + this.resource = selectedAvatar + }, + jsonHeaders, + loadingHandler = ::loadingHandler, + ) + if (response.ok) { + props.userInfoSetter(props.userInfo?.copy(avatar = selectedAvatar)) + } + } + + val saveAvatar = useDeferredRequest { + avatar?.let { + val response = post( + url = "$apiUrl/avatar/upload", + params = jso<dynamic> { + this.owner = props.userInfo?.name + this.type = USER + }, + Headers().apply { append(CONTENT_LENGTH_CUSTOM, avatar.size.toString()) }, + FormData().apply { set(FILE_PART_NAME, avatar) }, + loadingHandler = ::loadingHandler, + ) + val text = response.text().await() + if (response.ok) { + props.userInfoSetter(props.userInfo?.copy(avatar = text)) + } + } + } + + // === image editor === + avatarForm { + isOpen = avatarWindowOpen.isOpen() + title = AVATAR_TITLE + onCloseWindow = { + saveAvatar() + avatarWindowOpen.closeWindow() + } + imageUpload = { file -> + setAvatar(file) + } + } + + div { + className = ClassName("row") + div { + className = ClassName("col-4") + div { + className = ClassName("row") + avatarEditor( + avatarWindowOpen, + ) + } + } + div { + className = ClassName("col-8") + // render prepared preselected avatars 3 in row + var lowerBound = 1 + for (i in 1..AVATARS_PACKAGE_COUNT) { + if (i % 3 == 0) { + div { + className = ClassName("row") + for (j in lowerBound..i) { + val newAvatar = "$AVATARS_PACKS_DIR/avatar$j.png" + div { + className = ClassName("animated-provider") + img { + className = + ClassName("avatar avatar-user width-full border color-bg-default rounded-circle shadow") + src = newAvatar + style = jso { + height = 5.1.rem + width = 5.1.rem + cursor = Cursor.pointer + } + onClick = { + setSelectedAvatar(newAvatar) + setAvatarFromResources() + } + } + } + } + lowerBound = i + 1 + } + } + } + } + } +} + +/** + * @param avatarWindowOpen + */ +internal fun ChildrenBuilder.avatarEditor( + avatarWindowOpen: WindowOpenness, +) { + Link { + className = ClassName("btn px-0 pt-0") + title = AVATAR_TITLE + onClick = { + avatarWindowOpen.openWindow() + } + div { + className = ClassName("card card-body shadow") + style = jso { + display = Display.flex + justifyContent = JustifyContent.center + height = 8.rem + width = 8.rem + } + div { + className = ClassName("row justify-content-center") + fontAwesomeIcon(faCamera, classes = "fa-xl") + } + div { + className = ClassName("row mt-2 justify-content-center") + +AVATAR_TITLE + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/profile/Profile.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/profile/Profile.kt new file mode 100644 index 0000000000..7fcc3fa422 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/profile/Profile.kt @@ -0,0 +1,179 @@ +/** + * rendering for Profile management card + */ + +@file:Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + +package com.saveourtool.save.frontend.common.components.views.usersettings.right.profile + +import com.saveourtool.save.frontend.common.components.inputform.InputTypes +import com.saveourtool.save.frontend.common.components.views.usersettings.* +import com.saveourtool.save.frontend.common.components.views.usersettings.FieldsStateSetter +import com.saveourtool.save.frontend.common.components.views.usersettings.SettingsProps +import com.saveourtool.save.frontend.common.components.views.usersettings.inputForm +import com.saveourtool.save.frontend.common.components.views.usersettings.right.SettingsInputFields +import com.saveourtool.save.frontend.common.components.views.usersettings.right.validation.* +import com.saveourtool.save.frontend.common.components.views.usersettings.useSaveUser +import com.saveourtool.save.frontend.common.externals.i18next.TranslationFunction +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.* + +import react.ChildrenBuilder +import react.FC +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h4 +import react.dom.html.ReactHTML.hr +import react.dom.html.ReactHTML.textarea +import react.useState +import web.cssom.* + +const val AVATARS_PACKAGE_COUNT = 9 + +val profileSettingsCard: FC<SettingsProps> = FC { props -> + val (t) = useTranslation("profile") + + // === states === + val (settingsInputFields, setSettingsInputFields) = useState(SettingsInputFields()) + val saveUser = useSaveUser(props, settingsInputFields, setSettingsInputFields) + + // === design === + div { + className = ClassName("row px-5 mt-3") + + hr { } + + div { + className = ClassName("col-6 text-center") + div { + className = ClassName("row mb-2") + h4 { + +"Add bio and info:".t() + } + } + div { + className = ClassName("row pr-5") + div { + className = ClassName("input-group needs-validation") + textarea { + className = ClassName("form-control shadow") + onChange = { + val newSettingsInputFields = + settingsInputFields.updateValue(InputTypes.FREE_TEXT, it.target.value, "") + setSettingsInputFields(newSettingsInputFields) + } + placeholder = "400 characters".t() + defaultValue = props.userInfo?.freeText + rows = 10 + maxLength = 400 + } + } + } + } + + div { + className = ClassName("col-6") + div { + className = ClassName("row mb-2") + h4 { + +"Upload or select avatar:".t() + } + } + + avatarSelector { + this.userInfo = props.userInfo + this.userInfoSetter = props.userInfoSetter + } + } + } + + div { + className = ClassName("row") + + div { + className = ClassName("col mt-2 px-5") + extraInformation(t, props, settingsInputFields, setSettingsInputFields) + + div { + className = ClassName("row justify-content-center") + buttonBuilder("Save changes", style = "primary", isDisabled = settingsInputFields.containsError()) { + saveUser() + } + } + } + } +} + +@Suppress("TOO_LONG_FUNCTION", "LongMethod") +private fun ChildrenBuilder.extraInformation( + translate: TranslationFunction, + props: SettingsProps, + settingsInputFields: SettingsInputFields, + setSettingsInputFields: FieldsStateSetter +) { + hr { } + + inputForm( + props.userInfo?.realName, + InputTypes.REAL_NAME, + settingsInputFields, + setSettingsInputFields, + translate, + "e.g. John Smith" + ) { validateRealName() } + + inputForm( + props.userInfo?.company, + InputTypes.COMPANY, + settingsInputFields, + setSettingsInputFields, + translate, + "e.g. FutureWay Inc." + ) { validateCompany() } + + inputForm( + props.userInfo?.location, + InputTypes.LOCATION, + settingsInputFields, + setSettingsInputFields, + translate, + "Beijing, China" + ) { validateLocation() } + + inputForm( + props.userInfo?.website, + InputTypes.WEBSITE, + settingsInputFields, + setSettingsInputFields, + translate, + "https://saveourtool.com" + ) { validateWebsite() } + + inputForm( + props.userInfo?.linkedin, + InputTypes.LINKEDIN, + settingsInputFields, + setSettingsInputFields, + translate, + "https://linkedin.com/yourname" + ) { validateLinkedIn() } + + inputForm( + props.userInfo?.gitHub, + InputTypes.GITHUB, + settingsInputFields, + setSettingsInputFields, + translate, + "https://github.com/yourname" + ) { validateGithub() } + + inputForm( + props.userInfo?.twitter, + InputTypes.TWITTER, + settingsInputFields, + setSettingsInputFields, + translate, + "https://x.com/yourname" + ) { validateTwitter() } + + hr { } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/validation/InputValidationUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/validation/InputValidationUtils.kt new file mode 100644 index 0000000000..c062554f90 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/usersettings/right/validation/InputValidationUtils.kt @@ -0,0 +1,91 @@ +/** + * Utility file with validation functions that should be passed to inputForm + */ + +package com.saveourtool.save.frontend.common.components.views.usersettings.right.validation + +import com.saveourtool.save.frontend.common.utils.UsefulUrls +import com.saveourtool.save.validation.* + +private val namingAllowedSymbols = setOf('-', '_', '.', ' ') +private val extendedNamingAllowedSymbols = namingAllowedSymbols + setOf(',', '\'') + +/** + * @return validation in inputField + */ +fun String.validateLogin(): String = if (isValidName(NAMING_MAX_LENGTH)) "" else NAME_ERROR_MESSAGE + +/** + * @return validation in inputField + */ +fun String.validateUserEmail(): String = if (isValidEmail()) "" else EMAIL_ERROR_MESSAGE + +/** + * @return validation in inputField + */ +fun String.validateRealName(): String = + if (!isValidName(NAMING_ALLOWED_LENGTH, namingAllowedSymbols)) { + "Name should contain only English letters and be less than $NAMING_ALLOWED_LENGTH symbols" + } else { + "" + } + +/** + * @return validation in inputField + */ +fun String.validateCompany(): String = + if (!isValidName(NAMING_ALLOWED_LENGTH, extendedNamingAllowedSymbols)) { + "Affiliation should contain only English letters and be less than $NAMING_ALLOWED_LENGTH symbols" + } else { + "" + } + +/** + * @return validation in inputField + */ +fun String.validateLocation(): String = + if (!isValidName(NAMING_ALLOWED_LENGTH, extendedNamingAllowedSymbols)) { + "Location should contain only English letters and be less than $NAMING_ALLOWED_LENGTH symbols" + } else { + "" + } + +/** + * @return validation in inputField + */ +fun String.validateWebsite(): String = + when { + this == "" -> "" + this.matches(UsefulUrls.WEBSITE.regex) && isValidLengthWebsite() -> "" + else -> "Url should start with ${UsefulUrls.WEBSITE.basicUrl} and be less than $WEBSITE_ALLOWED_LENGTH symbols" + } + +/** + * @return validation in inputField + */ +fun String.validateLinkedIn(): String = + when { + this == "" -> "" + this.matches(UsefulUrls.LINKEDIN.regex) -> "" + else -> "Url should start with ${UsefulUrls.LINKEDIN.basicUrl}" + } + +/** + * @return validation in inputField + */ +fun String.validateGithub(): String = + when { + this == "" -> "" + this.matches(UsefulUrls.GITHUB.regex) || this.matches(UsefulUrls.GITEE.regex) -> "" + else -> "Url should start with ${UsefulUrls.GITEE.basicUrl} or ${UsefulUrls.GITHUB.basicUrl}" + } + +/** + * @return validation in inputField + */ +fun String.validateTwitter(): String = + when { + this == "" -> "" + this.matches(UsefulUrls.XCOM.regex) || this.matches(UsefulUrls.TWITTER.regex) -> "" + else -> "Url should start with ${UsefulUrls.XCOM.basicUrl} or ${UsefulUrls.TWITTER.basicUrl}" + } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/component/UploadCosvButton.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/vuln/UploadCosvButton.kt similarity index 75% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/component/UploadCosvButton.kt rename to save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/vuln/UploadCosvButton.kt index f7ce601995..91af7ab125 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/component/UploadCosvButton.kt +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/vuln/UploadCosvButton.kt @@ -1,10 +1,10 @@ @file:Suppress("FILE_NAME_MATCH_CLASS") -package com.saveourtool.save.frontend.components.views.vuln.component +package com.saveourtool.save.frontend.common.components.views.vuln -import com.saveourtool.save.frontend.externals.fontawesome.faFile -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.utils.buttonBuilder +import com.saveourtool.save.frontend.common.externals.fontawesome.faFile +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.utils.buttonBuilder import com.saveourtool.save.validation.FrontendRoutes import react.FC import react.Props diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityTableComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/vuln/VulnerabilityTableComponent.kt similarity index 96% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityTableComponent.kt rename to save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/vuln/VulnerabilityTableComponent.kt index 830ffde2b7..47eea1cdad 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/vuln/VulnerabilityTableComponent.kt +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/vuln/VulnerabilityTableComponent.kt @@ -2,21 +2,20 @@ * View for vulnerability tables */ -package com.saveourtool.save.frontend.components.views.vuln +package com.saveourtool.save.frontend.common.components.views.vuln import com.saveourtool.save.entities.cosv.VulnerabilityMetadataDto import com.saveourtool.save.entities.vulnerability.VulnerabilityStatus import com.saveourtool.save.filters.VulnerabilityFilter import com.saveourtool.save.frontend.TabMenuBar -import com.saveourtool.save.frontend.components.basic.renderOrganizationWithName -import com.saveourtool.save.frontend.components.basic.renderUserAvatarWithName -import com.saveourtool.save.frontend.components.basic.table.filters.vulnerabilitiesFiltersRow -import com.saveourtool.save.frontend.components.tables.* -import com.saveourtool.save.frontend.components.views.contests.tab -import com.saveourtool.save.frontend.externals.i18next.useTranslation -import com.saveourtool.save.frontend.themes.Colors -import com.saveourtool.save.frontend.utils.* -import com.saveourtool.save.frontend.utils.noopResponseHandler +import com.saveourtool.save.frontend.common.components.basic.renderOrganizationWithName +import com.saveourtool.save.frontend.common.components.basic.renderUserAvatarWithName +import com.saveourtool.save.frontend.common.components.basic.table.filters.vulnerabilitiesFiltersRow +import com.saveourtool.save.frontend.common.components.tables.* +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.themes.Colors +import com.saveourtool.save.frontend.common.utils.* +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/welcome/MarketingTitles.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/MarketingTitles.kt new file mode 100644 index 0000000000..3187ea182f --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/MarketingTitles.kt @@ -0,0 +1,113 @@ +/** + * Utility methods for beautiful titles/slogans on welcome view + */ + +package com.saveourtool.save.frontend.common.components.views.welcome + +import com.saveourtool.save.frontend.common.externals.fontawesome.faChevronDown +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.h3 +import web.cssom.* + +/** + * @param textColor + * @param isDark + */ +fun ChildrenBuilder.saveWelcomeMarketingTitle(textColor: String, isDark: Boolean = false) { + div { + className = ClassName("col-4 text-left mt-5 $textColor") + marketingTitle("Software", isDark) + marketingTitle("Analysis", isDark) + marketingTitle("Verification &", isDark) + marketingTitle("Evaluation", isDark) + h3 { + if (isDark) { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + } + className = ClassName("mt-4") + +"Advanced open-source cloud eco-system for continuous integration, evaluation and benchmarking of software tools." + } + } +} + +/** + * @param textColor + * @param isDark + */ +fun ChildrenBuilder.vulnWelcomeMarketingTitle(textColor: String, isDark: Boolean = false) { + div { + className = ClassName("col-4 text-left mt-5 mx-5 $textColor") + marketingTitle("Vulnerability", isDark) + marketingTitle("Database", isDark) + marketingTitle(" and", isDark) + marketingTitle("Benchmark", isDark) + marketingTitle("Archive", isDark) + h3 { + if (isDark) { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + } + className = ClassName("mt-4") + +"A huge storage of known vulnerabilities." + } + } +} + +/** + * @param str + * @param isDark + */ +fun ChildrenBuilder.marketingTitle(str: String, isDark: Boolean) { + div { + if (isDark) { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + } + className = ClassName("mb-0 mt-0") + h1Bold(str[0].toString()) + h1Normal(str.substring(1, str.length)) + } +} + +/** + * @param col + */ +@Suppress("MAGIC_NUMBER") +fun ChildrenBuilder.chevron(col: String) { + div { + className = ClassName("mt-5 row justify-content-center") + h1 { + className = ClassName("mt-5 animate__animated animate__pulse animate__infinite") + style = jso { + fontSize = 5.rem + color = col.unsafeCast<Color>() + } + fontAwesomeIcon(faChevronDown) + } + } +} + +private fun ChildrenBuilder.h1Bold(str: String) = h1 { + +str + style = jso { + fontWeight = "bold".unsafeCast<FontWeight>() + display = Display.inline + fontSize = "4.5rem".unsafeCast<FontSize>() + } +} + +private fun ChildrenBuilder.h1Normal(str: String) = h1 { + +str + style = jso { + display = Display.inline + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/SaveWelcomeView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/SaveWelcomeView.kt new file mode 100644 index 0000000000..fab9bdfaf5 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/SaveWelcomeView.kt @@ -0,0 +1,130 @@ +/** + * A view related to the sign-in view + */ + +@file:Suppress( + "FILE_WILDCARD_IMPORTS", + "WildcardImport", + "MAGIC_NUMBER", + "FILE_NAME_MATCH_CLASS" +) + +package com.saveourtool.save.frontend.common.components.views.welcome + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.allSaveWelcomePagers +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.save.renderGeneralInfoPage +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.save.renderReadMorePage +import com.saveourtool.save.frontend.common.externals.animations.* +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.i18next.useTranslation +import com.saveourtool.save.frontend.common.themes.Colors +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.OauthProviderInfo +import com.saveourtool.save.validation.FrontendRoutes + +import js.core.jso +import org.w3c.fetch.Headers +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.main +import react.dom.html.ReactHTML.span +import web.cssom.* + +import kotlinx.browser.window + +val saveWelcomeView: FC<UserInfoAwarePropsWithChildren> = FC { props -> + val (t) = useTranslation("welcome") + useBackground(Style.SAVE_DARK) + val (oauthProviders, setOauthProviders) = useState<List<OauthProviderInfo>>(emptyList()) + + useRequest { + val oauthProviderInfoList: List<OauthProviderInfo>? = get( + "${window.location.origin}/sec/oauth-providers", + Headers(), + loadingHandler = ::loadingHandler, + responseHandler = ::noopResponseHandler, + ).run { if (ok) decodeFromJsonString() else null } + oauthProviderInfoList?.let { setOauthProviders(it) } + } + + main { + className = ClassName("main-content mt-0 ps") + div { + className = ClassName("page-header align-items-start") + style = jso { + height = "100vh".unsafeCast<Height>() + background = SAVE_DARK_GRADIENT.unsafeCast<Background>() + position = Position.relative + } + span { + className = ClassName("mask bg-gradient-dark opacity-6") + } + + div { + particles() + + className = ClassName("row justify-content-center") + // Marketing information + saveWelcomeMarketingTitle("text-white") + + // Sign-in header + div { + className = ClassName("col-3 mt-5 mb-3") + div { + className = ClassName("card z-index-0 fadeIn3 fadeInBottom") + // if user is not logged in - he needs to input credentials + props.userInfo?.let { + welcomeUserMenu(props.userInfo, Colors.SAVE_PRIMARY, t) { + div { + className = ClassName("text-sm") + menuTextAndLink("Contests".t(), FrontendRoutes.CONTESTS, faCode) + hrNoMargin() + menuTextAndLink("List of Projects".t(), FrontendRoutes.PROJECTS, faExternalLinkAlt) + hrNoMargin() + menuTextAndLink("Benchmarks Archive".t(), FrontendRoutes.AWESOME_BENCHMARKS, faFolderOpen) + hrNoMargin() + menuTextAndLink("Create new organization".t(), FrontendRoutes.CREATE_ORGANIZATION, faUser) + if (props.userInfo.isSuperAdmin()) { + hrNoMargin() + menuTextAndLink("Manage organizations".t(), FrontendRoutes.MANAGE_ORGANIZATIONS, faUser) + } + hrNoMargin() + menuTextAndLink("New project in organization".t(), FrontendRoutes.CREATE_PROJECT, faPlus) + } + } + } ?: inputCredentialsView(oauthProviders, Colors.SAVE_PRIMARY, "/${FrontendRoutes.PROJECTS}", t) + } + } + } + + chevron("#FFFFFF") + } + + div { + className = ClassName("min-vh-100") + style = jso { + background = SAVE_LIGHT_GRADIENT.unsafeCast<Background>() + } + + renderGeneralInfoPage() + + @Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") + scrollContainer { + scrollPage { } + allSaveWelcomePagers.forEach { pager -> + scrollPage { } + scrollPage { + pager.forEach { + animator { + animation = it.animation + it.renderPage(this) + } + } + } + } + } + + renderReadMorePage() + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/WelcomeUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/WelcomeUtils.kt new file mode 100644 index 0000000000..cf5641c2d5 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/WelcomeUtils.kt @@ -0,0 +1,192 @@ +/** + * File containing utils for welcome views rendering + */ + +package com.saveourtool.save.frontend.common.components.views.welcome + +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.i18next.TranslationFunction +import com.saveourtool.save.frontend.common.themes.Colors +import com.saveourtool.save.frontend.common.utils.OauthProvidersFeConfig +import com.saveourtool.save.frontend.common.utils.processRegistrationId +import com.saveourtool.save.info.OauthProviderInfo +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.validation.FrontendRoutes +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.form +import react.dom.html.ReactHTML.h4 +import react.dom.html.ReactHTML.hr +import react.dom.html.ReactHTML.p +import react.router.dom.Link +import web.cssom.* + +const val INPUT_CREDENTIALS_VIEW_CUSTOM_BG = "rgb(240, 240, 240)" + +/** + * @param oauthProviders + * @param primaryColor color of a shield + * @param continueLink link for `continue` button + * @param t [TranslationFunction] received from [com.saveourtool.save.frontend.externals.i18next.useTranslation] hook + */ +@Suppress("TOO_LONG_FUNCTION", "IDENTIFIER_LENGTH") +fun ChildrenBuilder.inputCredentialsView( + oauthProviders: List<OauthProviderInfo>, + primaryColor: Colors, + continueLink: String, + t: TranslationFunction, +) { + div { + className = ClassName("card-header p-0 position-relative mt-n4 mx-3 z-index-2 rounded") + style = jso { + background = INPUT_CREDENTIALS_VIEW_CUSTOM_BG.unsafeCast<Background>() + border = "1px solid".unsafeCast<Border>() + } + div { + className = ClassName("shadow-primary border-radius-lg py-3 pe-1 rounded") + style = jso { + backgroundColor = primaryColor.value.unsafeCast<BackgroundColor>() + } + h4 { + className = ClassName("text-white font-weight-bolder text-center mt-2 mb-3") + +"Sign in with".t() + } + } + div { + className = ClassName("row") + oauthProviders.map { + processRegistrationId( + OauthProvidersFeConfig( + size = @Suppress("MAGIC_NUMBER") 3.rem, + it, + ) + ) + } + } + } + + div { + className = ClassName("card-body") + form { + className = ClassName("needs-validation") + div { + className = ClassName("mt-4 text-sm text-center") + p { + className = ClassName("mb-0") + +"Don't have an account?".t() + } + + div { + className = ClassName("text-sm text-center") + h4 { + style = jso { + color = "#3075c0".unsafeCast<Color>() + } + Link { + to = continueLink + className = ClassName("text-gradient font-weight-bold ml-2 mr-2") + +"Continue".t() + fontAwesomeIcon(icon = faSignInAlt) + } + } + +"with limited functionality".t() + } + } + } + } +} + +/** + * Render nice menu with options built from [renderMenu] + * + * @param userInfo current [UserInfo] + * @param primaryColor color of `Welcome, {username}` shield + * @param t [TranslationFunction] received from [com.saveourtool.save.frontend.externals.i18next.useTranslation] hook + * @param renderMenu callback to render menu options + */ +@Suppress("IDENTIFIER_LENGTH") +fun ChildrenBuilder.welcomeUserMenu( + userInfo: UserInfo?, + primaryColor: Colors, + t: TranslationFunction, + renderMenu: ChildrenBuilder.() -> Unit, +) { + div { + className = ClassName("card-header p-0 position-relative mt-n4 mx-3 z-index-2 rounded") + div { + className = ClassName("shadow-primary border-radius-lg py-3 pe-1 rounded") + style = jso { + backgroundColor = primaryColor.value.unsafeCast<BackgroundColor>() + } + h4 { + className = ClassName("text-white font-weight-bolder text-center mt-2 mb-0") + div { + className = ClassName("row") + div { + className = ClassName("col text-center px-1 mb-3") + Link { + className = ClassName("text-gradient font-weight-bold ml-2 mr-2") + to = "/${FrontendRoutes.INDEX}" + fontAwesomeIcon(icon = faHome) { + it.color = "#FFFFFF" + } + } + } + } + +"${"Welcome".t()}, ${userInfo?.name}!" + } + } + } + + div { + className = ClassName("card-body") + div { + className = ClassName("text-sm") + renderMenu() + } + } +} + +/** + * Render styled [text] with link by [route]'s [FrontendRoutes.path] and leading [icon] + * + * @param text [String] to display + * @param route that menu options points to + * @param icon [FontAwesomeIcon] to display + */ +fun ChildrenBuilder.menuTextAndLink(text: String, route: FrontendRoutes, icon: FontAwesomeIconModule) { + Link { + className = ClassName("text-gradient font-weight-bold ml-2 mr-2") + to = "/$route" + h4 { + div { + className = ClassName("row ml-2 align-items-center") + style = jso { + color = "#3075c0".unsafeCast<Color>() + marginBottom = "0.0em".unsafeCast<Margin>() + } + div { + className = ClassName("col-1 d-flex justify-content-center") + fontAwesomeIcon(icon = icon) + } + div { + className = ClassName("col-11 d-flex justify-content-start") + +text + } + } + } + } +} + +/** + * Render horizontal line with [Margin] equals to `0.0em` + */ +fun ChildrenBuilder.hrNoMargin() { + hr { + style = jso { + marginTop = "0.0em".unsafeCast<Margin>() + marginBottom = "0.0em".unsafeCast<Margin>() + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/WelcomePager.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/WelcomePager.kt new file mode 100644 index 0000000000..0433dd2a7a --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/WelcomePager.kt @@ -0,0 +1,36 @@ +package com.saveourtool.save.frontend.common.components.views.welcome.pagers + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.save.* +import com.saveourtool.save.frontend.common.externals.animations.Animation +import react.ChildrenBuilder + +val allSaveWelcomePagers = listOf( + // listOf(HighLevelSave), + listOf(SloganAboutCi), + listOf(GeneralInfoFirstPicture, GeneralInfoSecondPicture, GeneralInfoThirdPicture, GeneralInfoFourthPicture), + listOf(SloganAboutBenchmarks), + listOf(AwesomeBenchmarks), + listOf(SloganAboutTests), + listOf(TestsSelector), + listOf(SloganAboutContests), + listOf(Contests) +) + +val allVulnerabilityWelcomePagers: List<List<WelcomePager>> = emptyList() + +/** + * common interface for all pagers on welcome view + */ +interface WelcomePager { + /** + * animation for the pager + */ + val animation: Animation + + /** + * rendering function - place your html code here + * + * @param childrenBuilder + */ + fun renderPage(childrenBuilder: ChildrenBuilder) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/AwesomeBenchmarks.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/AwesomeBenchmarks.kt new file mode 100644 index 0000000000..b32ab9ff08 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/AwesomeBenchmarks.kt @@ -0,0 +1,31 @@ +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.WelcomePager +import com.saveourtool.save.frontend.common.externals.animations.* + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.img +import web.cssom.Height +import web.cssom.rem + +@Suppress("CUSTOM_GETTERS_SETTERS", "MAGIC_NUMBER") +object AwesomeBenchmarks : WelcomePager { + override val animation: Animation + get() = batch(fade(), sticky()) + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + img { + @Suppress("MAGIC_NUMBER") + style = jso { + width = 80.rem + height = "auto".unsafeCast<Height>() + } + src = "/img/awesome_view.png" + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/Contests.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/Contests.kt new file mode 100644 index 0000000000..3c8bee8f21 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/Contests.kt @@ -0,0 +1,35 @@ +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.WelcomePager +import com.saveourtool.save.frontend.common.externals.animations.* + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.img +import web.cssom.Height +import web.cssom.rem + +/** + * Funny picture + */ +@Suppress("CUSTOM_GETTERS_SETTERS") +object Contests : WelcomePager { + @Suppress("MAGIC_NUMBER") + override val animation: Animation + get() = batch(fade(), sticky()) + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + img { + @Suppress("MAGIC_NUMBER") + style = jso { + width = 80.rem + height = "auto".unsafeCast<Height>() + } + src = "/img/contests.png" + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/GeneralInfo.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/GeneralInfo.kt new file mode 100644 index 0000000000..1a169bd879 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/GeneralInfo.kt @@ -0,0 +1,89 @@ +/** + * [1 page] Main information about SAVE-cloud + */ + +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.p +import web.cssom.* + +private const val SAVE_CLI_TEXT = """ + Imagine, that you are a developer of some complex tool, for example a Compiler or a Static Analyzer. + What are first things that you will do after (or even before) coding your initial functionality? + Correct! You will start writing a test framework with functional tests to validate and fixate the code to avoid + regressions. The logic for these frameworks is very simple: they are running the tool on some text file and validate + the output. We decided to propose a universal native test framework (SAVE-cli) that will have it's own DSL and will + be covering common testing scenarios that developer of such group of tools can face. + """ + +private const val EASY_CI_TEXT = """ + Same is done by hundreds of teams that create system programming tools - they are reinventing the quick-and-dirty + wheel again and again to run their tests somehow. If the project becomes mature and big enough, the number of such + tests grows exponentially (some compilers have more than 500k tests). In this case Continuous Integration becomes a + hard challenge as the time spent on running such tests and analyzing results tends to infinity. SAVE-cloud provides + you a distributed cloud cluster for the parallelization of SAVE-cli runs, dashboards for results and historical + information for the detection of regressions. + """ + +private const val BENCHMARKS_TEXT = """ + SAVE can run several open benchmarks for validation of your tools, for example MISRA, NIST JULIET, + etc for static analyzers. But if you feel that your test pack is good enough to become a some sort of a standard benchmark + - you can easily share it with the community, just follow SAVE format and provide git URL to your tests. + SAVE will automatically download it, prepare a snapshot for each commit and community will get a chance to + use it for testing and even certifying of their tools. If you don't like to share sources, + SAVE provides a possibility to share private tests, that will be used for a black-box testing: only results + of testing (pass-rate) will be shown. We think that it is the best variant to avoid doing the same work and + writing same tests again and again. + """ + +private const val CONTESTS_TEXT = """ + If CI platform, sharing of your tests with community or the usage of existing benchmarks are not enough for your + project, then you can challenge other tools in SAVE contests. In these contests you need to pass as much tests + as possible to get the highest rating and become the champion. In each contest you get one open example of + benchmark and multiple closed benchmarks from the same category. For example - if the contest is related to + static analysis, you will get a pack of tests where your tool should find NPE and one open code snippet with possible + null pointer. If participating is not enough for you - then you can also create your own contests with your own challenges. + """ + +/** + * rendering of 4 paragraphs with info about SAVE + */ +fun ChildrenBuilder.renderGeneralInfoPage() { + div { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + className = ClassName("row justify-content-center") + + div { + className = ClassName("row justify-content-center mt-5") + text("Framework", SAVE_CLI_TEXT) + text("Easy CI", EASY_CI_TEXT) + } + + div { + className = ClassName("row justify-content-center") + text("Benchmarks", BENCHMARKS_TEXT) + text("Contests", CONTESTS_TEXT) + } + } +} + +private fun ChildrenBuilder.text(title: String, textStr: String) { + div { + className = ClassName("col-3 mx-3") + h1 { + style = jso { + textAlign = TextAlign.center + } + +title + } + p { + +textStr + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/GeneralInfoPictures.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/GeneralInfoPictures.kt new file mode 100644 index 0000000000..43e6378e87 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/GeneralInfoPictures.kt @@ -0,0 +1,120 @@ +/** + * 4 pictures with animation and screenshots from SAVE + */ + +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.WelcomePager +import com.saveourtool.save.frontend.common.externals.animations.* + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.img +import web.cssom.Color +import web.cssom.rem + +@Suppress("CUSTOM_GETTERS_SETTERS") +object GeneralInfoFirstPicture : WelcomePager { + override val animation: Animation + get() = fadeUpTopLeft + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + h1 { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + +"Easy to start" + } + img { + @Suppress("MAGIC_NUMBER") + style = jso { + width = 30.rem + } + src = "/img/run_view.png" + } + } +} + +@Suppress("CUSTOM_GETTERS_SETTERS") +object GeneralInfoSecondPicture : WelcomePager { + override val animation: Animation + get() = fadeUpTopRight + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + h1 { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + +"User-friendly dashboards" + } + img { + @Suppress("MAGIC_NUMBER") + style = jso { + width = 30.rem + } + src = "/img/exec_view.png" + } + } +} + +@Suppress("CUSTOM_GETTERS_SETTERS") +object GeneralInfoThirdPicture : WelcomePager { + override val animation: Animation + get() = fadeUpBottomLeft + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + h1 { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + +"Statistics for your tool" + } + img { + @Suppress("MAGIC_NUMBER") + + style = jso { + width = 30.rem + } + src = "/img/stat_view.png" + } + } +} + +@Suppress("CUSTOM_GETTERS_SETTERS") +object GeneralInfoFourthPicture : WelcomePager { + override val animation: Animation + get() = fadeUpBottomRight + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + h1 { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + +"Build a team" + } + img { + @Suppress("MAGIC_NUMBER") + style = jso { + width = 30.rem + } + src = "/img/organization_view.png" + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/HighLevelSave.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/HighLevelSave.kt new file mode 100644 index 0000000000..4126156cfb --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/HighLevelSave.kt @@ -0,0 +1,22 @@ +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.WelcomePager +import com.saveourtool.save.frontend.common.externals.animations.* +import react.ChildrenBuilder +import react.dom.html.ReactHTML.img + +@Suppress("CUSTOM_GETTERS_SETTERS") +object HighLevelSave : WelcomePager { + override val animation: Animation + get() = moveUpFromBottom + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + img { + src = "/img/save_hl.png" + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/ReadMore.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/ReadMore.kt new file mode 100644 index 0000000000..290af7de4b --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/ReadMore.kt @@ -0,0 +1,86 @@ +/** + * [Last page] Main information about SAVE-cloud + */ + +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.h3 +import react.router.dom.Link +import web.cssom.AlignItems +import web.cssom.AlignSelf +import web.cssom.ClassName +import web.cssom.Color +import web.cssom.Display +import web.cssom.JustifyContent +import web.cssom.TextAlign +import web.cssom.em + +/** + * rendering of "Read more" section + * + * @param platformName + */ +@Suppress("MAGIC_NUMBER") +fun ChildrenBuilder.renderReadMorePage(platformName: String = "SAVE") { + div { + className = ClassName("col") + style = jso { + this.bottom = 30.em + color?.let { this.color = it } + } + + wantToKnowMore(platformName) + + div { + className = ClassName("col justify-content-center") + div { + className = ClassName("row justify-content-center") + link("https://github.com/saveourtool/save-cloud", "Github") + } + div { + className = ClassName("row justify-content-center") + link("https://github.com/saveourtool/save-cloud/blob/master/info/SaveMotivation.md", "Motivation") + } + div { + className = ClassName("row justify-content-center") + link("/about", "About us") + } + } + } +} + +private fun ChildrenBuilder.wantToKnowMore(platformName: String) { + div { + className = ClassName("col justify-content-center") + h1 { + style = jso { + textAlign = TextAlign.center + color = "rgb(6, 7, 89)".unsafeCast<Color>() + } + +"Want to know more about $platformName?" + } + } +} + +private fun ChildrenBuilder.link(url: String, text: String) { + div { + className = ClassName("col-2") + style = jso { + justifyContent = JustifyContent.center + display = Display.flex + alignItems = AlignItems.center + alignSelf = AlignSelf.center + } + Link { + to = url + h3 { + className = ClassName("text-center") + +text + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/Slogan.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/Slogan.kt new file mode 100644 index 0000000000..77e4c94183 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/Slogan.kt @@ -0,0 +1,62 @@ +/** + * Slogans that will be used in welcome page + */ + +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.WelcomePager +import com.saveourtool.save.frontend.common.externals.animations.Animation +import com.saveourtool.save.frontend.common.externals.animations.zoomInScrollOut + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.h1 +import react.router.dom.Link +import web.cssom.Color +import web.cssom.FontSize +import web.cssom.TextAlign + +/** + * Class for a highlighted slogans + */ +@Suppress("CUSTOM_GETTERS_SETTERS") +open class Slogan( + private val text: String, + private val linkUrl: String = "", + private val linkText: String = "" +) : WelcomePager { + override val animation: Animation + get() = zoomInScrollOut + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + h1 { + style = jso { + color = "rgb(6, 7, 89)".unsafeCast<Color>() + textAlign = TextAlign.center + fontSize = "3.0rem".unsafeCast<FontSize>() + } + +text + } + if (linkUrl.isNotEmpty()) { + Link { + to = linkUrl + h1 { + style = jso { + textAlign = TextAlign.center + fontSize = "3.0rem".unsafeCast<FontSize>() + } + +linkText + } + } + } + } +} + +object SloganAboutCi : Slogan("Cloud CI platform with a main focus on code analyzers") +object SloganAboutTests : Slogan("Share your tests with the community") +object SloganAboutBenchmarks : Slogan("Archive with popular community benchmarks") +object SloganAboutContests : Slogan("Participate in Community", "/contests", "Contests") diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/TestsSelector.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/TestsSelector.kt new file mode 100644 index 0000000000..2e7608ed07 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/components/views/welcome/pagers/save/TestsSelector.kt @@ -0,0 +1,30 @@ +package com.saveourtool.save.frontend.common.components.views.welcome.pagers.save + +import com.saveourtool.save.frontend.common.components.views.welcome.pagers.WelcomePager +import com.saveourtool.save.frontend.common.externals.animations.* + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.img +import web.cssom.Height +import web.cssom.rem + +@Suppress("CUSTOM_GETTERS_SETTERS", "MAGIC_NUMBER") +object TestsSelector : WelcomePager { + override val animation: Animation + get() = batch(fade(), sticky()) + + override fun renderPage(childrenBuilder: ChildrenBuilder) { + childrenBuilder.renderAnimatedPage() + } + + private fun ChildrenBuilder.renderAnimatedPage() { + img { + style = jso { + width = 40.rem + height = "auto".unsafeCast<Height>() + } + src = "/img/tests_selector.png" + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/Particles.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/Particles.kt new file mode 100644 index 0000000000..d031772075 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/Particles.kt @@ -0,0 +1,22 @@ +/** + * Particles - is a special animation that makes background of pages dynamic. + * https://github.com/matteobruni/tsparticles + */ + +@file:JsModule("react-tsparticles") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.animations + +import react.* + +@JsName("default") +external class Particles : Component<ParticlesProps, State> { + override fun render(): ReactElement<ParticlesProps>? +} + +@JsName("IParticlesProps") +external interface ParticlesProps : PropsWithChildren { + var id: String + var url: String +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/RingLoader.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/RingLoader.kt new file mode 100644 index 0000000000..29f4fc7aaa --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/RingLoader.kt @@ -0,0 +1,17 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") +@file:JsModule("react-spinners") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.animations + +import react.* + +@JsName("LoaderSizeProps") +external interface LoaderSizeProps : PropsWithChildren + +/** + * @param options + * @return react element with a ring loader + */ +@JsName("RingLoader") +external fun ringLoader(options: dynamic = definedExternally): ReactElement<LoaderSizeProps>? diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/ScrollMotion.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/ScrollMotion.kt new file mode 100644 index 0000000000..67d5381320 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/ScrollMotion.kt @@ -0,0 +1,192 @@ +/** + * Very impressive and beautiful library with animation for scrolling: + * https://github.com/1000ship/react-scroll-motion + */ + +@file:JsModule("react-scroll-motion") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.animations + +import react.FC +import react.PropsWithChildren + +@JsName("ScrollContainer") +external val scrollContainer: FC<ScrollContainerProps> + +@JsName("ScrollPage") +external val scrollPage: FC<ScrollPageProps> + +@JsName("Animator") +external val animator: FC<AnimatorProps> + +// =================core========================== +@JsName("ScrollContainerProps") +external interface ScrollContainerProps : PropsWithChildren { + var snap: String + var scrollParent: dynamic +} + +@JsName("ScrollPageProps") +external interface ScrollPageProps : PropsWithChildren { + var debugBorder: Boolean + var page: dynamic +} + +@JsName("AnimatorProps") +external interface AnimatorProps : PropsWithChildren { + var animation: Animation +} + +@JsName("Animation") +external interface Animation + +/** + * @param left position on the page (x) + * @param top position on the page (y) + * @return animation + */ +// =================sticky animation========================== +@JsName("Sticky") +external fun sticky( + left: Number = definedExternally, + top: Number = definedExternally, +): Animation + +/** + * @param left position on the page (x) + * @param top position on the page (y) + * @return animation + */ +@JsName("StickyIn") +external fun stickyIn( + left: Number = definedExternally, + top: Number = definedExternally, +): Animation + +/** + * @param left position on the page (x) + * @param top position on the page (y) + * @return animation + */ +@JsName("StickyOut") +external fun stickyOut( + left: Number = definedExternally, + top: Number = definedExternally, +): Animation + +// =================fade animation========================== + +/** + * @param from initial opacity + * @param to final opacity + * @return animation + */ +@JsName("Fade") +external fun fade( + from: Number = definedExternally, + to: Number = definedExternally, +): Animation + +/** + * @param from initial opacity + * @param to final opacity + * @return animation + */ +@JsName("FadeIn") +external fun fadeIn( + from: Number = definedExternally, + to: Number = definedExternally +): Animation + +/** + * @param from initial opacity + * @param to final opacity + * @return animation + */ +@JsName("FadeOut") +external fun fadeOut( + from: Number = definedExternally, + to: Number = definedExternally +): Animation + +// =================move animation========================== + +/** + * @param dx initial x coordinate + * @param dy initial y coordinate + * @param outDx target x coordinate + * @param outDy target y coordinate + * @return animation + */ +@JsName("Move") +external fun move( + dx: Number = definedExternally, + dy: Number = definedExternally, + outDx: Number = definedExternally, + outDy: Number = definedExternally +): Animation + +/** + * @param dx initial x coordinate + * @param dy initial y coordinate + * @return animation + */ +@JsName("MoveIn") +external fun moveIn( + dx: Number = definedExternally, + dy: Number = definedExternally +): Animation + +/** + * @param dx initial x coordinate + * @param dy initial y coordinate + * @return animation + */ +@JsName("MoveOut") +external fun moveOut( + dx: Number = definedExternally, + dy: Number = definedExternally +): Animation + +// =================zoom animation========================== + +/** + * @param from initial zoom size + * @param to final zoom size + * @return animation + */ +@JsName("Zoom") +external fun zoom( + from: Number = definedExternally, + to: Number = definedExternally, +): Animation + +/** + * @param from initial zoom size + * @param to final zoom size + * @return animation + */ +@JsName("ZoomIn") +external fun zoomIn( + from: Number = definedExternally, + to: Number = definedExternally, +): Animation + +/** + * @param from initial zoom size + * @param to final zoom size + * @return animation + */ +@JsName("ZoomOut") +external fun zoomOut( + from: Number = definedExternally, + to: Number = definedExternally, +): Animation + +/** + * @param animations + * @return batched and merged animation from the list of several animations + */ +@JsName("batch") +external fun batch(vararg animations: Animation): Animation diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/ScrollMotionConstants.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/ScrollMotionConstants.kt new file mode 100644 index 0000000000..5ab9437cfc --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/animations/ScrollMotionConstants.kt @@ -0,0 +1,19 @@ +/** + * Prepared animation styles + */ + +@file:Suppress("MAGIC_NUMBER") + +package com.saveourtool.save.frontend.common.externals.animations + +val zoomInScrollOut = batch(fade(), zoomIn(), sticky()) + +val fadeUpTopLeft = batch(fade(), move(-300, 0, -300, 0), sticky(40, 25)) +val fadeUpBottomLeft = batch(fade(), move(-300, 0, -300, 0), sticky(40, 75)) + +val fadeUpTopRight = batch(fade(), move(300, 0, 300, 0), sticky(70, 25)) +val fadeUpBottomRight = batch(fade(), move(300, 0, 300, 0), sticky(70, 75)) + +val moveUpFromBottom = batch(fade(), move(dy = 1000), sticky(55)) + +val simplyFade = batch(fade(), sticky()) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/calendar/ReactCalendar.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/calendar/ReactCalendar.kt new file mode 100644 index 0000000000..b51c9af150 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/calendar/ReactCalendar.kt @@ -0,0 +1,87 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("react-calendar") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.calendar + +import org.w3c.dom.events.Event +import react.Component +import react.PropsWithChildren +import react.ReactElement +import react.State +import kotlin.js.Date + +/** + * External declaration of [ReactCalendarProps] react component + */ +@JsName("Calendar") +external class ReactCalendar : Component<ReactCalendarProps, State> { + override fun render(): ReactElement<ReactCalendarProps>? +} + +/** + * Props of [ReactCalendarProps] + */ +external interface ReactCalendarProps : PropsWithChildren { + /** + * The beginning of a period that shall be displayed. + * If you wish to use React-Calendar in an uncontrolled way, use [defaultActiveStartDate] instead. + */ + var activeStartDate: Date + + /** + * Class name(s) that will be added along with "react-calendar" to the main React-Calendar <div> element. + */ + var className: String + + /** + * The beginning of a period that shall be displayed by default. + * If you wish to use React-Calendar in a controlled way, use [activeStartDate] instead. + */ + var defaultActiveStartDate: Date + + /** + * Calendar value that shall be selected initially. + * Can be either one value or an array of two values. + * If you wish to use React-Calendar in a controlled way, use [value] instead. + */ + var defaultValue: Array<Date> + + /** + * Function called when the user clicks an item (day on month view, month on year view and so on) + * on the most detailed view available. + */ + var onChange: (Date, Event) -> Unit + + /** + * Function called when the user clicks a day. + */ + var onClickDay: (Int, Event) -> Unit + + /** + * Calendar value. Can be either one value or an array of two values. + * If you wish to use React-Calendar in an uncontrolled way, use [defaultValue] instead. + */ + var value: Array<Date> + + /** + * Whether days from previous or next month shall be rendered + * if the month doesn't start on the first day of the week + * or doesn't end on the last day of the week, respectively. + */ + var showNeighboringMonth: Boolean + + /** + * Locale that should be used by the calendar. + * Can be any IETF language tag. + */ + var locale: String + + /** + * Type of calendar that should be used. Can be "ISO 8601", "US", "Arabic", or "Hebrew". + * Setting to "US" or "Hebrew" will change the first day of the week to Sunday. + * Setting to "Arabic" will change the first day of the week to Saturday. + * Setting to "Arabic" or "Hebrew" will make weekends appear on Friday to Saturday. + */ + var calendarType: String +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/calendar/ReactCalendarBuilder.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/calendar/ReactCalendarBuilder.kt new file mode 100644 index 0000000000..d39421eaa1 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/calendar/ReactCalendarBuilder.kt @@ -0,0 +1,25 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.externals.calendar + +import org.w3c.dom.events.Event +import react.ChildrenBuilder +import react.react +import kotlin.js.Date + +/** + * @param onChange + * @param handler + */ +fun ChildrenBuilder.calendar( + onChange: (Date, Event) -> Unit, + handler: ChildrenBuilder.(ReactCalendarProps) -> Unit = {}, +) { + kotlinext.js.require<dynamic>("react-calendar/dist/Calendar.css") + ReactCalendar::class.react { + this.onChange = onChange + this.showNeighboringMonth = false + this.locale = "en-EN" + handler(this) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChart.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChart.kt new file mode 100644 index 0000000000..a6476f788e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChart.kt @@ -0,0 +1,16 @@ +@file:JsModule("react-minimal-pie-chart") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.chart + +import react.Component +import react.ReactElement +import react.State + +/** + * External declaration of [PieChart] react component + */ +@JsName("PieChart") +external class PieChart : Component<PieChartProps, State> { + override fun render(): ReactElement<PieChartProps>? +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChartBuilder.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChartBuilder.kt new file mode 100644 index 0000000000..0c16bcd4ac --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChartBuilder.kt @@ -0,0 +1,51 @@ +/** + * kotlin-react builders for PieChart components + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.externals.chart + +import react.ChildrenBuilder +import react.react +import kotlin.random.Random + +/** + * @property hex + */ +enum class PieChartColors(val hex: String) { + GREEN("#89E894"), + GREY("#CCCCC4"), + RED("#FF8989"), + ; +} + +/** + * @param data dataset for pie chart + * @param handler handler to set up a component + * @return ReactElement + */ +@Suppress("MAGIC_NUMBER") +fun ChildrenBuilder.pieChart( + data: Array<DataPieChart>, + handler: ChildrenBuilder.(PieChartProps) -> Unit = {}, +) = PieChart::class.react { + this.data = data + animate = false + segmentsShift = 0 + viewBoxSize = intArrayOf(100, 100) + radius = 50 + handler(this) +} + +/** + * @return string of random hex color + */ +fun randomColor(): String { + var stringColor = "#" + val charPool = "0123456789ABCDEF".split("") + while (stringColor.length <= 6) { + stringColor += charPool[Random.nextInt(16)] + } + return stringColor +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChartProps.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChartProps.kt new file mode 100644 index 0000000000..43207e8d2c --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/chart/PieChartProps.kt @@ -0,0 +1,66 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.externals.chart + +import react.PropsWithChildren +import web.events.Event + +/** + * Props of [PieChart] + */ +external interface PieChartProps : PropsWithChildren { + /** + * Dataset for pie chart + */ + var data: Array<DataPieChart> + + /** + * Animate segments on component mount + */ + var animate: Boolean + + /** + * Translates segments radially. If number set, provide shift value relative to viewBoxSize space + */ + var segmentsShift: Int + + /** + * width and height of SVG viewBox attribute + */ + var viewBoxSize: IntArray + + /** + * Radius of the pie (relative to viewBoxSize space) + */ + var radius: Int + + /** + * onClick event handler for each segment + */ + var onClick: (Event, Int) -> Unit + + /** + * onMouseOut event handler for each segment + */ + var onMouseOut: (Event, Int) -> Unit + + /** + * onMouseOver event handler for each segment + */ + var onMouseOver: (Event, Int) -> Unit +} + +/** + * Source data. Each entry represents a chart segment + * + * @property title segment name + * @property value value of segment + * @property color color of segment + * @property key custom value to be used as segments element keys + */ +data class DataPieChart( + val title: String? = null, + val value: Int, + var color: String, + val key: String? = null, +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/cookie/Cookie.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/cookie/Cookie.kt new file mode 100644 index 0000000000..50ef0b4070 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/cookie/Cookie.kt @@ -0,0 +1,162 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.externals.cookie + +import js.core.jso +import kotlinext.js.require + +/** + * Object that manages cookies + */ +val cookie: Cookie = require("js-cookie") + +/** + * Interface that encapsulates all cookies interactions + */ +external interface Cookie { + /** + * Get cookie by [key] + * + * @param key key to get cookie + * @param cookieAttribute [CookieAttribute] + * @return cookie as [String] + */ + fun get(key: String, cookieAttribute: CookieAttribute = definedExternally): String + + /** + * Get all cookies + * + * @return [Set] of cookies as [String]s + */ + fun get(): Set<String> + + /** + * Set cookie + * + * @param key key to set cookie + * @param value cookie value as [String] + * @param cookieAttribute [CookieAttribute] + */ + fun set(key: String, value: String, cookieAttribute: CookieAttribute = definedExternally) + + /** + * Remove cookie + * + * @param key cookie key + * @param cookieAttribute [CookieAttribute] + */ + fun remove(key: String, cookieAttribute: CookieAttribute = definedExternally) +} + +/** + * Cookie attributes that can be passed in [Cookie.remove], [Cookie.set] or [Cookie.get] methods + * + * @see <a href=https://github.com/js-cookie/js-cookie>Documentation on GitHub</a> + */ +external interface CookieAttribute { + /** + * A [String] indicating the path where the cookie is visible. + */ + var path: String? + + /** + * A [String] indicating a valid domain where the cookie should be visible. + * The cookie will also be visible to all subdomains. + */ + var domain: String? + + /** + * Define when the cookie will be removed. + * Value must be an [Int] which will be interpreted as days from time of creation. + * + * If omitted, the cookie becomes a session cookie. + */ + var expires: Int? + + /** + * Either true or false, indicating if the cookie transmission requires a secure protocol (https). + */ + var secure: Boolean? +} + +/** + * Class that encapsulates the cookie information + * + * @property key [String] cookie name + * @property expires amount of days before a cookie is considered to be expired, [DEFAULT_EXPIRES] by default + */ +sealed class CookieKeys(val key: String, val expires: Int = DEFAULT_EXPIRES) { + /** + * Cookie that indicates that user as accepted cookie policy + */ + object IsCookieOk : CookieKeys("isCookieOk") + + /** + * Cookie that stores preferred platform language + */ + object PreferredLanguage : CookieKeys("language") + companion object { + /** + * Default value for [CookieKeys.expires] + */ + const val DEFAULT_EXPIRES = 365 + } +} + +/** + * @param key key as [CookieKeys] + * @param value value to set + * @see Cookie.set + */ +fun Cookie.set(key: CookieKeys, value: String) = set(key.key, value, jso { expires = key.expires }) + +/** + * @param key key as [CookieKeys] + * @return cookie as [String] by [CookieKeys.key] of [key] + * @see Cookie.get + */ +fun Cookie.get(key: CookieKeys): String = get(key.key) + +/** + * @param key key as [CookieKeys] + * @see Cookie.remove + */ +fun Cookie.remove(key: CookieKeys) = remove(key.key) + +/** + * Check if cookies are accepted by user + * + * @return true if cookies are accepted, false otherwise + */ +fun Cookie.isAccepted() = get(CookieKeys.IsCookieOk) == "true" + +/** + * Accept cookies + * + * @return [Unit] + */ +fun Cookie.acceptCookies() = set(CookieKeys.IsCookieOk, "true") + +/** + * Decline cookies and delete existed once + * + * @return [Unit] + */ +fun Cookie.declineCookies() = remove(CookieKeys.IsCookieOk).also { + remove(CookieKeys.PreferredLanguage) +} + +/** + * Get preferred platform language code + * + * @return preferred platform language code as [String] + */ +fun Cookie.getLanguageCode() = get(CookieKeys.PreferredLanguage) + +/** + * Save preferred platform language code + * + * @param languageCode preferred platform language code as [String] + * @return [Unit] + */ +fun Cookie.saveLanguageCode(languageCode: String) = set(CookieKeys.PreferredLanguage, languageCode) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/diffviewer/ReactDiffViewer.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/diffviewer/ReactDiffViewer.kt new file mode 100644 index 0000000000..0f4359c052 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/diffviewer/ReactDiffViewer.kt @@ -0,0 +1,74 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", "FILE_NAME_MATCH_CLASS") +@file:JsModule("react-diff-viewer-continued") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.diffviewer + +import react.FC +import react.Props + +/** + * External declaration of [reactDiffViewer] react component + */ +@JsName("default") +external val reactDiffViewer: FC<ReactDiffViewerProps> + +/** + * Props of [ReactDiffViewerProps] + */ +external interface ReactDiffViewerProps : Props { + /** + * Old value as string. + */ + var oldValue: String + + /** + * New value as string. + */ + var newValue: String + + /** + * Switch between unified and split view. + */ + var splitView: Boolean + + /** + * Show and hide word diff in a diff line. + */ + var disableWordDiff: Boolean + + /** + * Show and hide line numbers. + */ + var hideLineNumbers: Boolean + + /** + * Shows only the diffed lines and folds the unchanged lines + */ + var showDiffOnly: Boolean + + /** + * Number of extra unchanged lines surrounding the diff. Works along with [showDiffOnly]. + */ + var extraLinesSurroundingDiff: Int + + /** + * To enable/disable dark theme. + */ + var useDarkTheme: Boolean + + /** + * Column title for left section of the diff in split view. This will be used as the only title in inline view. + */ + var leftTitle: String + + /** + * Column title for right section of the diff in split view. This will be ignored in inline view. + */ + var rightTitle: String + + /** + * Number to start count code lines from. + */ + var linesOffset: Int +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/BrandIcons.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/BrandIcons.kt new file mode 100644 index 0000000000..393f2fd71b --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/BrandIcons.kt @@ -0,0 +1,17 @@ +/** + * External declarations of icons from fontawesome-solid + */ + +package com.saveourtool.save.frontend.common.externals.fontawesome + +@JsModule("@fortawesome/free-brands-svg-icons/faGithub") +@JsNonModule +external val faGithub: FontAwesomeIconModule + +@JsModule("@fortawesome/free-brands-svg-icons/faTwitter") +@JsNonModule +external val faTwitter: FontAwesomeIconModule + +@JsModule("@fortawesome/free-brands-svg-icons/faLinkedinIn") +@JsNonModule +external val faLinkedIn: FontAwesomeIconModule diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FaSetup.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FaSetup.kt new file mode 100644 index 0000000000..017e91796e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FaSetup.kt @@ -0,0 +1,10 @@ +/** + * External declarations from fontawesome-svg-core + */ + +@file:JsModule("@fortawesome/fontawesome-svg-core") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.fontawesome + +external val library: dynamic diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIcon.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIcon.kt new file mode 100644 index 0000000000..43d093e6aa --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIcon.kt @@ -0,0 +1,15 @@ +@file:JsModule("@fortawesome/react-fontawesome") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.fontawesome + +import react.Component +import react.ReactElement +import react.State + +/** + * External declaration of [FontAwesomeIcon] react component + */ +external class FontAwesomeIcon : Component<FontAwesomeIconProps, State> { + override fun render(): ReactElement<FontAwesomeIconProps>? +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIconBuilders.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIconBuilders.kt new file mode 100644 index 0000000000..330f81cdaa --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIconBuilders.kt @@ -0,0 +1,44 @@ +/** + * kotlin-react builders for FontAwesomeIcon components + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.externals.fontawesome + +import react.ChildrenBuilder +import react.react + +/** + * A small wrapper for font awesome icons imported from individual modules. + * See [faUser.d.ts](https://unpkg.com/browse/@fortawesome/free-solid-svg-icons@5.11.2/faUser.d.ts) as an example. + * We only need [definition] field for using those icons, other fields could be added if needed. + */ +external interface FontAwesomeIconModule { + /** + * Definition of FA icon ([IconDefinition] in terms of `@fortawesome/fontawesome-common-types`) + */ + var definition: dynamic +} + +/** + * Builder function for new kotlin-react API + * + * @param icon + * @param classes + * @param size size of an icon + * @param handler + * + * @see <a href=https://fontawesome.com/docs/web/use-with/react/style#size>size docs</a> + */ +fun ChildrenBuilder.fontAwesomeIcon( + icon: FontAwesomeIconModule, + classes: String = "", + size: String? = null, + handler: ChildrenBuilder.(props: FontAwesomeIconProps) -> Unit = {}, +): Unit = FontAwesomeIcon::class.react { + this.icon = icon.definition + this.className = classes + this.size = size + this.handler(this) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIconProps.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIconProps.kt new file mode 100644 index 0000000000..14b545daf0 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/FontAwesomeIconProps.kt @@ -0,0 +1,30 @@ +package com.saveourtool.save.frontend.common.externals.fontawesome + +import react.PropsWithChildren + +/** + * Props of [FontAwesomeIcon] + */ +external interface FontAwesomeIconProps : PropsWithChildren { + /** + * Icon. Can be an object, string or array. + */ + var icon: dynamic + + /** + * Classes of the element + */ + var className: String + + /** + * Color of the element + */ + var color: String + + /** + * Icon size, can be t-shirt size (2xs to 2lx) and x-factor size (1x to 10x) + * + * @see <a href=https://fontawesome.com/docs/web/use-with/react/style#size>size docs</a> + */ + var size: String? +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/Icons.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/Icons.kt new file mode 100644 index 0000000000..a3ca503efa --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/fontawesome/Icons.kt @@ -0,0 +1,253 @@ +/** + * External declarations of icons from fontawesome-solid + */ + +package com.saveourtool.save.frontend.common.externals.fontawesome + +@JsModule("@fortawesome/free-solid-svg-icons/faUser") +@JsNonModule +external val faUser: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCogs") +@JsNonModule +external val faCogs: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faSignOutAlt") +@JsNonModule +external val faSignOutAlt: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faAngleUp") +@JsNonModule +external val faAngleUp: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCheck") +@JsNonModule +external val faCheck: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faExclamationTriangle") +@JsNonModule +external val faExclamationTriangle: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTimesCircle") +@JsNonModule +external val faTimesCircle: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTimes") +@JsNonModule +external val faTimes: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faQuestionCircle") +@JsNonModule +external val faQuestionCircle: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faList") +@JsNonModule +external val faList: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faSpinner") +@JsNonModule +external val faSpinner: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faUpload") +@JsNonModule +external val faUpload: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faFile") +@JsNonModule +external val faFile: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faHistory") +@JsNonModule +external val faHistory: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCalendarAlt") +@JsNonModule +external val faCalendarAlt: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faExternalLinkAlt") +@JsNonModule +external val faExternalLinkAlt: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTrashAlt") +@JsNonModule +external val faTrashAlt: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faEdit") +@JsNonModule +external val faEdit: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faFilter") +@JsNonModule +external val faFilter: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faSearch") +@JsNonModule +external val faSearch: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faRedo") +@JsNonModule +external val faRedo: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faSync") +@JsNonModule +external val faReload: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCopyright") +@JsNonModule +external val faCopyright: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faSignInAlt") +@JsNonModule +external val faSignInAlt: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCopy") +@JsNonModule +external val faCopy: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faFolderOpen") +@JsNonModule +external val faFolderOpen: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCheckCircle") +@JsNonModule +external val faCheckCircle: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faPlus") +@JsNonModule +external val faPlus: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faInfoCircle") +@JsNonModule +external val faInfoCircle: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faAlignJustify") +@JsNonModule +external val faAlignJustify: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faArrowRight") +@JsNonModule +external val faArrowRight: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faArrowLeft") +@JsNonModule +external val faArrowLeft: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faArrowDown") +@JsNonModule +external val faArrowDown: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faLightbulb") +@JsNonModule +external val faLightbulb: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCog") +@JsNonModule +external val faCog: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faHome") +@JsNonModule +external val faHome: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faEnvelope") +@JsNonModule +external val faEnvelope: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faKey") +@JsNonModule +external val faKey: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCity") +@JsNonModule +external val faCity: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faDownload") +@JsNonModule +external val faDownload: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTrash") +@JsNonModule +external val faTrash: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faBell") +@JsNonModule +external val faBell: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faChevronDown") +@JsNonModule +external val faChevronDown: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCheckDouble") +@JsNonModule +external val faCheckDouble: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTrophy") +@JsNonModule +external val faTrophy: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCode") +@JsNonModule +external val faCode: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTag") +@JsNonModule +external val faTag: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTable") +@JsNonModule +external val faTable: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faImage") +@JsNonModule +external val faImage: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCodeBranch") +@JsNonModule +external val faCodeBranch: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCaretSquareRight") +@JsNonModule +external val faCaretSquareRight: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faSyncAlt") +@JsNonModule +external val faSyncAlt: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faPoo") +@JsNonModule +external val faPoo: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faDice") +@JsNonModule +external val faDice: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faBug") +@JsNonModule +external val faBug: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faShieldVirus") +@JsNonModule +external val faShieldVirus: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faGlobe") +@JsNonModule +external val faGlobe: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faLink") +@JsNonModule +external val faLink: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faPaperPlane") +@JsNonModule +external val faPaperPlane: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faWindowClose") +@JsNonModule +external val faWindowClose: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCamera") +@JsNonModule +external val faCamera: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faBoxOpen") +@JsNonModule +external val faBoxOpen: FontAwesomeIconModule diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/CpgUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/CpgUtils.kt new file mode 100644 index 0000000000..8defd7734e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/CpgUtils.kt @@ -0,0 +1,131 @@ +/** + * Utils for CpgGraph and Sigma in general + */ + +package com.saveourtool.save.frontend.common.externals.graph + +import com.saveourtool.save.demo.cpg.CpgEdge +import com.saveourtool.save.demo.cpg.CpgGraph +import com.saveourtool.save.demo.cpg.CpgNode +import com.saveourtool.save.demo.cpg.cytoscape.CytoscapeEdge +import com.saveourtool.save.demo.cpg.cytoscape.CytoscapeGraph +import com.saveourtool.save.demo.cpg.cytoscape.CytoscapeNode + +import js.core.jso + +import kotlin.random.Random +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val cpgJsonSerializer = Json { encodeDefaults = true } + +/** + * @return serialized graph that can be used with useLoadGraph hook + */ +fun CpgGraph.toGraphologyJson() = let { graph -> + @Suppress("UnusedPrivateProperty", "UNUSED_VARIABLE") + val str = cpgJsonSerializer.encodeToString(graph) + js("JSON.parse(str);") +} + +/** + * Paint [CpgNode] with color + * + * @param color color to paint the [CpgNode] to + * @return [CpgNode] painted with [color] + */ +fun CpgNode.paint(color: String) = copy( + attributes = attributes.copy(color = color) +) + +/** + * Paint [CpgEdge] with color + * + * @param color color to paint the [CpgEdge] to + * @return [CpgEdge] painted with [color] + */ +fun CpgEdge.paint(color: String) = copy( + attributes = attributes.copy(color = color) +) + +/** + * Paint all the [CpgNode]s of [CpgGraph] with [color]. + * + * @param color color to paint the [CpgGraph.nodes] to, if null - each [CpgNode] is painted into random color + * @return [CpgGraph] with all [CpgGraph.nodes] painted with [color] (or randomly) + */ +fun CpgGraph.paintNodes( + color: String? = null +) = nodes.map { node -> + node.paint(color ?: getRandomHexColor()) +} + .let { coloredNodes -> + copy(nodes = coloredNodes) + } + +/** + * Paint all the [CpgEdge]s of [CpgGraph] with [color]. + * + * @param color color to paint the [CpgGraph.edges] to, if null - each [CpgEdge] is painted into random color + * @return [CpgGraph] with all [CpgGraph.edges] painted with [color] (or randomly) + */ +fun CpgGraph.paintEdges( + color: String? = null +) = edges.map { edge -> + edge.paint(color ?: getRandomHexColor()) +} + .let { coloredEdges -> + copy(edges = coloredEdges) + } + +/** + * @return [CytoscapeEdge] from [CpgEdge] + */ +fun CpgEdge.asCytoscapeEdge() = CytoscapeEdge( + CytoscapeEdge.Data(key, source, target, attributes.label), + false +) + +/** + * @return [CytoscapeNode] from [CpgNode] + */ +fun CpgNode.asCytoscapeNode() = CytoscapeNode( + CytoscapeNode.Data(key, label = attributes.label) +) + +/** + * @return [CytoscapeGraph] from [CpgGraph] + */ +fun CpgGraph.asCytoscapeGraph() = CytoscapeGraph( + nodes.map { it.asCytoscapeNode() }, + edges.map { it.asCytoscapeEdge() }, + CytoscapeGraph.Attributes(name = attributes.name) +) + +/** + * @param edgeType type of the edge that should be displayed + * @param isRenderEdgeLabels flag that defines if edge labels should be displayed or not + * @return settings of sigmaContainer + */ +fun getSigmaContainerSettings( + edgeType: String = "arrow", + isRenderEdgeLabels: Boolean = true, +): dynamic = jso { + renderEdgeLabels = isRenderEdgeLabels + defaultEdgeType = edgeType +} + +/** + * Get random hex color + * + * @param isPastel flag to generate only pastel colors - true by default + * @return randomized color in format "#RRGGBB" + */ +@Suppress("MAGIC_NUMBER") +fun getRandomHexColor(isPastel: Boolean = true) = buildString { + append("#") + val from = if (isPastel) 127 else 0 + append(Random.nextInt(from, 255).toString(16)) + append(Random.nextInt(from, 255).toString(16)) + append(Random.nextInt(from, 255).toString(16)) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/cytoscape/CytoscapeWrapper.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/cytoscape/CytoscapeWrapper.kt new file mode 100644 index 0000000000..0c8eb0f412 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/cytoscape/CytoscapeWrapper.kt @@ -0,0 +1,87 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.externals.graph.cytoscape + +import com.saveourtool.save.demo.cpg.cytoscape.CytoscapeGraph +import com.saveourtool.save.demo.cpg.cytoscape.CytoscapeLayout + +import react.* +import web.cssom.* +import web.html.HTMLDivElement + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * @param graph data in [CytoscapeGraph] format + * @param layout [CytoscapeLayout] that should be applied to graph + * @param divRef reference to div where a graph should be rendered in + * @param selectionType type of node selection + * @return cytoscape graph as dynamic + */ +@Suppress( + "UNUSED_VARIABLE", + "TOO_LONG_FUNCTION", + "UNUSED_PARAMETER", + "LongMethod" +) +fun cytoscape( + graph: CytoscapeGraph, + layout: CytoscapeLayout, + divRef: MutableRefObject<HTMLDivElement>, + selectionType: String = "single", +): dynamic { + val cytoscapeGraphJsonString = Json.encodeToString(graph) + val cytoscapeGraphJsonStringJs = js("JSON.parse(cytoscapeGraphJsonString);") + // language=json + val graphStyle = js(""" + [ + { + "selector": "node", + "style": { + "background-color": "#666", + "label": "data(label)", + "height": 20, + "width": 20, + "font-size": 7 + } + }, + { + "selector": "edge", + "style": { + "width": 2, + "height": 2, + "line-color": "#ccc", + "target-arrow-color": "#ccc", + "target-arrow-shape": "triangle", + "curve-style": "bezier", + "label": "data(label)", + "font-size": 5, + "arrow-scale": 0.5 + } + } + ] + """) + val layoutName = layout.layoutName + val graphLayout = js(""" + { + "name": layoutName, + "padding": 5 + } + """) + @Suppress("UNUSED_ANONYMOUS_PARAMETER") + val options = divRef.current?.let { containerRefCurrent -> + js(""" + { + "container" : containerRefCurrent, + "elements" : cytoscapeGraphJsonStringJs, + "layout" : graphLayout, + "style" : graphStyle, + "selectionType": selectionType + } + """) + } + + val cytoscapeJs: dynamic = kotlinext.js.require("cytoscape") + return cytoscapeJs(options) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/ReactSigma.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/ReactSigma.kt new file mode 100644 index 0000000000..a451c44572 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/ReactSigma.kt @@ -0,0 +1,68 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("@react-sigma/core") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.graph.sigma + +import react.* + +/** + * External declaration of [sigmaContainer] react [FC] + */ +@JsName("SigmaContainer") +external val sigmaContainer: FC<SigmaContainerProps> + +/** + * [PropsWithChildren] for [sigmaContainer] + */ +external interface SigmaContainerProps : PropsWithChildren { + /** + * HTML classes that should be applied to [sigmaContainer] + */ + var className: String? + + /** + * CSS styles that should be applied to [sigmaContainer] + */ + var style: dynamic + + /** + * Component settings + */ + var settings: dynamic + + /** + * Graphology graph + */ + var graph: dynamic +} + +/** + * @return graph setter + */ +@JsName("useLoadGraph") +external fun useLoadGraph(): (graph: dynamic) -> Unit + +/** + * Hook to configure the graph + * + * @return callback that receives json with graph settings + */ +@JsName("useSetSettings") +external fun useSetSettings(): (dynamic) -> Unit + +/** + * Hook to configure event handlers + * + * @return callback that receives the json with event handlers + */ +@JsName("useRegisterEvents") +external fun useRegisterEvents(): (dynamic) -> Unit + +/** + * Hook to get Sigma object, required to get current graph for example + * + * @return sigma object + */ +@JsName("useSigma") +external fun useSigma(): dynamic diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutCircular.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutCircular.kt new file mode 100644 index 0000000000..7619cdaa96 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutCircular.kt @@ -0,0 +1,15 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("@react-sigma/layout-circular") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.graph.sigma.layouts + +import react.* + +/** + * @param settings + * @return [LayoutInstance] with positions and assign functions + */ +@JsName("useLayoutCircular") +@JsExport +external fun useLayoutCircular(settings: dynamic = definedExternally): LayoutInstance diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutForceAtlas2.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutForceAtlas2.kt new file mode 100644 index 0000000000..5af60187a2 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutForceAtlas2.kt @@ -0,0 +1,14 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("@react-sigma/layout-forceatlas2") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.graph.sigma.layouts + +import react.* + +/** + * @param settings + * @return [LayoutInstance] with positions and assign functions + */ +@JsName("useLayoutForceAtlas2") +external fun useLayoutForceAtlas2(settings: dynamic = definedExternally): LayoutInstance diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutInstance.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutInstance.kt new file mode 100644 index 0000000000..f87de18769 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutInstance.kt @@ -0,0 +1,7 @@ +package com.saveourtool.save.frontend.common.externals.graph.sigma.layouts + +/** + * Wrapper for useLayoutRandom returning value to allow unpacking a pair (has [component1] and [component2]) + */ +@JsExport +external interface LayoutInstance diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutRandom.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutRandom.kt new file mode 100644 index 0000000000..4b229fb7ca --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/graph/sigma/layouts/LayoutRandom.kt @@ -0,0 +1,15 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("@react-sigma/layout-random") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.graph.sigma.layouts + +import react.* + +/** + * @param settings + * @return [LayoutInstance] with positions and assign functions + */ +@JsName("useLayoutRandom") +@JsExport +external fun useLayoutRandom(settings: dynamic = definedExternally): LayoutInstance diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/I18n.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/I18n.kt new file mode 100644 index 0000000000..ff763103bc --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/I18n.kt @@ -0,0 +1,29 @@ +package com.saveourtool.save.frontend.common.externals.i18next + +import com.saveourtool.save.frontend.PlatformLanguages + +/** + * Class that represents i18n object + */ +external class I18n { + /** + * Current language + */ + val language: String + + /** + * Set language by language code + * + * @param language language code + * + * @see I18n.changeLanguage + */ + fun changeLanguage(language: String) +} + +/** + * Get current [PlatformLanguages] + * + * @return current language as [PlatformLanguages] or [PlatformLanguages.defaultLanguage] + */ +fun I18n.language(): PlatformLanguages = PlatformLanguages.getByCodeOrDefault(language) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/InitI18n.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/InitI18n.kt new file mode 100644 index 0000000000..f0972111c0 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/InitI18n.kt @@ -0,0 +1,54 @@ +/** + * File containing initialization of i18next + */ + +package com.saveourtool.save.frontend.common.externals.i18next + +/** + * Function that encapsulates i18n initialization. + * + * @param isDebug flag to set debug mode + * @param interpolationEscapeValue interpolation.escapeValue value + */ +@Suppress("UNUSED_PARAMETER") +fun initI18n(isDebug: Boolean = false, interpolationEscapeValue: Boolean = false) { + js(""" + var i18n = require("i18next"); + var reactI18n = require("react-i18next"); + var Backend = require("i18next-http-backend"); + + i18n + .use(reactI18n.initReactI18next) + .use(Backend.default) + .init({ + load: 'languageOnly', + initImmediate: false, + partialBundledLanguages: true, + ns: [ + 'cookies', + 'topbar', + 'organization', + 'proposing', + 'table-headers', + 'thanks-for-registration', + 'vulnerability-collection', + 'vulnerability-upload', + 'welcome', + 'vulnerability', + 'comments', + 'dates', + 'index', + 'profile' + ], + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json' + }, + lng: "en", + fallbackLng: "en", + debug: isDebug, + interpolation: { + escapeValue: interpolationEscapeValue + }, + }); + """) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/Translation.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/Translation.kt new file mode 100644 index 0000000000..624e714202 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/Translation.kt @@ -0,0 +1,84 @@ +package com.saveourtool.save.frontend.common.externals.i18next + +import com.saveourtool.save.frontend.PlatformLanguages +import com.saveourtool.save.frontend.common.externals.cookie.cookie +import com.saveourtool.save.frontend.common.externals.cookie.isAccepted +import com.saveourtool.save.frontend.common.externals.cookie.saveLanguageCode + +typealias TranslationFunction = String.() -> String + +/** + * Class that represents the return value of `useTranslation` hook + * @see useTranslation + */ +@Suppress("NOTHING_TO_INLINE") +sealed class Translation { + /** + * Function that receives key and return a localized value + * + * @param key key + * @return localized value + */ + inline fun translateLambda(key: String): String = asDynamic()[0].unsafeCast<(String) -> String>()(key) + + /** + * @return t-function that receives a key and returns a localized value + */ + inline operator fun component1(): TranslationFunction = { translateLambda(this) } + + /** + * Get an i18n instance and use + * + * ``` + * i18n.changeLanguage("LANG") + * ``` + * + * in order to change language + * + * @return an i18n instance + */ + inline operator fun component2(): I18n = asDynamic()[1].unsafeCast<I18n>() + + /** + * @return ready flag + */ + @Suppress("FUNCTION_BOOLEAN_PREFIX") + inline operator fun component3(): Boolean = asDynamic()[2].unsafeCast<Boolean>() + + /** + * Operator that should be used in order to get rid of this: + * + * ``` + * val (t) = useTranslation() + * ``` + * and use this: + * + * ``` + * val t = useTranslation() + * ``` + * + * @param key key for translation + * @return localized value by [key] + * @see component1 + */ + inline operator fun invoke(key: String): String = translateLambda(key) +} + +/** + * Set [language] and save it to cookies + * + * @param language [PlatformLanguages] enum entity corresponding to language to set + */ +fun I18n.changeLanguage(language: PlatformLanguages) = changeLanguage(language.code).also { + if (cookie.isAccepted()) { + cookie.saveLanguageCode(language.code) + } +} + +/** + * @param namespace locale namespace + * + * @see useTranslation + */ +fun useTranslation(namespace: String) = + useTranslation(arrayOf(namespace)) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/UseTranslation.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/UseTranslation.kt new file mode 100644 index 0000000000..84139f5901 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/i18next/UseTranslation.kt @@ -0,0 +1,11 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("react-i18next") + +package com.saveourtool.save.frontend.common.externals.i18next + +/** + * @param namespaces [Array] of namespaces to load + * @return [Translation] instance + */ +@JsName("useTranslation") +external fun useTranslation(namespaces: Array<String> = definedExternally): Translation diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/imageeditor/ReactAvatarImageCropper.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/imageeditor/ReactAvatarImageCropper.kt new file mode 100644 index 0000000000..a8361e321b --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/imageeditor/ReactAvatarImageCropper.kt @@ -0,0 +1,26 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", "FILE_NAME_MATCH_CLASS") +@file:JsModule("react-avatar-image-cropper") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.imageeditor + +import org.w3c.dom.events.Event +import react.* +import web.file.File + +/** + * External declaration of [reactAvatarImageCropper] react component + */ +@JsName("default") +external val reactAvatarImageCropper: FC<ReactAvatarImageCropperProps> + +/** + * Props of [ReactAvatarImageCropperProps] + */ +@Suppress("TYPE_ALIAS") +external interface ReactAvatarImageCropperProps : Props { + /** + * The apply function will get the cropped blob file, you can handle it whatever you want. + */ + var apply: (File, Event) -> Unit +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/jsonview/JsonView.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/jsonview/JsonView.kt new file mode 100644 index 0000000000..f97b7ada44 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/jsonview/JsonView.kt @@ -0,0 +1,38 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", "FILE_NAME_MATCH_CLASS") +@file:JsModule("react-json-view") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.jsonview + +import react.FC +import react.Props +import kotlin.js.Json + +/** + * External declaration of [reactJson] react component + */ +@JsName("default") +external val reactJson: FC<ReactJsonViewProps> + +/** + * Props of [ReactJsonViewProps] + */ +external interface ReactJsonViewProps : Props { + /** + * Input JSON + */ + var src: Json + + /** + * name : {} or no name in case of false + */ + var name: Boolean + + /** + * theme for the background + */ + var theme: String + // FixMe: onAdd?: ((add: InteractionProps) => false | any) | false; + // FixMe: onEdit?: ((edit: InteractionProps) => false | any) | false; + // FixMe: onDelete?: ((del: InteractionProps) => false | any) | false; +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/lodash/Debounce.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/lodash/Debounce.kt new file mode 100644 index 0000000000..d545e02867 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/lodash/Debounce.kt @@ -0,0 +1,13 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.externals.lodash + +/** + * @param function to be debounced + * @param milliseconds between invocations + * @return debounced function + */ +@JsModule("lodash.debounce") +@JsNonModule +@Suppress("LAMBDA_IS_NOT_LAST_PARAMETER") +external fun debounce(function: () -> Unit, milliseconds: Int): () -> Unit diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/markdown/ReactMarkdown.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/markdown/ReactMarkdown.kt new file mode 100644 index 0000000000..c1be7d1042 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/markdown/ReactMarkdown.kt @@ -0,0 +1,22 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") +@file:JsModule("react-markdown") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.markdown + +import react.* + +/** + * Options for [reactMarkdown] + */ +@JsName("ReactMarkdownOptions") +external interface ReactMarkdownProps : PropsWithChildren + +/** + * External declaration of ReactMarkdown react component + * + * @param options + * @return special div that can be filled with text + */ +@JsName("default") +external fun reactMarkdown(options: dynamic = definedExternally): ReactElement<ReactMarkdownProps>? diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/markdown/rehype/RehypeMarkdownPlugin.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/markdown/rehype/RehypeMarkdownPlugin.kt new file mode 100644 index 0000000000..d58a65dad3 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/markdown/rehype/RehypeMarkdownPlugin.kt @@ -0,0 +1,14 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("rehype-highlight") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.markdown.rehype + +/** + * Plugin for syntax highlighting in code blocks + * + * @param options + * @return dynamic + */ +@JsName("default") +external fun rehypeHighlightPlugin(options: dynamic = definedExternally): dynamic diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/modal/ReactModal.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/modal/ReactModal.kt new file mode 100644 index 0000000000..f4593c1e8a --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/modal/ReactModal.kt @@ -0,0 +1,145 @@ +/** + * Kotlin/JS-React wrappers for react-modal library: JS definitions + */ + +@file:Suppress("USE_DATA_CLASS") + +package com.saveourtool.save.frontend.common.externals.modal + +import react.* +import web.dom.Element +import web.html.HTMLDivElement +import web.html.HTMLElement + +/** + * [PropsWithChildren] of modal component + */ +external interface ModalProps : PropsWithChildren { + /** + * Boolean describing if the modal should be shown or not. Defaults to false. + */ + var isOpen: Boolean + + /** + * String indicating how the content container should be announced to screenreaders. + */ + var contentLabel: String + + /** + * String or object className to be applied to the modal content. + */ + var className: Classes + + /** + * String or object className to be applied to the overlay. + */ + var overlayClassName: Classes + + /** + * Object indicating styles to be used for the modal, divided into overlay and content styles. + */ + var style: Styles + + /** + * Boolean indicating if the overlay should close the modal. Defaults to true. + */ + var shouldCloseOnOverlayClick: Boolean? +} + +/** + * Styles of Modal component. + * + * @property content css styles for modal content + * @property overlay css styles for modal overlay + */ +class Styles( + @JsName("content") val content: CSSProperties? = undefined, + @JsName("overlay") val overlay: CSSProperties? = undefined, +) + +/** + * The value corresponding to each key is a class name. Please note that specifying a CSS class + * for the overlay or the content will disable the default styles for that component. + * + * @property base This class will always be applied to the component + * @property afterOpen This class will be applied after the modal has been opened + * @property beforeClose This class will be applied after the modal has requested to be closed + * (e.g. when the user presses the escape key or clicks on the overlay). + * Will have no effect unless the closeTimeoutMS prop is set to a non-zero value, since otherwise the modal will be closed immediately when requested. + */ +class Classes( + var base: String, + var afterOpen: String = "$base--after-open", + var beforeClose: String = "$base--before-close", +) + +/** Describes overlay and content element references passed to onAfterOpen function */ +external interface OnAfterOpenCallbackOptions { + /** + * overlay element reference + */ + var overlayEl: Element + + /** + * content element reference + */ + var contentEl: HTMLDivElement +} + +/** Describes unction that will be run after the modal has opened */ +external interface OnAfterOpenCallback { + /** + * @param obj + */ + fun onAfterOpen(obj: OnAfterOpenCallbackOptions): Unit +} + +/** + * A wrapper component for a [portal](https://reactjs.org/docs/portals.html) which will contain this modal + */ +@JsModule("react-modal") +@JsNonModule +@JsName("ModalPortal") +external class ReactModalPortal : Component<PropsWithChildren, State> { + /** + * Content of the modal portal + */ + val content: HTMLDivElement + + /** + * Overlay (part that covers the background) of the modal portal + */ + val overlay: HTMLDivElement + + override fun render(): ReactElement<*>? +} + +/** + * A main [Component] of react-modal. + */ +@JsModule("react-modal") +@JsNonModule +@JsName("ReactModal") +external class ReactModal : Component<ModalProps, State> { + /** + * A portal for modal window. + */ + var portal: ReactModalPortal? = definedExternally + + override fun render(): ReactElement<ModalProps>? + + companion object { + /** + * Override base styles for all instances of this component. + */ + val defaultStyles: Styles + + /** + * Call this to properly hide your application from assistive screenreaders + * and other assistive technologies while the modal is open. + * + * @param appElement an [HTMLElement] corresponding to app root + */ + fun setAppElement(appElement: HTMLElement): Unit + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/progressbar/ReactCircle.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/progressbar/ReactCircle.kt new file mode 100644 index 0000000000..959cb4ff80 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/progressbar/ReactCircle.kt @@ -0,0 +1,70 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("react-circle") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.progressbar + +import react.* + +/** + * External declaration of [ReactCircleProps] react component + */ +@JsName("default") +external class ReactCircle : Component<ReactCircleProps, State> { + override fun render(): ReactElement<ReactCircleProps>? +} + +/** + * Props of [ReactCircleProps] + */ +external interface ReactCircleProps : PropsWithChildren { + /** + * Defines the size of the circle. + */ + var size: String + + /** + * Defines the thickness of the circle's stroke. + */ + var lineWidth: String + + /** + * Update to change the progress and percentage. + */ + var progress: String + + /** + * Color of "progress" portion of circle (example: "#ecedf0"). + */ + var progressColor: String + + /** + * Color of "empty" portion of circle (example: "#ecedf0"). + */ + var bgColor: String + + /** + * Color of percentage text color (example: "#ecedf0"). + */ + var textColor: String + + /** + * Adjust spacing of "%" symbol and number. + */ + var percentSpacing: Long + + /** + * Show/hide percentage value inside the circle. + */ + var showPercentage: Boolean + + /** + * Show/hide only the "%" symbol. + */ + var showPercentageSymbol: Boolean + + /** + * Custom styling for text. + */ + var textStyle: CSSProperties +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/progressbar/ReactCircleBuilder.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/progressbar/ReactCircleBuilder.kt new file mode 100644 index 0000000000..2e749771b3 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/progressbar/ReactCircleBuilder.kt @@ -0,0 +1,36 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", "FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.externals.progressbar + +import com.saveourtool.save.frontend.common.themes.Colors +import react.ChildrenBuilder +import react.react + +/** + * @param progress progress and percentage + * @param size of the circle + * @param lineWidth of the circle's stroke + * @param color of percentage text and "progress" portion of circle + * @param handler + * @param showPercentageSymbol + */ +@Suppress("LongParameterList", "TOO_MANY_PARAMETERS") +fun ChildrenBuilder.progressBar( + progress: Float, + size: String = "10rem", + lineWidth: String = "5rem", + color: String = Colors.SUCCESS.value, + showPercentageSymbol: Boolean = false, + handler: ChildrenBuilder.(ReactCircleProps) -> Unit = {}, +) { + // FixMe: setting textStyle as jso this does not work in Circle, investigate why + ReactCircle::class.react { + this.size = size + this.lineWidth = lineWidth + this.progress = progress.toString() + this.progressColor = color + this.showPercentageSymbol = showPercentageSymbol + this.textColor = color + handler(this) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/AceBuilder.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/AceBuilder.kt new file mode 100644 index 0000000000..831691ce7d --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/AceBuilder.kt @@ -0,0 +1,156 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.externals.reactace + +import com.saveourtool.save.frontend.common.utils.parsePositionString +import com.saveourtool.save.utils.DEBOUNCE_PERIOD_FOR_EDITORS +import com.saveourtool.save.utils.Languages + +import io.github.petertrr.diffutils.diff +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import web.cssom.ClassName + +typealias AceMarkers = Array<AceMarker> + +/** + * @param text displayed text + * @param selectedMode highlight mode + * @param selectedTheme displayed theme + * @param aceMarkers array of [AceMarker]s that defines which lines should be marked as unsaved + * @param disabled should this editor be readonly + * @param onChangeFun callback invoked on input + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.aceBuilder( + text: String, + selectedMode: Languages, + selectedTheme: AceThemes = AceThemes.CHROME, + aceMarkers: Array<AceMarker> = emptyArray(), + disabled: Boolean = false, + onChangeFun: (String) -> Unit, +) { + selectedTheme.require() + kotlinext.js.require<dynamic>("ace-builds/src-min-noconflict/mode-${selectedMode.modeName}") + + div { + className = ClassName("d-flex justify-content-center flex-fill") + reactAce { + fontSize = "16px" + className = "flex-fill" + mode = selectedMode.modeName + theme = selectedTheme.themeName + width = "auto" + debounceChangePeriod = DEBOUNCE_PERIOD_FOR_EDITORS + value = text + showPrintMargin = false + readOnly = disabled + onChange = { value, _ -> onChangeFun(value) } + markers = aceMarkers + } + } +} + +/** + * Get array of [AceMarker]s for modified lines of a [String]. + * + * @param oldString old version of string + * @param newString new version of string + * @return Array of [AceMarker]s corresponding to modified lines. + */ +fun getAceMarkers(oldString: String, newString: String) = diff( + oldString.split("\n"), + newString.split("\n"), +) + .deltas + .map { + it.target.position to it.target.last() + } + .map { (from, to) -> + aceMarkerBuilder(from, to) + } + .toTypedArray() + +/** + * Get [AceMarker] + * + * @param beginLineIndex index of the first line to be marked + * @param endLineIndex index of the last line to be marked + * @param markerType type of marker + * @param classes + * @return [AceMarker] + */ +fun aceMarkerBuilder( + beginLineIndex: Int, + endLineIndex: Int = beginLineIndex, + markerType: String = "fullLine", + classes: String = "unsaved-marker", +): AceMarker = aceMarkerBuilder( + beginLineIndex, + 0, + endLineIndex, + 1, + classes, + markerType +) + +/** + * Get [AceMarker] from [positionString] - string in format: + * + * <FILE> (<START_ROW>:<START_COL>-<END_ROW><END_COL>) + * + * @param positionString string that contains beginning and ending positions of requested [AceMarker] + * @param classes + * @return [AceMarker] + */ +@Suppress("DestructuringDeclarationWithTooManyEntries") +fun aceMarkerBuilder( + positionString: String, + classes: String = "unsaved-marker", +): AceMarker? = positionString.parsePositionString() + ?.let { positionList -> + val (startRow, startCol, endRow, endCol) = positionList + aceMarkerBuilder( + startRow, + startCol, + endRow, + endCol, + classes, + "background", + ) + } + +/** + * Get [AceMarkers] by [CpgNodeAdditionalParams.location] - string in format + * + * `<FILENAME>(<START_LINE>:<START_CHAR>-<END_LINE>:<END_CHAR>)` + * + * @param positionString string that contains start and end positions + * @return [AceMarker] + */ +fun getAceMarkers( + positionString: String? +): AceMarkers = positionString?.let { + aceMarkerBuilder(positionString)?.let { + arrayOf(it) + } ?: emptyArray() +} ?: emptyArray() + +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +private fun aceMarkerBuilder( + beginLineIndex: Int, + beginCharacterIndex: Int, + endLineIndex: Int, + endCharacterIndex: Int, + classes: String, + markerType: String +): AceMarker = jso { + startRow = beginLineIndex + endRow = endLineIndex + startCol = beginCharacterIndex + endCol = endCharacterIndex + className = classes + type = markerType + inFront = false +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/AceThemes.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/AceThemes.kt new file mode 100644 index 0000000000..7b2d90db6b --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/AceThemes.kt @@ -0,0 +1,35 @@ +package com.saveourtool.save.frontend.common.externals.reactace + +/** + * Themes for AceEditor + * + * @property themeName substring of file with required theme + */ +enum class AceThemes(val themeName: String) { + /** + * Light theme + */ + CHROME("chrome"), + + /** + * Another light theme, but worse + */ + GITHUB("github"), + + /** + * Nice dark theme + */ + MONOKAI("monokai"), + ; + + /** + * Method that includes required theme + */ + fun require() = kotlinext.js.require<dynamic>("ace-builds/src-min-noconflict/theme-$themeName") + companion object { + /** + * Theme that is recommended to be used everywhere + */ + val preferredTheme = CHROME + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/ReactAce.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/ReactAce.kt new file mode 100644 index 0000000000..9b11c4e6d1 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/reactace/ReactAce.kt @@ -0,0 +1,82 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") +@file:JsModule("react-ace") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.reactace + +import react.* + +/** + * External declaration of [reactAce] react [FC] + */ +@JsName("default") +external val reactAce: FC<AceEditorProps> + +/** + * [Props] for [reactAce] + */ +@Suppress("COMMENTED_OUT_CODE") +@JsName("IAceEditorProps") +external interface AceEditorProps : Props { + var name: String + var style: CSSProperties + var mode: String + var theme: String + var height: String + var width: String + var className: String + var fontSize: String + var showGutter: Boolean + var showPrintMargin: Boolean + var highlightActiveLine: Boolean + var focus: Boolean + var cursorStart: Int + var wrapEnabled: Boolean + var readOnly: Boolean + var minLines: Int + var maxLines: Int + var navigateToFileEnd: Boolean + var debounceChangePeriod: Int? + var enableBasicAutocompletion: Boolean + var enableLiveAutocompletion: Boolean + var tabSize: Int + var value: String + var placeholder: String? + var defaultValue: String + var enableSnippets: Boolean + var setOptions: dynamic + var markers: AceMarkers + + @Suppress("TYPE_ALIAS") + var onChange: (value: String, event: dynamic) -> Unit + // var onSelectionChange: (value: String, event: Event) -> Unit + // onCursorChange?: (value: any, event?: any) => void; + // onInput?: (event?: any) => void; + // onLoad?: (editor: Ace.Editor) => void; + // onValidate?: (annotations: Ace.Annotation[]) => void; + // onBeforeLoad?: (ace: typeof AceBuilds) => void; + // onSelection?: (selectedText: string, event?: any) => void; + // onCopy?: (value: string) => void; + // onPaste?: (value: string) => void; + // onFocus?: (event: any, editor?: Ace.Editor) => void; + // onBlur?: (event: any, editor?: Ace.Editor) => void; + // onScroll?: (editor: IEditorProps) => void; + // editorProps?: IEditorProps; + // keyboardHandler?: string; + // commands?: ICommand[]; + // annotations?: Ace.Annotation[]; +} + +/** + * Line markers for [reactAce] + */ +@JsName("IMarker") +external interface AceMarker { + var startRow: Int + var startCol: Int + var endRow: Int + var endCol: Int + var className: String + var type: dynamic + var inFront: Boolean +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/slider/MultiRangeSlider.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/slider/MultiRangeSlider.kt new file mode 100644 index 0000000000..570304c59d --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/externals/slider/MultiRangeSlider.kt @@ -0,0 +1,141 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE", "FILE_NAME_MATCH_CLASS") +@file:JsModule("multi-range-slider-react") +@file:JsNonModule + +package com.saveourtool.save.frontend.common.externals.slider + +import react.CSSProperties +import react.FC +import react.Props + +/** + * External declaration of [multiRangeSlider] react component + */ +@JsName("default") +external val multiRangeSlider: FC<MultiRangeSliderProps> + +/** + * Props of [MultiRangeSliderProps] + */ +external interface MultiRangeSliderProps : Props { + /** + * Slider minimum value. + */ + var min: Float + + /** + * Slider maximum value. + */ + var max: Float + + /** + * Selected with thumb minimum value. + */ + var minValue: Float + + /** + * Selected with thumb maximum value. + */ + var maxValue: Float + + /** + * Value change on step change when bar clicked or keyboard arrow key pressed. + */ + var step: Float + + /** + * True then slider value change with only rounded step values. + */ + var stepOnly: Boolean + + /** + * True then it not accept mouse wheel to change its value. + */ + var preventWheel: Boolean + + /** + * Is ruler visible or not. + */ + var ruler: Boolean + + /** + * Is label visible or not. + */ + var label: Boolean + + /** + * Caption on min thumb when sliding - can set on onChange/onInput event. + */ + var minCaption: Boolean + + /** + * Caption on max thumb when sliding - can set on onChange/onInput event. + */ + var maxCaption: Boolean + + /** + * Specify/override additional style. + */ + var style: CSSProperties + + /** + * Specify slider left part background color. + */ + var barLeftColor: String + + /** + * Specify slider right part background color. + */ + var barRightColor: String + + /** + * Specify slider inner part background color. + */ + var barInnerColor: String + + /** + * Specify slider left thumb background color. + */ + var thumbLeftColor: String + + /** + * Specify slider right thumb background color. + */ + var thumbRightColor: String + + /** + * Trigger when slider value changing. + */ + var onInput: (ChangeResult) -> Unit + + /** + * Trigger when slider value change done. + */ + var onChange: (ChangeResult) -> Unit +} + +/** + * Result of slider value change + */ +@Suppress("USE_DATA_CLASS") +external class ChangeResult { + /** + * Slider minimum value. + */ + var min: Float + + /** + * Slider maximum value. + */ + var max: Float + + /** + * Changed selected with thumb minimum value. + */ + var minValue: Float + + /** + * Changed selected with thumb maximum value. + */ + var maxValue: Float +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/http/Exceptions.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/http/Exceptions.kt new file mode 100644 index 0000000000..a1429a32fa --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/http/Exceptions.kt @@ -0,0 +1,20 @@ +/** + * Exception types for frontend + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.http + +/** + * Exception class for HTTP responses until we use a dedicated HTTP client + * + * @property status response status + * @property statusText response status text + */ +data class HttpStatusException( + val status: Short, + val statusText: String, +) : RuntimeException() { + override val message: String = "$status $statusText" +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/http/Requests.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/http/Requests.kt new file mode 100644 index 0000000000..858bafdafd --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/http/Requests.kt @@ -0,0 +1,193 @@ +/** + * Methods to make specific requests to backend + */ + +package com.saveourtool.save.frontend.common.http + +import com.saveourtool.save.agent.TestExecutionDto +import com.saveourtool.save.entities.* +import com.saveourtool.save.entities.contest.ContestDto +import com.saveourtool.save.frontend.common.utils.* +import com.saveourtool.save.info.UserInfo +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 org.w3c.fetch.Response +import web.file.File +import web.http.FormData + +import kotlinx.browser.window + +/** + * @param name + * @param organizationName + * @return project + */ +suspend fun ComponentWithScope<*, *>.getProject(name: String, organizationName: String) = get( + "$apiUrl/projects/get/organization-name?name=$name&organizationName=$organizationName", + jsonHeaders, + loadingHandler = ::classLoadingHandler, + responseHandler = ::classComponentRedirectOnFallbackResponseHandler, +) + .runCatching { + decodeFromJsonString<ProjectDto>() + } + +/** + * @param name organization name + * @return organization + */ +suspend fun ComponentWithScope<*, *>.getOrganization(name: String) = get( + "$apiUrl/organizations/$name", + jsonHeaders, + loadingHandler = ::classLoadingHandler, + responseHandler = ::classComponentRedirectOnFallbackResponseHandler, +) + .decodeFromJsonString<OrganizationDto>() + +/** + * @param name contest name + * @return contestDTO + */ +suspend fun ComponentWithScope<*, *>.getContest(name: String) = get( + "$apiUrl/contests/$name", + jsonHeaders, + loadingHandler = ::classLoadingHandler, + responseHandler = ::classComponentRedirectOnFallbackResponseHandler, +) + .decodeFromJsonString<ContestDto>() + +/** + * @param name username + * @return info about user + */ +suspend fun ComponentWithScope<*, *>.getUser(name: String) = get( + "$apiUrl/users/$name", + jsonHeaders, + loadingHandler = ::classLoadingHandler, +) + .decodeFromJsonString<UserInfo>() + +/** + * @param file image file + * @param name avatar owner name + * @param type avatar type + * @param loadingHandler + */ +suspend fun ComponentWithScope<*, *>.postImageUpload( + file: File, + name: String, + type: AvatarType, + loadingHandler: suspend (suspend () -> Response) -> Response, +) { + val response = post( + url = "$apiUrl/avatar/upload", + params = jso<dynamic> { + owner = name + this.type = type + }, + Headers().apply { append(CONTENT_LENGTH_CUSTOM, file.size.toString()) }, + FormData().apply { set(FILE_PART_NAME, file) }, + loadingHandler, + ) + if (response.ok) { + window.location.reload() + } +} + +/** + * @param file image file + * @param name avatar owner name + * @param type avatar type + * @param loadingHandler + */ +suspend fun WithRequestStatusContext.postImageUpload( + file: File, + name: String?, + type: AvatarType, + loadingHandler: suspend (suspend () -> Response) -> Response, +) { + val response = post( + url = "$apiUrl/avatar/upload", + params = jso<dynamic> { + owner = name + this.type = type + }, + Headers().apply { append(CONTENT_LENGTH_CUSTOM, file.size.toString()) }, + FormData().apply { set(FILE_PART_NAME, file) }, + loadingHandler, + ) + if (response.ok) { + window.location.reload() + } +} + +/** + * @param url url to upload a file + * @param file a file which needs to be uploaded + * @param loadingHandler + * @return response of operation + */ +suspend fun WithRequestStatusContext.postUploadFile( + url: String, + file: File, + loadingHandler: suspend (suspend () -> Response) -> Response, +): Response = post( + url, + Headers().apply { append(CONTENT_LENGTH_CUSTOM, file.size.toString()) }, + FormData().apply { set(FILE_PART_NAME, file) }, + loadingHandler = loadingHandler, +) + +/** + * Fetch debug info for test execution + * + * @param testExecutionId id of a particular test execution + * @return Response + */ +@Suppress("TYPE_ALIAS") +suspend fun ComponentWithScope<*, *>.getDebugInfoFor( + testExecutionId: Long, +) = getDebugInfoFor(testExecutionId, this::get) + +/** + * Fetch debug info for test execution + * + * @param testExecutionId id of a particular test execution + * @return Response + */ +suspend fun WithRequestStatusContext.getDebugInfoFor( + testExecutionId: Long, +) = getDebugInfoFor(testExecutionId, this::get) + +/** + * Fetch execution info for test execution + * + * @param testExecutionDto + * @return Response + */ +@Suppress("TYPE_ALIAS") +suspend fun ComponentWithScope<*, *>.getExecutionInfoFor( + testExecutionDto: TestExecutionDto, +) = get( + "$apiUrl/files/get-execution-info", + params = jso<dynamic> { executionId = testExecutionDto.executionId }, + jsonHeaders, + ::noopLoadingHandler, + ::noopResponseHandler +) + +@Suppress("TYPE_ALIAS") +private suspend fun getDebugInfoFor( + testExecutionId: Long, + get: suspend (String, dynamic, Headers, suspend (suspend () -> Response) -> Response, (Response) -> Unit) -> Response, +) = get( + "$apiUrl/files/get-debug-info", + jso { this.testExecutionId = testExecutionId }, + jsonHeaders, + ::noopLoadingHandler, + ::noopResponseHandler, +) diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/themes/Colors.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/themes/Colors.kt new file mode 100644 index 0000000000..c6cedc8180 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/themes/Colors.kt @@ -0,0 +1,21 @@ +package com.saveourtool.save.frontend.common.themes + +/** + * @property value - string with the color in rgb + */ +enum class Colors(val value: String) { + DANGER("#dc3545"), + DARK_RED("rgba(130, 50, 58, 0.1)"), + GOLD("rgba(188,187,47, 0.1)"), + GREEN("rgba(139, 237, 78, 0.1)"), + GREY("rgba(188,186,179, 0.1)"), + NONE("#6b6b6b"), + ORANGE("#ffa500"), + RED("rgba(245, 50, 50, 0.1)"), + SAVE_PRIMARY("#3075c0"), + SUCCESS("#28a745"), + VULN_PRIMARY("#7952b3"), + WARNING("#ffc107"), + WHITE("#ffffff"), + ; +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Action.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Action.kt new file mode 100644 index 0000000000..aac768dbf2 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Action.kt @@ -0,0 +1,176 @@ +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.frontend.common.components.modal.displayModalWithCheckBox + +import org.w3c.fetch.Response +import react.* +import react.dom.html.ReactHTML.button +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.ButtonType +import web.html.InputType + +val actionButton: FC<ButtonWithActionProps> = FC { props -> + val windowOpenness = useWindowOpenness() + val (displayTitle, setDisplayTitle) = useState(props.title) + val (displayMessage, setDisplayMessage) = useState(props.message) + val (isError, setError) = useState(false) + val (isClickMode, setClickMode) = useState(false) + + val action = useDeferredRequest { + val response = props.sendRequest(isClickMode)(this) + if (response.ok) { + props.onActionSuccess(isClickMode) + } else { + setDisplayTitle(props.errorTitle) + setDisplayMessage(response.unpackMessage()) + setError(true) + windowOpenness.openWindow() + } + } + + div { + button { + type = ButtonType.button + className = ClassName(props.classes) + props.buttonStyleBuilder(this) + onClick = { + setDisplayTitle(props.title) + setDisplayMessage(props.message) + setClickMode(false) + windowOpenness.openWindow() + } + } + } + + displayModalWithCheckBox( + title = displayTitle, + message = displayMessage, + isOpen = windowOpenness.isOpen(), + onCloseButtonPressed = { + if (isError) { + setError(false) + } + windowOpenness.closeWindow() + }, + buttonBuilder = { + if (isError) { + buttonBuilder("Ok") { + windowOpenness.closeWindow() + setError(false) + } + } else { + props.modalButtons(action, windowOpenness.closeWindowAction(), this, isClickMode) + } + }, + clickBuilder = { + if (props.conditionClick && !isError) { + div { + className = ClassName("d-sm-flex justify-content-center form-check") + div { + className = ClassName("d-sm-flex justify-content-center form-check") + div { + input { + className = ClassName("form-check-input") + type = InputType.checkbox + value = isClickMode + id = "click" + checked = isClickMode + onChange = { + setClickMode(!isClickMode) + } + } + } + div { + label { + className = ClassName("click") + htmlFor = "click" + +props.clickMessage + } + } + } + } + } + } + ) +} + +/** + * Button with modal for some action + * + * @return noting + */ +external interface ButtonWithActionProps : Props { + /** + * Title of the modal + */ + var title: String + + /** + * Error title of the modal + */ + var errorTitle: String + + /** + * Message of the modal + */ + var message: String + + /** + * Message when clicked + */ + var clickMessage: String + + /** + * If the action (request) is successful, this is done + * + * @param isClickMode is checkBox status + */ + @Suppress("TYPE_ALIAS") + var onActionSuccess: (isClickMode: Boolean) -> Unit + + /** + * Button View + */ + var buttonStyleBuilder: (ChildrenBuilder) -> Unit + + /** + * Classname for the button + */ + var classes: String + + /** + * Modal buttons + * + * @param action is the main action of the buttons + * @param closeWindow is the action of closing the window and assigning the status false to the checkBox + * @param ChildrenBuilder + * @param isClickMode is checkBox status + * @return buttons + */ + @Suppress("TYPE_ALIAS") + var modalButtons: ( + action: () -> Unit, + closeWindow: () -> Unit, + ChildrenBuilder, + isClickMode: Boolean, + ) -> Unit + + /** + * Condition for click + */ + var conditionClick: Boolean + + /** + * function passes arguments to call the request + * + * @param isClickMode is checkBox status + * @return lazy response + */ + @Suppress("TYPE_ALIAS") + var sendRequest: (isClickMode: Boolean) -> DeferredRequestAction<Response> +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/CComponent.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/CComponent.kt new file mode 100644 index 0000000000..c19eda8731 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/CComponent.kt @@ -0,0 +1,60 @@ +@file:Suppress("FILE_NAME_INCORRECT") + +package com.saveourtool.save.frontend.common.utils + +import js.core.jso +import kotlinext.js.assign +import react.* +import react.Fragment + +/** + * Base class that inherit [Component] class in order to provide [ChildrenBuilder] API in class components. + */ +@Suppress("CLASS_NAME_INCORRECT") +abstract class CComponent<P : Props, S : State> : Component<P, S> { + constructor() : super() { + state = jso { init() } + } + constructor(props: P) : super(props) { + state = jso { init(props) } + } + + @Suppress( + "WRONG_OVERLOADING_FUNCTION_ARGUMENTS", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_ON_FUNCTION", + ) + open fun S.init() {} + + /** + * @param props + */ + @Suppress( + "WRONG_OVERLOADING_FUNCTION_ARGUMENTS", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "MISSING_KDOC_CLASS_ELEMENTS" + ) + open fun S.init(props: P) {} + + /** + * Wrapper for convenient use of `ChildrenBuilder#render()` + */ + override fun render(): ReactNode? = Fragment.create { + render() + } + + /** + * Method that should be overridden in order to render the component + */ + abstract fun ChildrenBuilder.render() + + /** + * State setter + * + * @param stateSetter lambda to set a state + */ + fun setState(stateSetter: S.() -> Unit) { + super.setState({ assign(it, stateSetter) }) + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ChildrenBuilderUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ChildrenBuilderUtils.kt new file mode 100644 index 0000000000..4f559544b9 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ChildrenBuilderUtils.kt @@ -0,0 +1,193 @@ +/** + * Utilities for kotlin-js ChildrenBuilder + */ + +@file:Suppress("FILE_NAME_INCORRECT", "FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.frontend.common.externals.fontawesome.FontAwesomeIconModule +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import js.core.jso + +import react.ChildrenBuilder +import react.dom.events.ChangeEventHandler +import react.dom.events.MouseEventHandler +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.option +import react.dom.html.ReactHTML.select +import web.cssom.BorderStyle +import web.cssom.BorderWidth +import web.cssom.ClassName +import web.html.ButtonType +import web.html.HTMLButtonElement +import web.html.HTMLSelectElement + +/** + * Enum that stores types of confirmation windows for different situations. + */ +enum class ConfirmationType { + DELETE_CONFIRM, + DELETE_FILE_CONFIRM, + NO_BINARY_CONFIRM, + NO_CONFIRM, + ; +} + +/** + * Function to create buttons for modals with text as content + * + * @param label text that will be displayed on button + * @param style color-defining string + * @param isDisabled flag that might disable button + * @param isOutline flag that defines either usual button or outlined will be displayed + * @param isActive flag that defines whether button should be displayed as pressed or not + * @param classes additional classes for button + * @param title title for tooltip + * @param onClickFun button click handler + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.buttonBuilder( + label: String, + style: String = "primary", + isDisabled: Boolean = false, + isOutline: Boolean = true, + isActive: Boolean = false, + classes: String = "", + title: String? = null, + onClickFun: MouseEventHandler<HTMLButtonElement>, +) { + buttonBuilder({ +label }, style, isDisabled, isOutline, isActive, classes, title, onClickFun) +} + +/** + * Function to create buttons for modals with icon as content + * + * @param icon icon that will be displayed on button + * @param style color-defining string + * @param isDisabled flag that might disable button + * @param isOutline flag that defines either usual button or outlined will be displayed + * @param isActive flag that defines whether button should be displayed as pressed or not + * @param classes additional classes for button + * @param title title for tooltip + * @param onClickFun button click handler + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +fun ChildrenBuilder.buttonBuilder( + icon: FontAwesomeIconModule, + style: String? = "primary", + isDisabled: Boolean = false, + isOutline: Boolean = false, + isActive: Boolean = false, + classes: String = "", + title: String? = null, + onClickFun: MouseEventHandler<HTMLButtonElement>, +) { + buttonBuilder({ fontAwesomeIcon(icon) { it.className = "align-middle" } }, style, isDisabled, isOutline, isActive, classes, title, onClickFun) +} + +/** + * Simple and light way to display selector. Use it in input-group. + * + * @param selectedValue currently selected value + * @param values list of possible selections + * @param classes additional classes for [select] tag + * @param isDisabled flag that might disable selector + * @param onChangeFun callback invoked on selector change + */ +fun ChildrenBuilder.selectorBuilder( + selectedValue: String, + values: List<String>, + classes: String = "", + isDisabled: Boolean = false, + onChangeFun: ChangeEventHandler<HTMLSelectElement>, +) { + select { + className = ClassName(classes) + disabled = isDisabled + onChange = onChangeFun + value = selectedValue + values.forEach { currentOption -> + option { + value = currentOption + +currentOption + } + } + } +} + +/** + * Render nice placeholder for an empty table + * + * @param classes string [ClassName] value that should be applied to higher [div] + * @param borderStyleString [BorderStyle] attribute as string, `dashed` by default + * @param borderWidthString [BorderWidth] attribute as string, `thin` by default + * @param noInformationLabelBuilder placeholder children builder - usually used for "No info" rendering text + */ +fun ChildrenBuilder.renderTablePlaceholder( + classes: String = "text-center p-5", + borderStyleString: String = "solid", + borderWidthString: String = "thin", + noInformationLabelBuilder: ChildrenBuilder.() -> Unit, +) { + div { + className = ClassName(classes) + style = jso { + borderStyle = borderStyleString.unsafeCast<BorderStyle>() + borderWidth = borderWidthString.unsafeCast<BorderWidth>() + } + noInformationLabelBuilder() + } +} + +/** + * @param labelBuilder + * @param style + * @param isDisabled + * @param isOutline + * @param isActive + * @param classes + * @param title + * @param onClickFun + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList", "LAMBDA_IS_NOT_LAST_PARAMETER") +fun ChildrenBuilder.buttonBuilder( + labelBuilder: ChildrenBuilder.() -> Unit, + style: String? = "primary", + isDisabled: Boolean = false, + isOutline: Boolean = false, + isActive: Boolean = false, + classes: String = "", + title: String? = null, + onClickFun: MouseEventHandler<HTMLButtonElement>, +) { + button { + type = ButtonType.button + val builtClasses = buildString { + append("btn") + style?.let { + append(" btn-") + if (isOutline) { + append("outline-") + } + append(it) + if (isActive) { + append(" active") + } + } + append(" align-middle") + append(" $classes") + } + className = ClassName(builtClasses) + disabled = isDisabled + onClick = onClickFun + title?.let { + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "top" + asDynamic()["data-original-title"] = title + this.title = title + } + labelBuilder() + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ComponentWithScope.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ComponentWithScope.kt new file mode 100644 index 0000000000..1940f68de5 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ComponentWithScope.kt @@ -0,0 +1,25 @@ +package com.saveourtool.save.frontend.common.utils + +import react.Props +import react.State + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive + +/** + * Base class for react components with CoroutineScope, that will be cancelled on unmounting. + */ +abstract class ComponentWithScope<P : Props, S : State> : CComponent<P, S>() { + /** + * A [CoroutineScope] that should be used by implementing classes. Will be cancelled on unmounting. + */ + val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + + override fun componentWillUnmount() { + if (scope.isActive) { + scope.cancel() + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/CustomHooks.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/CustomHooks.kt new file mode 100644 index 0000000000..6fdad12740 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/CustomHooks.kt @@ -0,0 +1,411 @@ +/** + * Contains custom react hooks + * + * Keep in mind that hooks could only be used from functional components! + */ + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.frontend.common.components.requestStatusContext +import com.saveourtool.save.frontend.common.externals.lodash.debounce +import com.saveourtool.save.info.UserStatus +import com.saveourtool.save.utils.DEFAULT_DEBOUNCE_PERIOD +import com.saveourtool.save.validation.FrontendRoutes + +import js.core.jso +import org.w3c.dom.EventSource +import org.w3c.dom.EventSource.Companion.CONNECTING +import org.w3c.dom.MessageEvent +import org.w3c.dom.events.Event +import org.w3c.fetch.Headers +import org.w3c.fetch.Response +import react.* +import react.router.useNavigate + +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onCompletion + +/** + * Hook that redirects to special pages depending on [status] + * + * @param status [UserStatus] of current user + */ +fun useUserStatusRedirects(status: UserStatus?) { + val navigate = useNavigate() + useEffect(status, window.location.pathname) { + if (status == UserStatus.CREATED && window.location.pathname != "/${FrontendRoutes.TERMS_OF_USE}") { + navigate("/${FrontendRoutes.REGISTRATION}", jso { replace = false }) + } else if (status == UserStatus.BANNED) { + navigate("/${FrontendRoutes.BAN}", jso { replace = false }) + } else if (status == UserStatus.NOT_APPROVED) { + navigate(to = "/${FrontendRoutes.THANKS_FOR_REGISTRATION}", jso { replace = false }) + } + } +} + +/** + * Hook that redirects to index view when [predicate] is true + * + * @param dependencies dependencies to be put to underlying [useEffect] + * @param predicate callback that defines whether redirect should be performed or not + */ +fun useRedirectToIndexIf(vararg dependencies: Any?, predicate: () -> Boolean) { + val navigate = useNavigate() + useEffect(dependencies) { + if (predicate()) { + navigate("/", jso { replace = true }) + } + } +} + +/** + * Runs the provided [action] only once of first render + * + * @param action + */ +fun useOnce(action: () -> Unit) { + val useOnceAction = useOnceAction() + useOnceAction { + action() + } +} + +/** + * @return action which will be run once per function component + */ +fun useOnceAction(): (() -> Unit) -> Unit { + val (isFirstRender, setFirstRender) = useState(true) + return { action -> + if (isFirstRender) { + action() + setFirstRender(false) + } + } +} + +/** + * Custom hook to enable tooltips. + * + * Requires element to have "data-toggle=tooltip" attribute set + * Show timeout can be set by setting "data-show-timeout=100" + * Hide timeout can be set by setting "data-hide-timeout=100" + * In order to update the tooltip content dynamically, you need to change "data-original-title" attribute + * + * @see [enableTooltip] + */ +fun useTooltip() { + useEffect { enableTooltip() } +} + +/** + * Custom hook to enable tooltips and popovers. + */ +fun useTooltipAndPopover() { + useEffect { + enableTooltipAndPopover() + return@useEffect + } +} + +/** + * Hook to get callbacks to perform requests in functional components. + * + * @param request + * @return a function to trigger request execution. + */ +fun <R> useDeferredRequest( + request: suspend WithRequestStatusContext.() -> R, +): () -> Unit { + val scope = CoroutineScope(Dispatchers.Default) + val context = useRequestStatusContext() + val (isSending, setIsSending) = useState(false) + useEffect(isSending) { + if (!isSending) { + return@useEffect + } + scope.launch { + request(context) + setIsSending(false) + }.invokeOnCompletion { + if (it != null && it !is CancellationException) { + setIsSending(false) + } + } + cleanup { + if (scope.isActive) { + scope.cancel() + } + } + } + val initiateSending: () -> Unit = { + if (!isSending) { + setIsSending(true) + } + } + return initiateSending +} + +/** + * Hook to perform requests in functional components. + * + * @param dependencies + * @param request + */ +fun <R> useRequest( + dependencies: Array<dynamic> = emptyArray(), + request: suspend WithRequestStatusContext.() -> R, +) { + val scope = CoroutineScope(Dispatchers.Default) + val context = useRequestStatusContext() + + useEffect(*dependencies) { + scope.launch { + request(context) + } + cleanup { + if (scope.isActive) { + scope.cancel() + } + } + } +} + +/** + * @return [WithRequestStatusContext] implementation + */ +@Suppress("TOO_LONG_FUNCTION", "MAGIC_NUMBER") +fun useRequestStatusContext(): WithRequestStatusContext { + val statusContext = useContext(requestStatusContext) + val context = object : WithRequestStatusContext { + override val coroutineScope = CoroutineScope(Dispatchers.Default) + override fun setResponse(response: Response) { + statusContext?.run { + setResponse(response) + } + } + override fun setRedirectToFallbackView(isNeedRedirect: Boolean, response: Response) { + statusContext?.run { + setRedirectToFallbackView( + isNeedRedirect && response.status == 404.toShort() + ) + } + } + override fun setLoadingCounter(transform: (oldValue: Int) -> Int) { + statusContext?.run { setLoadingCounter(transform) } + } + } + return context +} + +/** + * @param colorStyle page style + */ +fun useBackground(colorStyle: Style) { + useOnce { + document.getElementById("main-body")?.apply { + className = when (colorStyle) { + Style.SAVE_DARK, Style.SAVE_LIGHT -> className.replace("vuln", "save") + Style.VULN_DARK, Style.VULN_LIGHT -> className.replace("save", "vuln") + Style.INDEX -> className.replace("vuln", "save") + } + } + } + useEffect { + document.getElementById("content-wrapper")?.apply { + setAttribute( + "style", + "background: ${colorStyle.globalBackground}", + ) + } + configureTopBar(colorStyle) + } +} + +/** + * Creates a callback that is run synchronously. + * + * Only works inside functional components. + * + * @param effect the callback body (executed under [useEffect]). + * @return a lambda that triggers the callback. + * @see useEffect + */ +fun useDeferredEffect( + effect: EffectBuilder.() -> Unit, +): () -> Unit { + var isRunning by useState(initialValue = false) + + useEffect(isRunning) { + if (!isRunning) { + return@useEffect + } + + effect() + + isRunning = false + } + + return { + if (!isRunning) { + isRunning = true + } + } +} + +/** + * Reads the response of `text/event-stream` `Content-Type`. + * _Server-Sent Events_ (SSE) are limited to `HTTP GET` method. + * + * Only works inside functional components. + * + * @param url the URL that accepts an `HTTP GET` and can respond with a + * `text/event-stream` `Content-Type`. + * @param withCredentials whether + * [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) + * should be allowed, the default is `false`. + * @param eventType the event type selector. + * The same HTTP endpoint may return events of different types, and this + * selector allows to receive only a certain subset of the whole event volume. + * The default is `message`. + * On the server side, use `org.springframework.http.codec.ServerSentEvent` to + * create an event with a custom type. + * @param init invoked before an event stream is requested. + * Allowed to change the state of components. + * @param onCompletion invoked if the response is `HTTP 200 OK`, and when the + * server closes the connection. + * Allowed to change the state of components. + * @param onError invoked when the event source reports an error. + * Allowed to change the state of components. + * @param onEvent invoked when a new event arrives. + * Allowed to change the state of components. + * @return a lambda that triggers the callback. + * @see useNdjson + */ +@Suppress( + "LongParameterList", + "TOO_MANY_PARAMETERS", + "TYPE_ALIAS", +) +fun useEventStream( + url: String, + withCredentials: Boolean = false, + eventType: String = "message", + init: EffectBuilder.() -> Unit = {}, + onCompletion: EffectBuilder.() -> Unit = {}, + onError: EffectBuilder.(error: Event, readyState: Short) -> Unit = { _, _ -> }, + onEvent: EffectBuilder.(message: MessageEvent) -> Unit, +): () -> Unit = + useDeferredEffect { + init() + + val source = EventSource( + url = url, + eventSourceInitDict = jso { + this.withCredentials = withCredentials + }, + ) + + source.addEventListener(eventType, { event -> + onEvent(event as MessageEvent) + }) + + source.onerror = { error -> + if (source.readyState == CONNECTING) { + source.close() + onCompletion() + } else { + onError(error, source.readyState) + } + } + } + +/** + * [useState] modification in order to support default state value from [Props]. + * Usually data passed with [Props] is not up-to-date yet and expected to be updated (e.g. when the response is received) + * In this case, [useStateFromProps] should update the state once on first [valueFromProps] change. + * + * @param valueFromProps value that somehow depends on variable from [Props] + * @param postProcess callback that should be applied to [valueFromProps] when [Props] are loaded + * @return [StateInstance] of [valueFromProps] changing its value on first [valueFromProps] change + * @see [useState] + */ +fun <T : Any> useStateFromProps(valueFromProps: T, postProcess: (T) -> T = { it }): StateInstance<T> { + val state = useState(valueFromProps) + val onceWrapper = useOnceAction() + val reference = useRef(valueFromProps) + useEffect(valueFromProps) { + if (reference.current != valueFromProps) { + onceWrapper { + state.component2().invoke(valueFromProps.let(postProcess)) + } + } + } + return state +} + +/** + * Hook to get callbacks to perform requests in functional components with [debounce]. + * + * @param debouncePeriodMillis debounce period milliseconds + * @param request request that should be sent + * @return a function to trigger request execution. + */ +fun useDebouncedDeferredRequest( + debouncePeriodMillis: Int = DEFAULT_DEBOUNCE_PERIOD, + request: suspend WithRequestStatusContext.() -> Unit, +) = debounce(useDeferredRequest(request), debouncePeriodMillis) + +/** + * Reads the response of `application/x-ndjson` `Content-Type`. + * + * Only works inside functional components. + * + * @param url the URL that accepts an `HTTP GET` and can respond with a + * `application/x-ndjson` `Content-Type`. + * @param init invoked before an event stream is requested. + * Allowed to change the state of components. + * @param onCompletion invoked if the response is `HTTP 200 OK`, and when the + * server closes the connection. + * Allowed to change the state of components. + * @param onError invoked when the event source reports an error. + * Allowed to change the state of components. + * @param onEvent invoked when a new event arrives. + * Allowed to change the state of components. + * @return a lambda that triggers the callback. + * @see useEventStream + */ +internal fun useNdjson( + url: String, + init: () -> Unit = {}, + onCompletion: () -> Unit = {}, + onError: suspend (response: Response) -> Unit = { _ -> }, + onEvent: (message: String) -> Unit, +): () -> Unit = + useDeferredRequest { + init() + + val response = get( + url = url, + params = jso(), + headers = Headers(jso { + Accept = "application/x-ndjson" + }), + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + when { + response.ok -> response + .readLines() + .filter(String::isNotEmpty) + .onCompletion { + onCompletion() + } + .collect(onEvent) + + else -> onError(response) + } + } diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/FilterParamUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/FilterParamUtils.kt new file mode 100644 index 0000000000..5369f88cfc --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/FilterParamUtils.kt @@ -0,0 +1,18 @@ +/** + * If you would like to add filtering to tables and have proper routing on react level to filtered views + * than you can add extension methods to URLSearchParams and pass filter to particular view + */ + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.filters.VulnerabilityFilter +import org.w3c.dom.url.URLSearchParams + +/** + * @return VulnerabilityFilter that can be passed to a table + */ +fun URLSearchParams.toVulnerabilitiesFilter(): VulnerabilityFilter { + val tags = this.get("tag")?.let { setOf(it) } ?: emptySet() + + return VulnerabilityFilter(tags = tags) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/HtmlElements.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/HtmlElements.kt new file mode 100644 index 0000000000..93563c3869 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/HtmlElements.kt @@ -0,0 +1,100 @@ +/** + * File that contains html elements that are used multiple times in the project + */ + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.entities.* +import com.saveourtool.save.frontend.common.externals.fontawesome.FontAwesomeIconModule +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon + +import js.core.jso +import react.ChildrenBuilder +import react.StateInstance +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.span +import web.cssom.BorderRadius +import web.cssom.ClassName +import web.html.ButtonType + +/** + * @param project + */ +fun ChildrenBuilder.privacySpan(project: ProjectDto) { + span { + className = ClassName("border ml-2 pr-1 pl-1 text-xs text-muted ") + style = jso { + borderRadius = "2em".unsafeCast<BorderRadius>() + } + +if (project.isPublic) "public" else "private" + } +} + +/** + * @param classes + * @param text + */ +fun ChildrenBuilder.spanWithClassesAndText(classes: String, text: String) { + span { + className = ClassName("border ml-2 pr-1 pl-1 text-xs $classes") + style = jso { + borderRadius = "2em".unsafeCast<BorderRadius>() + } + +text + } +} + +/** + * @param icon + * @param isActive + * @param tooltipText + * @param onClickFun + */ +fun ChildrenBuilder.buttonWithIcon( + icon: FontAwesomeIconModule, + isActive: Boolean, + tooltipText: String, + onClickFun: () -> Unit +) { + button { + type = ButtonType.button + title = tooltipText + val active = if (isActive) { + "active" + } else { + "" + } + className = ClassName("btn btn-outline-secondary $active") + fontAwesomeIcon(icon = icon) + onClick = { + onClickFun() + } + asDynamic()["data-toggle"] = "tooltip" + asDynamic()["data-placement"] = "bottom" + } +} + +/** + * @param icon + * @param tooltipText + * @param buttonMode + * @param currentModeState + * @param onClickFun + */ +fun <T : Enum<T>> ChildrenBuilder.buttonWithIcon( + icon: FontAwesomeIconModule, + tooltipText: String, + buttonMode: T, + currentModeState: StateInstance<T>, + onClickFun: () -> Unit, +) { + val (currentMode, setCurrentMode) = currentModeState + buttonWithIcon( + icon = icon, + isActive = buttonMode == currentMode, + tooltipText = tooltipText, + ) { + setCurrentMode(buttonMode) + onClickFun() + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/JsUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/JsUtils.kt new file mode 100644 index 0000000000..b4e35a7af4 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/JsUtils.kt @@ -0,0 +1,210 @@ +/** + * Utilities for kotlin-js interop + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.utils + +import js.core.Object +import react.ChildrenBuilder + +import kotlinx.browser.document +import kotlinx.browser.window + +private const val SUPER_ADMIN_MESSAGE = "Keep in mind that you are super admin, so you are able to manage organization regardless of your organization permissions." + +const val SAVE_DARK_GRADIENT = "-webkit-linear-gradient(270deg, rgb(0,20,73), rgb(13,71,161))" + +const val VULN_DARK_GRADIENT = "-webkit-linear-gradient(270deg, #3e295e, #662cbd)" + +const val SAVE_LIGHT_GRADIENT = "-webkit-linear-gradient(270deg, rgb(209, 229, 235), rgb(217, 215, 235))" + +const val VULN_DARK_REVERSED_GRADIENT = "-webkit-linear-gradient(90deg, #3e295e, #662cbd)" + +/** + * @property globalBackground + * @property topBarBgColor + * @property topBarTransparency + * @property borderForContainer + * @property marginBottomForTopBar + */ +enum class Style( + val globalBackground: String, + val topBarBgColor: String, + val topBarTransparency: String, + val borderForContainer: String, + val marginBottomForTopBar: String, +) { + INDEX( + "-webkit-linear-gradient(0deg, rgb(0,20,73), #662cbd)", + "", + "transparent", + "px-0", + "", + ), + SAVE_DARK( + SAVE_DARK_GRADIENT, + "", + "transparent", + "px-0", + "", + ), + SAVE_LIGHT( + "bg-light", + "bg-dark", + "bg-dark", + "", + "mb-3", + ), + VULN_DARK( + VULN_DARK_GRADIENT, + "", + "transparent", + "px-0", + "", + ), + VULN_LIGHT( + "bg-light", + "#563d7c", + "#563d7c", + "", + "mb-3", + ), + ; +} + +/** + * Shortcut for + * ```kotlin + * child(MyComponent::class) { + * spread(props) { key, value -> + * attrs[key] = value + * } + * } + * ``` + * + * Allows writing `<MyComponent ...props/>` as + * ```kotlin + * child(MyComponent::class) { + * spread(props) + * } + * ``` + * + * @param jsObject a JS object properties of which will be used + */ +fun ChildrenBuilder.spread(jsObject: Any) { + spread(jsObject) { key, value -> + asDynamic()[key] = value + } +} + +/** + * Attempt to mimic `...` operator from ES6. + * For example, equivalent of `<MyComponent ...props/>` would be + * ```kotlin + * child(MyComponent::class) { + * spread(props) { key, value -> + * attrs[key] = value + * } + * } + * ``` + * + * @param jsObject a JS object which properties will be used + * @param handler a handler for [jsObject]'s property names and values + */ +@Suppress("TYPE_ALIAS") +fun spread(jsObject: Any, handler: (key: String, value: Any) -> Unit) { + Object.keys(jsObject).map { + it to jsObject.asDynamic()[it] as Any + } + .forEach { (key, value) -> + handler(key, value) + } +} + +/** + * External function to JS + * + * @param str + * @return encoded [str] + */ +@Suppress("FUNCTION_NAME_INCORRECT_CASE") +external fun encodeURIComponent(str: String): String + +/** + * Function invoked when super admin might change something because of global role + * + * @return [Unit] + */ +fun showGlobalRoleConfirmation() = window.alert(SUPER_ADMIN_MESSAGE) + +/** + * JS code lines to enable tooltip. + * + * @return dynamic + * @see [useTooltip] + */ +// language=js +fun enableTooltip() { + js(""" + var jQuery = require("jquery") + require("popper.js") + require("bootstrap") + jQuery('[data-toggle="tooltip"]').each(function() { + jQuery(this).tooltip({ + delay: { + "show": jQuery(this).attr("data-show-timeout") || 100, + "hide": jQuery(this).attr("data-hide-timeout") || 100 + } + }) + }) +""") +} + +/** + * JS code lines to enable tooltip and popover. + * + * @return dynamic + */ +// language=JS +fun enableTooltipAndPopover() = js(""" + var jQuery = require("jquery") + require("popper.js") + require("bootstrap") + jQuery('.popover').each(function() { + jQuery(this).popover({ + placement: jQuery(this).attr("popover-placement"), + title: jQuery(this).attr("popover-title"), + content: jQuery(this).attr("popover-content"), + html: true + }).on('show.bs.popover', function() { + jQuery(this).tooltip('hide') + }).on('hide.bs.popover', function() { + jQuery(this).tooltip('show') + }) + }) +""") + +/** + * @param style + */ +internal fun configureTopBar(style: Style) { + val topBar = document.getElementById("navigation-top-bar") + topBar?.setAttribute( + "class", + "navbar navbar-expand ${style.topBarBgColor} navbar-dark topbar ${style.marginBottomForTopBar} " + + "static-top shadow mr-1 ml-1 rounded" + ) + + topBar?.setAttribute( + "style", + "background: ${style.topBarTransparency}" + ) + + val container = document.getElementById("common-save-container") + container?.setAttribute( + "class", + "container-fluid ${style.borderForContainer}" + ) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/LocationUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/LocationUtils.kt new file mode 100644 index 0000000000..970180bf09 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/LocationUtils.kt @@ -0,0 +1,40 @@ +@file:Suppress("HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.validation.FrontendRoutes +import com.saveourtool.save.validation.SETTINGS +import remix.run.router.Location + +/** + * @param url url for comparison + * @return true if [Location.pathname] is not [url], false otherwise + */ +fun Location<*>.not(url: String) = pathname != url + +/** + * @param urls list of urls + * @return true of [Location] is not in [urls] + */ +fun Location<*>.notIn(urls: List<String>) = urls.all { not(it) } + +/** + * @param routes array of [FrontendRoutes] + * @return true of [Location] is not in [FrontendRoutes.path] of [routes] + */ +fun Location<*>.notIn(routes: Array<FrontendRoutes>) = notIn(routes.map { "/$it" }) + +/** + * @return true if [Location.pathname] starts with `/vuln`, false otherwise + */ +fun Location<*>.isVuln() = this.pathname.startsWith("/vuln") + +/** + * @return true if [Location.pathname] starts with `/settings`, false otherwise + */ +fun Location<*>.isSettings() = this.pathname.startsWith("/$SETTINGS") + +/** + * @return true if we are on a main (index) page + */ +fun Location<*>.isIndex() = pathname == "/" diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/LoginUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/LoginUtils.kt new file mode 100644 index 0000000000..85222e1701 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/LoginUtils.kt @@ -0,0 +1,114 @@ +/** + * Utilities for visualization of auth providers on welcome and index pages + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.frontend.common.externals.fontawesome.* +import com.saveourtool.save.frontend.common.externals.fontawesome.faCopyright +import com.saveourtool.save.frontend.common.externals.fontawesome.faSignInAlt +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.info.OauthProviderInfo + +import js.core.jso +import react.ChildrenBuilder +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import web.cssom.* + +/** + * Configuration for setting up oauth provider buttons on the frontend + * + * @property size font size of oauth logos + * @property provider oauth provider (Huawei, Gitee, Github, etc.) + * @property animate animation for these logos + * @property label text for logo + */ +data class OauthProvidersFeConfig( + val size: FontSize, + val provider: OauthProviderInfo, + val animate: String = "", + val label: String = "" +) + +/** + * @param oauthProvidersFeConfig configuration for the frontend + * @return html block with logo of the provider + */ +fun ChildrenBuilder.processRegistrationId( + oauthProvidersFeConfig: OauthProvidersFeConfig +) = oauthLoginForKnownAwesomeIcons( + oauthProvidersFeConfig, + mapKnownUploadedIcons(oauthProvidersFeConfig.provider.registrationId), + mapKnownFontAwesomeIcons(oauthProvidersFeConfig.provider.registrationId) +) + +/** + * @param oauthProvidersFeConfig all configurations + * @param staticSvg + * @param awesomeIcon known icon implemented in awesomeIcon + */ +private fun ChildrenBuilder.oauthLoginForKnownAwesomeIcons( + oauthProvidersFeConfig: OauthProvidersFeConfig, + staticSvg: String = "", + awesomeIcon: dynamic +) { + div { + className = ClassName("animated-provider col animate__animated ${oauthProvidersFeConfig.animate} mb-4") + a { + href = oauthProvidersFeConfig.provider.authorizationLink + className = ClassName("text-center") + div { + className = ClassName("col text-center") + style = jso { + fontSize = oauthProvidersFeConfig.size + } + // if there is no fontAwesome for this brand we use simple static SVG + if (staticSvg.isEmpty()) { + fontAwesomeIcon(awesomeIcon) + } else { + img { + src = staticSvg + style = jso { + width = oauthProvidersFeConfig.size.unsafeCast<Width>() + height = oauthProvidersFeConfig.size.unsafeCast<Height>() + cursor = "pointer".unsafeCast<Cursor>() + } + } + } + } + div { + className = ClassName("col text text-center") + +oauthProvidersFeConfig.label + } + } + } +} + +/** + * @param registrationId oauth provider name (same as in spring security config) from api-gateway + */ +fun mapKnownFontAwesomeIcons(registrationId: String) = + when (registrationId) { + "codehub" -> faCopyright + else -> faSignInAlt + } + +/** + * Mapping ONLY for those icons that are uploaded to SAVE. + * Please note that companies like google strictly prohibits incorrect usage of sign-in buttons: + * https://developers.google.com/identity/branding-guidelines + * + * @param registrationId + */ +fun mapKnownUploadedIcons(registrationId: String) = + when (registrationId) { + "huawei" -> "/img/huawei.svg" + "gitee" -> "/img/gitee.svg" + "github" -> "/img/github.svg" + "google" -> "/img/google.svg" + else -> "" + } 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/utils/OrganizationMenuBar.kt new file mode 100644 index 0000000000..86d35cd6ae --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/OrganizationMenuBar.kt @@ -0,0 +1,24 @@ +package com.saveourtool.save.frontend.common.utils + +/** + * A value for project menu. + */ +@Suppress("WRONG_DECLARATIONS_ORDER") +enum class OrganizationMenuBar(private val title: String? = null) { + INFO, + TOOLS, + BENCHMARKS, + CONTESTS, + VULNERABILITIES, + SETTINGS, + ; + + /** + * @return title or name if title is not specified + */ + fun getTitle() = title ?: name + + companion object { + val defaultTab: OrganizationMenuBar = INFO + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Particles.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Particles.kt new file mode 100644 index 0000000000..eca6b65f65 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Particles.kt @@ -0,0 +1,26 @@ +/** + * Particles - effect with flying objects on the background + */ + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.frontend.common.externals.animations.Particles +import react.ChildrenBuilder +import react.react + +/** + * @param enabled true if particles enabled + */ +fun ChildrenBuilder.particles( + enabled: Boolean = true, +) { + if (enabled) { + // FixMe: Note that they block user interactions. + // Particles are superimposed on top of the view in some transitions + // https://github.com/matteobruni/tsparticles/discussions/4489 + Particles::class.react { + id = "tsparticles" + url = "${kotlinx.browser.window.location.origin}/particles.json" + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ProjectMenuBar.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ProjectMenuBar.kt new file mode 100644 index 0000000000..acb5b1bb9e --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/ProjectMenuBar.kt @@ -0,0 +1,18 @@ +package com.saveourtool.save.frontend.common.utils + +/** + * A value for project menu. + */ +@Suppress("WRONG_DECLARATIONS_ORDER") +enum class ProjectMenuBar { + INFO, + RUN, + FILES, + STATISTICS, + DEMO, + SETTINGS, + ; + companion object { + val defaultTab = INFO + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/RequestUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/RequestUtils.kt new file mode 100644 index 0000000000..5b27e63553 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/RequestUtils.kt @@ -0,0 +1,841 @@ +/** + * Kotlin/JS utilities for Fetch API + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.coroutines.flow.decodeToString +import com.saveourtool.save.frontend.common.components.RequestStatusContext +import com.saveourtool.save.frontend.common.components.requestStatusContext +import com.saveourtool.save.frontend.common.http.HttpStatusException +import com.saveourtool.save.v1 + +import js.buffer.ArrayBuffer +import js.core.jso +import js.promise.asDeferred +import js.typedarrays.Int8Array +import js.typedarrays.Uint8Array +import org.w3c.dom.url.URLSearchParams +import org.w3c.fetch.Headers +import org.w3c.fetch.RequestCredentials +import org.w3c.fetch.RequestInit +import org.w3c.fetch.Response +import web.streams.ReadableStream +import web.streams.ReadableStreamReadValueResult + +import kotlin.js.Promise +import kotlinx.browser.window +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.* + +val apiUrl = "${window.location.origin}/api/$v1" +val demoApiUrl = "${window.location.origin}/api/demo" +val cpgDemoApiUrl = "${window.location.origin}/api/cpg" + +val jsonHeaders = Headers() + .withAcceptJson() + .withContentTypeJson() + +/** + * The chunk of data read from the body of an HTTP response. + * + * @param T the type of the data (usually a byte array). + */ +private typealias ResultAsync<T> = Promise<ReadableStreamReadValueResult<T>> + +/** + * Interface for objects that have access to [requestStatusContext] + */ +interface WithRequestStatusContext { + /** + * Coroutine used for processing [setLoadingCounter] + */ + val coroutineScope: CoroutineScope + + /** + * @param response + */ + fun setResponse(response: Response) + + /** + * @param isNeedRedirect + * @param response + */ + fun setRedirectToFallbackView(isNeedRedirect: Boolean, response: Response) + + /** + * @param transform + */ + fun setLoadingCounter(transform: (oldValue: Int) -> Int) +} + +/** + * @return [this] headers with `content-type` for JSON + */ +fun Headers.withContentTypeJson() = apply { + set("Content-Type", "application/json") +} + +/** + * @return [this] headers with `accept` for JSON + */ +fun Headers.withAcceptJson() = apply { + set("Accept", "application/json") +} + +/** + * @return [this] headers with `accept` for NDJSON + */ +fun Headers.withAcceptNdjson() = apply { + set("Accept", "application/x-ndjson") +} + +/** + * @return [this] headers with `accept` for octet-stream (bytes) + */ +fun Headers.withAcceptOctetStream() = apply { + set("Accept", "application/octet-stream") +} + +/** + * Gets errors from the back-end (_Spring Boot_ returns errors in the `message` + * part of JSON). + * + * @return the `message` part of JSON response, or "null" if the `message` is + * `null`. + * @see Response.unpackMessageOrNull + * @see Response.unpackMessageOrHttpStatus + */ +suspend fun Response.unpackMessage(): String = unpackMessageOrNull().toString() + +/** + * Gets errors from the back-end (_Spring Boot_ returns errors in the `message` + * part of JSON). + * + * @return the `message` part of JSON response (may well be `null`). + * @see Response.unpackMessage + * @see Response.unpackMessageOrHttpStatus + */ +suspend fun Response.unpackMessageOrNull(): String? = decodeFieldFromJsonStringOrNull("message") + +/** + * Gets errors from the back-end (_Spring Boot_ returns errors in the `message` + * part of JSON). + * + * @return the `message` part of JSON response, or the HTTP status line (in the + * form of "HTTP 418 I'm a teapot"). + * @see Response.unpackMessage + * @see Response.unpackMessageOrNull + */ +suspend fun Response.unpackMessageOrHttpStatus(): String = unpackMessageOrNull() ?: "HTTP $status $statusText" + +/** + * Perform a mapping operation on a [Response] if it's status is OK or throw an exception otherwise. + * + * @param map mapping function + * @return mapped result + * @throws IllegalStateException if response status is not OK + */ +suspend fun <T> Response.unsafeMap(map: suspend (Response) -> T) = if (this.ok) { + map(this) +} else { + throw HttpStatusException(status, statusText) +} + +/** + * Read [this] Response body as text and deserialize it using [Json] as type [T] + * + * @return response body deserialized as [T] + */ +suspend inline fun <reified T> Response.decodeFromJsonString() = Json.decodeFromString<T>(text().await()) + +/** + * Read [this] Response body as text and deserialize it using [Json] to [JsonObject] and take [fieldName] + * + * @param fieldName + * @return content of [fieldName] taken from response body + * @throws IllegalArgumentException if [fieldName] is not present in response body + */ +suspend inline fun Response.decodeFieldFromJsonString(fieldName: String): String = decodeFieldFromJsonStringOrNull(fieldName) + ?: throw IllegalArgumentException("Not found field \'$fieldName\' in response body") + +/** + * Read [this] Response body as text and deserialize it using [Json] to [JsonObject] and take [fieldName] + * + * @param fieldName + * @return content of [fieldName] taken from response body or null if [fieldName] is not present + */ +suspend inline fun Response.decodeFieldFromJsonStringOrNull(fieldName: String): String? = text().await() + .let { Json.parseToJsonElement(it) } + .let { it as? JsonObject } + ?.let { it[fieldName] } + ?.let { it as? JsonPrimitive } + ?.content + +/** + * @return content of [this] with type [T] encoded as JSON + */ +inline fun <reified T : Any> T.toJsonBody(): String = Json.encodeToString(this) + +/** + * Perform GET request from a class component. See [request] for parameter description. + * + * @return [Response] + */ +@Suppress("KDOC_WITHOUT_PARAM_TAG") +suspend fun ComponentWithScope<*, *>.get( + url: String, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::classComponentResponseHandler, +): Response = get<dynamic>( + url = url, + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Performs a `GET` request from a class component. See [request] for parameter + * description. + * + * @param T the type of [request parameters][params] (by default, use `dynamic`). + * @param url the request URL (may or may not end with `?`). + * @param params the request parameters, the default is an empty object (`jso {}`). + * @return the HTTP response _promise_, see + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * The response, even a successful one, can also be processed using + * [responseHandler]. + * @see request + */ +@Suppress( + "KDOC_WITHOUT_PARAM_TAG", + "EMPTY_BLOCK_STRUCTURE_ERROR", +) +suspend fun <T : Any> ComponentWithScope<*, *>.get( + url: String, + params: T = jso { }, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::classComponentResponseHandler, +): Response = request( + url = url.withParams(params), + method = "GET", + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Perform POST request from a class component. See [request] for parameter description. + * + * @return [Response] + */ +@Suppress("KDOC_WITHOUT_PARAM_TAG") +suspend fun ComponentWithScope<*, *>.post( + url: String, + headers: Headers, + body: dynamic, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::classComponentResponseHandler, +): Response = post<dynamic>( + url = url, + headers = headers, + body = body, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Performs a `POST` request from a class component. + * + * @param T the type of [request parameters][params] (by default, use `dynamic`). + * @param url the request URL (may or may not end with `?`). + * @param params the request parameters, the default is an empty object (`jso {}`). + * @param headers the HTTP request headers. + * Use [jsonHeaders] for the standard `Accept` and `Content-Type` headers. + * @param responseHandler the response handler to be invoked. + * The default implementation is to show the modal dialog if the HTTP response + * code is not in the range of 200..299 (i.e. [Response.ok] is `false`). + * Alternatively, a custom or a [noopResponseHandler] can be used, or the + * return value can be inspected directly. + * @return the HTTP response _promise_, see + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * The response, even a successful one, can also be processed using + * [responseHandler]. + */ +@Suppress( + "LongParameterList", + "TOO_MANY_PARAMETERS", + "KDOC_WITHOUT_PARAM_TAG", + "EMPTY_BLOCK_STRUCTURE_ERROR", +) +suspend fun <T : Any> ComponentWithScope<*, *>.post( + url: String, + params: T = jso { }, + headers: Headers, + body: dynamic, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::classComponentResponseHandler, +): Response = request( + url = url.withParams(params), + method = "POST", + headers = headers, + body = body, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Perform DELETE request from a class component. See [request] for parameter description. + * + * @return [Response] + */ +@Suppress("KDOC_WITHOUT_PARAM_TAG") +suspend fun ComponentWithScope<*, *>.delete( + url: String, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::classComponentResponseHandler, +): Response = delete<dynamic>( + url = url, + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Performs a `DELETE` request from a class component. + * + * @param T the type of [request parameters][params] (by default, use `dynamic`). + * @param url the request URL (may or may not end with `?`). + * @param params the request parameters, the default is an empty object (`jso {}`). + * @param headers the HTTP request headers. + * Use [jsonHeaders] for the standard `Accept` and `Content-Type` headers. + * @param responseHandler the response handler to be invoked. + * The default implementation is to show the modal dialog if the HTTP response + * code is not in the range of 200..299 (i.e. [Response.ok] is `false`). + * Alternatively, a custom or a [noopResponseHandler] can be used, or the + * return value can be inspected directly. + * @return the HTTP response _promise_, see + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * The response, even a successful one, can also be processed using + * [responseHandler]. + */ +@Suppress( + "KDOC_WITHOUT_PARAM_TAG", + "EMPTY_BLOCK_STRUCTURE_ERROR", +) +suspend fun <T : Any> ComponentWithScope<*, *>.delete( + url: String, + params: T = jso { }, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::classComponentResponseHandler, +): Response = request( + url = url.withParams(params), + method = "DELETE", + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Perform GET request from a functional component + * + * @return [Response] + */ +@Suppress( + "KDOC_WITHOUT_PARAM_TAG", + "EXTENSION_FUNCTION_WITH_CLASS", +) +suspend fun WithRequestStatusContext.get( + url: String, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::withModalResponseHandler, +): Response = get<dynamic>( + url = url, + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Performs a `GET` request from a functional component. See [request] for + * parameter description. + * + * @param T the type of [request parameters][params] (by default, use `dynamic`). + * @param url the request URL (may or may not end with `?`). + * @param params the request parameters, the default is an empty object (`jso {}`). + * @return the HTTP response _promise_, see + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * The response, even a successful one, can also be processed using + * [responseHandler]. + * @see request + */ +@Suppress( + "KDOC_WITHOUT_PARAM_TAG", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "EXTENSION_FUNCTION_WITH_CLASS", +) +suspend fun <T : Any> WithRequestStatusContext.get( + url: String, + params: T = jso { }, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::withModalResponseHandler, +): Response = request( + url = url.withParams(params), + method = "GET", + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Perform POST request from a functional component + * + * @return [Response] + */ +@Suppress( + "KDOC_WITHOUT_PARAM_TAG", + "EXTENSION_FUNCTION_WITH_CLASS", +) +suspend fun WithRequestStatusContext.post( + url: String, + headers: Headers, + body: dynamic, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::withModalResponseHandler, +): Response = post<dynamic>( + url = url, + headers = headers, + body = body, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Performs a `POST` request from a functional component. + * + * @param T the type of [request parameters][params] (by default, use `dynamic`). + * @param url the request URL (may or may not end with `?`). + * @param params the request parameters, the default is an empty object (`jso {}`). + * @param headers the HTTP request headers. + * Use [jsonHeaders] for the standard `Accept` and `Content-Type` headers. + * @param loadingHandler use either [WithRequestStatusContext.loadingHandler], + * or [noopLoadingHandler]. + * @param responseHandler the response handler to be invoked. + * The default implementation is to show the modal dialog if the HTTP response + * code is not in the range of 200..299 (i.e. [Response.ok] is `false`). + * Alternatively, a custom or a [noopResponseHandler] can be used, or the + * return value can be inspected directly. + * @return the HTTP response _promise_, see + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * The response, even a successful one, can also be processed using + * [responseHandler]. + */ +@Suppress( + "LongParameterList", + "TOO_MANY_PARAMETERS", + "KDOC_WITHOUT_PARAM_TAG", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "EXTENSION_FUNCTION_WITH_CLASS", +) +suspend fun <T : Any> WithRequestStatusContext.post( + url: String, + params: T = jso { }, + headers: Headers, + body: dynamic, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::withModalResponseHandler, +): Response = request( + url = url.withParams(params), + method = "POST", + headers = headers, + body = body, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Perform a `DELETE` request from a functional component. + * + * @param url the request URL. + * @param headers the HTTP request headers. + * Use [jsonHeaders] for the standard `Accept` and `Content-Type` headers. + * @param loadingHandler use either [WithRequestStatusContext.loadingHandler], + * or [noopLoadingHandler]. + * @param responseHandler the response handler to be invoked. + * The default implementation is to show the modal dialog if the HTTP response + * code is not in the range of 200..299 (i.e. [Response.ok] is `false`). + * Alternatively, a custom or a [noopResponseHandler] can be used, or the + * return value can be inspected directly. + * @return the HTTP response _promise_, see + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * The response, even a successful one, can also be processed using + * [responseHandler]. + * @see jsonHeaders + * @see undefined + * @see WithRequestStatusContext.loadingHandler + * @see noopLoadingHandler + * @see noopResponseHandler + */ +@Suppress("EXTENSION_FUNCTION_WITH_CLASS") +suspend fun WithRequestStatusContext.delete( + url: String, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::withModalResponseHandler, +): Response = delete<dynamic>( + url = url, + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Performs a `DELETE` request from a functional component. + * + * @param T the type of [request parameters][params] (by default, use `dynamic`). + * @param url the request URL (may or may not end with `?`). + * @param params the request parameters, the default is an empty object (`jso {}`). + * @param headers the HTTP request headers. + * Use [jsonHeaders] for the standard `Accept` and `Content-Type` headers. + * @param loadingHandler use either [WithRequestStatusContext.loadingHandler], + * or [noopLoadingHandler]. + * @param responseHandler the response handler to be invoked. + * The default implementation is to show the modal dialog if the HTTP response + * code is not in the range of 200..299 (i.e. [Response.ok] is `false`). + * Alternatively, a custom or a [noopResponseHandler] can be used, or the + * return value can be inspected directly. + * @return the HTTP response _promise_, see + * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + * The response, even a successful one, can also be processed using + * [responseHandler]. + */ +@Suppress( + "KDOC_WITHOUT_PARAM_TAG", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "EXTENSION_FUNCTION_WITH_CLASS", +) +suspend fun <T : Any> WithRequestStatusContext.delete( + url: String, + params: T = jso { }, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::withModalResponseHandler, +): Response = request( + url = url.withParams(params), + method = "DELETE", + headers = headers, + loadingHandler = loadingHandler, + responseHandler = responseHandler, +) + +/** + * Handler that allows to show loading modal + * + * @param request REST API method + * @return [Response] received with [request] + */ +@Suppress("EXTENSION_FUNCTION_WITH_CLASS") +suspend fun WithRequestStatusContext.loadingHandler(request: suspend () -> Response) = run { + setLoadingCounter { it + 1 } + val deferred = coroutineScope.async { request() } + deferred.invokeOnCompletion { + setLoadingCounter { it - 1 } + } + deferred.await() +} + +/** + * @return true if given [Response] has 409 code, false otherwise + */ +@Suppress("MAGIC_NUMBER") +fun Response.isConflict(): Boolean = this.status == 409.toShort() + +/** + * @return true if given [Response] has 409 code, false otherwise + */ +@Suppress("MAGIC_NUMBER") +fun Response.isBadRequest(): Boolean = this.status == 400.toShort() + +/** + * @return true if given [Response] has 401 code, false otherwise + */ +@Suppress("MAGIC_NUMBER") +fun Response.isUnauthorized(): Boolean = this.status == 401.toShort() + +/** + * Reads the HTTP response body as a flow of strings. + * + * @return the string flow produced from the body of this HTTP response. + * @see Response.inputStream + */ +suspend fun Response.readLines(): Flow<String> = inputStream().decodeToString() + +/** + * Appends the [parameters][params] to this URL. + * + * @param params + * @return final URL with appended parameters after a `question` symbol + */ +fun <T : Any> String.withParams(params: T): String { + val paramString = URLSearchParams(params).toString() + + return when { + paramString.isEmpty() -> this + endsWith('?') -> this + paramString + contains('?') -> "$this&$paramString" + else -> "$this?$paramString" + } +} + +/** + * If this component has context, set [response] in this context. Otherwise, fallback to redirect. + * + * @param response + */ +@Suppress("MAGIC_NUMBER") +fun ComponentWithScope<*, *>.classComponentResponseHandler( + response: Response, +) { + val hasResponseContext = this.asDynamic().context is RequestStatusContext + if (hasResponseContext) { + this.withModalResponseHandler(response, false) + } +} + +/** + * @param response + */ +fun ComponentWithScope<*, *>.classComponentRedirectOnFallbackResponseHandler( + response: Response, +) { + val hasResponseContext = this.asDynamic().context is RequestStatusContext + if (hasResponseContext) { + this.withModalResponseHandler(response, true) + } +} + +/** + * Handler that allows to show loading modal + * + * @param request REST API method + * @return [Response] received with [request] + */ +@Suppress("MAGIC_NUMBER") +suspend fun ComponentWithScope<*, *>.classLoadingHandler(request: suspend () -> Response): Response { + val hasRequestStatusContext = this.asDynamic().context is RequestStatusContext + if (hasRequestStatusContext) { + return this.loadingHandler(request) + } + return request() +} + +/** + * If this component has context, set [response] in this context. Otherwise, fallback to redirect. + * + * @param response + */ +@Suppress("MAGIC_NUMBER") +fun ComponentWithScope<*, *>.classComponentResponseHandlerWithValidation( + response: Response, +) { + val hasResponseContext = this.asDynamic().context is RequestStatusContext + if (hasResponseContext) { + this.responseHandlerWithValidation(response) + } +} + +/** + * @param response + */ +@Suppress("EXTENSION_FUNCTION_WITH_CLASS", "MAGIC_NUMBER") +fun WithRequestStatusContext.responseHandlerWithValidation( + response: Response, +) { + if (!response.ok && !response.isConflict()) { + setResponse(response) + } +} + +/** + * Handler that allows to show loading modal + * + * @param request REST API method + * @return [Response] received with [request] + */ +private suspend fun ComponentWithScope<*, *>.loadingHandler(request: suspend () -> Response) = run { + val context: RequestStatusContext = this.asDynamic().context + context.setLoadingCounter { it + 1 } + val deferred = scope.async { request() } + deferred.invokeOnCompletion { + context.setLoadingCounter { it - 1 } + } + deferred.await() +} + +@Suppress("MAGIC_NUMBER") +private fun ComponentWithScope<*, *>.withModalResponseHandler( + response: Response, + isNeedRedirect: Boolean +) { + if (!response.ok) { + val statusContext: RequestStatusContext = this.asDynamic().context + statusContext.setRedirectToFallbackView(isNeedRedirect && response.status == 404.toShort()) + statusContext.setResponse.invoke(response) + } +} + +@Suppress("EXTENSION_FUNCTION_WITH_CLASS") +private fun WithRequestStatusContext.withModalResponseHandler( + response: Response, +) { + if (!response.ok) { + setResponse(response) + } +} + +private fun ComponentWithScope<*, *>.responseHandlerWithValidation( + response: Response, +) { + if (!response.ok && !response.isConflict()) { + val statusContext: RequestStatusContext = this.asDynamic().context + statusContext.setRedirectToFallbackView(response.isUnauthorized()) + statusContext.setResponse.invoke(response) + } +} + +/** + * Reads the HTTP response body as a byte flow. + * + * @return the byte flow produced from the body of this HTTP response. + * @see Response.readLines + */ +@OptIn(ExperimentalCoroutinesApi::class) +private suspend fun Response.inputStream(): Flow<Byte> { + val reader = body.unsafeCast<ReadableStream<Uint8Array>>().getReader() + + return flow { + /* + * Read the response body in byte chunks, emitting each chunk as it's + * available. + */ + while (true) { + val resultAsync: ResultAsync<Uint8Array> = reader + .read() + .unsafeCast<ResultAsync<Uint8Array>>() + + val result = resultAsync.await() + + val jsBytes: Uint8Array = result.value + + if (jsBytes == undefined || result.done) { + break + } + + emit(jsBytes.asByteArray()) + } + } + .flatMapConcat { bytes -> + /* + * Concatenate all chunks into a byte flow. + */ + bytes.asSequence().asFlow() + } + .onCompletion { + /* + * Wait for the stream to get closed. + */ + reader.closed.asDeferred().await() + + /* + * Release the reader's lock on the stream. + * See https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/releaseLock + */ + reader.releaseLock() + } +} + +/** + * Converts this [Uint8Array] (most probably obtained by reading an HTTP + * response body) to the standard [ByteArray]. + * + * Conversion from an `Uint8Array` to an `Int8Array` is necessary — + * otherwise, non-ASCII data will get corrupted. + * + * @return the converted instance. + */ +@Suppress("UnsafeCastFromDynamic") +private fun Uint8Array.asByteArray(): ByteArray = Int8Array( + buffer = buffer.unsafeCast<ArrayBuffer>(), + byteOffset = byteOffset, + length = length, +) + .asDynamic() + +/** + * Perform an HTTP request using Fetch API. Suspending function that returns a [Response] - a JS promise with result. + * + * @param url request URL + * @param method HTTP request method + * @param headers HTTP headers + * @param body request body + * @param credentials [RequestCredentials] for fetch API + * @param loadingHandler + * @param responseHandler + * @return [Response] instance + */ +@Suppress("TOO_MANY_PARAMETERS", "LongParameterList") +suspend fun request( + url: String, + method: String, + headers: Headers, + body: dynamic = undefined, + credentials: RequestCredentials? = undefined, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = ::noopResponseHandler, +): Response = loadingHandler { + window.fetch( + input = url, + RequestInit( + method = method, + headers = headers, + body = body, + credentials = credentials, + ) + ) + .await() +} + .also { response -> + if (responseHandler != undefined) { + responseHandler(response) + } + } + +/** + * Handler that allows to skip loading modal + * + * @param request REST API method + * @return [Response] received with [request] + */ +suspend fun noopLoadingHandler(request: suspend () -> Response) = request() + +/** + * Can be used to explicitly specify, that response will be handled is a custom way + * + * @param response + * @return Unit + */ +fun noopResponseHandler(@Suppress("UNUSED_PARAMETER") response: Response) = Unit diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/RouterUtils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/RouterUtils.kt new file mode 100644 index 0000000000..7e7dce6050 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/RouterUtils.kt @@ -0,0 +1,51 @@ +/** + * Utilities to work with react-router + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS") + +package com.saveourtool.save.frontend.common.utils + +import react.* +import react.router.* +import remix.run.router.Location +import remix.run.router.Params + +/** + * Interface that provides [NavigateFunction] to use [navigation support](https://reactrouter.com/en/v6.3.0/api#navigation) + * from react-router v6 + */ +interface NavigateFunctionContext { + /** + * Function that performs navigation using react-router library + */ + val navigate: NavigateFunction +} + +/** + * @param handler DOM builder that can consume [NavigateFunctionContext] + */ +fun ChildrenBuilder.withNavigate(handler: ChildrenBuilder.(NavigateFunctionContext) -> Unit) { + val wrapper: FC<Props> = FC { + val navigate = useNavigate() + val ctx = object : NavigateFunctionContext { + override val navigate: NavigateFunction = navigate + } + handler(ctx) + } + wrapper() +} + +/** + * Wrapper function component to allow using router props in class components + * as suggested in https://reactrouter.com/docs/en/v6/faq#what-happened-to-withrouter-i-need-it + * + * @param handler router props aware builder + * @return a function component + */ +@Suppress("TYPE_ALIAS") +fun withRouter(handler: ChildrenBuilder.(Location<*>, Params) -> Unit): FC<Props> = FC { + val location = useLocation() + val params = useParams() + handler(location, params) +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/StateWithRole.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/StateWithRole.kt new file mode 100644 index 0000000000..9db334e3f0 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/StateWithRole.kt @@ -0,0 +1,14 @@ +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.domain.Role +import react.State + +/** + * State with role of current user + */ +external interface StateWithRole : State { + /** + * Role of a user that is seeing this view + */ + var selfRole: Role +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/TopBarUrl.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/TopBarUrl.kt new file mode 100644 index 0000000000..8fb4400a39 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/TopBarUrl.kt @@ -0,0 +1,148 @@ +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.validation.FrontendRoutes + +/** + * The class for analyzing url address and creating links in topBar + * @property href + */ +class TopBarUrl(val href: String) { + /** + * CurrentPath is the link that we put in buttons + */ + var currentPath = "#" + private var circumstance: SituationUrlClassification = SituationUrlClassification.KEYWORD_PROCESS + private var processLastSegments = 0 + private val sizeUrlSegments: Int = href.split("/").size + + init { + findExclude(href) + } + + /** + * The function is called to specify the link address in the button before creating the button itself + * + * @param pathPart is an appended suffix to an already existing [currentPath] + */ + fun changeUrlBeforeButton(pathPart: String) { + currentPath = when (circumstance) { + SituationUrlClassification.PROJECT, SituationUrlClassification.ORGANIZATION -> "/${FrontendRoutes.PROJECTS}" + SituationUrlClassification.ARCHIVE -> "/${FrontendRoutes.AWESOME_BENCHMARKS}" + SituationUrlClassification.DETAILS, SituationUrlClassification.EXECUTION -> if (pathPart == "execution") currentPath else mergeUrls(pathPart) + else -> mergeUrls(pathPart) + } + } + + /** + * The function is called to specify the link address in the button after creating the button itself + * + * @param pathPart is an appended suffix to an already existing [currentPath] + */ + fun changeUrlAfterButton(pathPart: String) { + fixCurrentPathAfter(pathPart) + fixExcludeAfter("\\d+".toRegex().matches(pathPart)) + } + + /** + * The function set a flag whether to create this button or not + * + * @param index + */ + fun shouldDisplayPathFragment(index: Int) = when (circumstance) { + SituationUrlClassification.KEYWORD_PROCESS_LAST_SEGMENTS -> index >= sizeUrlSegments - 1 - processLastSegments + SituationUrlClassification.KEYWORD_NOT_PROCESS -> false + else -> true + } + + /** + * The function classification url address + * + * @param href + */ + private fun findExclude(href: String) { + circumstance = SituationUrlClassification.values() + .filter { it.regex != null } + .firstOrNull { href.contains(Regex(it.regex ?: "")) } + ?: SituationUrlClassification.KEYWORD_PROCESS + } + + /** + * Function check exclude and generate currentPath after the buttons creating + * + * @param pathPart + */ + private fun fixCurrentPathAfter(pathPart: String) { + currentPath = when (circumstance) { + SituationUrlClassification.PROJECT, SituationUrlClassification.ORGANIZATION, SituationUrlClassification.ARCHIVE -> "" + SituationUrlClassification.DETAILS, SituationUrlClassification.EXECUTION -> if (pathPart == "execution") mergeUrls(pathPart) else currentPath + else -> currentPath + } + } + + /** + * The function changes the exclude after the button is created + * + * @param isNumber + */ + private fun fixExcludeAfter(isNumber: Boolean) { + circumstance = when (circumstance) { + SituationUrlClassification.PROJECT, SituationUrlClassification.ORGANIZATION, SituationUrlClassification.ARCHIVE -> SituationUrlClassification.KEYWORD_PROCESS + SituationUrlClassification.DETAILS -> if (isNumber) setProcessLastSegments(1) else SituationUrlClassification.DETAILS + else -> circumstance + } + } + + private fun mergeUrls(secondPath: String) = "$currentPath/$secondPath" + + private fun setProcessLastSegments(number: Int): SituationUrlClassification { + processLastSegments = number + return SituationUrlClassification.KEYWORD_PROCESS_LAST_SEGMENTS + } + + /** + * This Enum class classifies work with the url address segment + * @property regex + */ + enum class SituationUrlClassification(val regex: String? = null) { + /** + * Situation with the processing of the "archive" in the url address - need for tabs in AwesomeBenchmarksView + */ + ARCHIVE, + + /** + * Situation with the processing of the "details" in the url address - need for deleted multi-segment urls, starting with the word "details" + */ + DETAILS("/[^/]+/[^/]+/history/execution/\\d+/details"), + + /** + * Situation with the processing of the "execution" in the url address - need for redirect to the page with the executions history + */ + EXECUTION("/[^/]+/[^/]+/history/execution"), + + /** + * The button with this url segment is not created + */ + KEYWORD_NOT_PROCESS, + + /** + * The button with this url segment is created without changes + */ + KEYWORD_PROCESS, + + /** + * The button is created if this segment is one of the last + */ + KEYWORD_PROCESS_LAST_SEGMENTS, + + /** + * Situation with the processing of the "organization" in the url address - need for tabs in OrganizationView + */ + ORGANIZATION, + + /** + * Situation with the processing of the "project" in the url address - need for tabs in ProjectView + */ + PROJECT, + ; + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/UsefulUrls.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/UsefulUrls.kt new file mode 100644 index 0000000000..a291929545 --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/UsefulUrls.kt @@ -0,0 +1,20 @@ +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.validation.NAME_FRAGMENT_CLASS +import com.saveourtool.save.validation.ValidationRegularExpressions.URL_VALIDATOR + +/** + * Enum only for storing URLs to well-known website + * + * @property basicUrl [String] basic url of a website + * @property regex [Regex] that is used during validation + */ +enum class UsefulUrls(val basicUrl: String, val regex: Regex) { + GITEE("https://gitee.com/", """https?://(?:www\.)?gitee.com\b$NAME_FRAGMENT_CLASS*""".toRegex()), + GITHUB("https://github.com/", """https?://(?:www\.)?github.com\b$NAME_FRAGMENT_CLASS*""".toRegex()), + LINKEDIN("https://linkedin.com/", """https?://(?:www\.)?linkedin.com\b$NAME_FRAGMENT_CLASS*""".toRegex()), + TWITTER("https://twitter.com/", """https?://(?:www\.)?twitter.com\b$NAME_FRAGMENT_CLASS*""".toRegex()), + WEBSITE("https://", URL_VALIDATOR.value), + XCOM("https://x.com/", """https?://(?:www\.)?x.com\b$NAME_FRAGMENT_CLASS*""".toRegex()), + ; +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/UserInfoAwareProps.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/UserInfoAwareProps.kt new file mode 100644 index 0000000000..83dca5f0bc --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/UserInfoAwareProps.kt @@ -0,0 +1,55 @@ +/** + * Props for UserInfo + */ + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.info.UserInfo +import react.FC +import react.Props +import react.PropsWithChildren +import react.StateSetter + +/** + * Property to propagate user info from App + */ +external interface UserInfoAwareProps : Props { + /** + * Currently logged-in user or null + */ + var userInfo: UserInfo? +} + +/** + * Property to propagate user info from App with children + */ +external interface UserInfoAwarePropsWithChildren : UserInfoAwareProps, PropsWithChildren + +/** + * Property to propagate user info from App with ability to update it + */ +external interface UserInfoAwareMutableProps : UserInfoAwareProps { + /** + * Setter of user info (it can be updated in settings on several views) + * + * After updating user information we will update userSettings without re-rendering the page + * PLEASE NOTE: THIS PROPERTY AFFECTS RENDERING OF WHOLE APP.KT + * IF YOU HAVE SOME PROBLEMS WITH IT, CHECK THAT YOU HAVE PROPAGATED IT PROPERLY: + * { this.userInfoSetter = (!) PROPS (!) .userInfoSetter } + */ + var userInfoSetter: StateSetter<UserInfo?> +} + +/** + * Property to propagate user info from App with ability to update it with children + */ +external interface UserInfoAwareMutablePropsWithChildren : UserInfoAwareMutableProps, PropsWithChildren + +/** + * @return [FC] with [UserInfoAwareMutableProps] instead of [UserInfoAwareProps] + */ +internal fun FC<UserInfoAwareProps>.asMutable(): FC<UserInfoAwareMutableProps> = FC { props -> + this@asMutable { + this.userInfo = props.userInfo + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Utils.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Utils.kt new file mode 100644 index 0000000000..3848e9889a --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/Utils.kt @@ -0,0 +1,281 @@ +/** + * Various utils for frontend + */ + +package com.saveourtool.save.frontend.common.utils + +import com.saveourtool.save.domain.Role +import com.saveourtool.save.domain.Role.SUPER_ADMIN +import com.saveourtool.save.frontend.common.externals.fontawesome.FontAwesomeIconModule +import com.saveourtool.save.frontend.common.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.info.UserInfo + +import js.core.jso +import org.w3c.dom.Location +import org.w3c.files.Blob +import org.w3c.files.BlobPropertyBag +import org.w3c.xhr.FormData +import react.ChildrenBuilder +import react.StateSetter +import react.dom.events.ChangeEvent +import react.dom.events.MouseEventHandler +import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.br +import react.dom.html.ReactHTML.samp +import react.dom.html.ReactHTML.small +import react.dom.html.ReactHTML.table +import react.dom.html.ReactHTML.tbody +import react.dom.html.ReactHTML.td +import react.dom.html.ReactHTML.tr +import web.cssom.ClassName +import web.cssom.Color +import web.cssom.Cursor +import web.dom.Element +import web.html.HTMLInputElement + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Avatar placeholder if an error was thrown. + */ +const val AVATAR_PLACEHOLDER = "/img/undraw_image_not_found.png" + +/** + * Avatar profile for those who don't want to upload it + */ +const val AVATAR_PROFILE_PLACEHOLDER = "/img/avatar_placeholder.png" + +/** + * Timeout after `onBlur` event takes place but before the component is hidden + */ +const val ON_BLUR_TIMEOUT_MILLIS = 200 + +/** + * The body of a [useDeferredRequest] invocation. + * + * @param T the return type of this action. + */ +typealias DeferredRequestAction<T> = suspend (WithRequestStatusContext) -> T + +/** + * Append an object [obj] to `this` [FormData] as a JSON, using kx.serialization for serialization + * + * @param name key to be appended to the form data + * @param obj an object to be appended + * @return Unit + */ +inline fun <reified T> FormData.appendJson(name: String, obj: T) = + append( + name, + Blob( + arrayOf(Json.encodeToString(obj)), + BlobPropertyBag("application/json") + ) + ) + +/** + * @return [Role] if string matches any role, else throws [IllegalStateException] + * @throws IllegalStateException if string is not matched with any role + */ +fun String.toRole() = Role.values().find { + this == it.formattedName || this == it.toString() +} ?: throw IllegalStateException("Unknown role is passed: $this") + +/** + * @return lambda which does the same as receiver but takes unused arg + */ +fun <T> (() -> Unit).withUnusedArg(): (T) -> Unit = { this() } + +/** + * Converts `this` no-argument function to a [MouseEventHandler]. + * + * @return `this` function as a [MouseEventHandler]. + * @see MouseEventHandler + */ +fun <T : Element> (() -> Unit).asMouseEventHandler(): MouseEventHandler<T> = + { + this() + } + +/** + * @return lambda which does the same but take value from [HTMLInputElement] + */ +fun StateSetter<String?>.fromInput(): (ChangeEvent<HTMLInputElement>) -> Unit = + { event -> this(event.target.value) } + +/** + * @return lambda which does the same but take value from [HTMLInputElement] + */ +fun StateSetter<String>.fromInput(): (ChangeEvent<HTMLInputElement>) -> Unit = + { event -> this(event.target.value) } + +/** + * Parse string in format + * + * FILE (START_ROW:START_COL-END_ROW:END_COL) + * + * into [[START_ROW, START_COL], [END_ROW, END_COL]] + * + * @return list in format: [[START_ROW, START_COL], [END_ROW, END_COL]] + */ +@Suppress("MAGIC_NUMBER") +fun String.parsePositionString(): List<Int>? = substringAfter("(", "") + .substringBefore(")", "") + .split("-") + .map { positionList -> positionList.split(":") } + .flatten() + .takeIf { it.size == 4 } + ?.mapIndexed { idx, value -> value.toInt() - idx % 2 } + +/** + * @param time time to set to [LocalDateTime] + * @return [LocalDateTime] from [String] + */ +fun String.dateStringToLocalDateTime(time: LocalTime = LocalTime(0, 0, 0)) = LocalDateTime( + LocalDate.parse(this), + time, +) + +/** + * Dirty hack for the COSV location + * Should be removed in future + * + * @return true if we are in COSV domains range + */ +fun Location.isCosvDomain() = this.hostname in setOf("cosv.dev", "cosv.gitlink.org.cn") + +/** + * @return `true` if this user is a super-admin, `false` otherwise. + * @see Role.isSuperAdmin + */ +fun UserInfo?.isSuperAdmin(): Boolean = this?.globalRole.isSuperAdmin() + +/** + * @return `true` if this is a super-admin role, `false` otherwise. + * @see UserInfo.isSuperAdmin + */ +fun Role?.isSuperAdmin(): Boolean = this?.isHigherOrEqualThan(SUPER_ADMIN) ?: false + +/** + * Adds this text to ChildrenBuilder line by line, separating with `<br>` + * + * @param text text to display + */ +@Suppress("EMPTY_BLOCK_STRUCTURE_ERROR") +fun ChildrenBuilder.multilineText(text: String) { + text.lines().forEach { + small { + samp { + +it + } + } + br { } + } +} + +/** + * @param text + */ +fun ChildrenBuilder.multilineTextWithIndices(text: String) { + table { + className = ClassName("table table-borderless table-hover table-sm") + tbody { + text.lines().filterNot { it.isEmpty() }.forEachIndexed { i, line -> + tr { + td { + +"${i + 1}" + } + td { + +line + } + } + } + } + } +} + +/** + * @param maxLength + * @return true if string is invalid + */ +fun String?.isInvalid(maxLength: Int) = this.isNullOrBlank() || this.contains(" ") || this.length > maxLength + +/** + * @param digits number of digits to round to + */ +fun Double.toFixed(digits: Int) = asDynamic().toFixed(digits) + +/** + * @param digits number of digits to round to + * @return rounded value as String + */ +fun Double.toFixedStr(digits: Int) = toFixed(digits).toString() + +/** + * @param title + * @param icon + */ +fun ChildrenBuilder.title(title: String, icon: FontAwesomeIconModule) { + ReactHTML.div { + className = ClassName("row justify-content-center") + ReactHTML.h4 { + style = jso { + color = "#5a5c69".unsafeCast<Color>() + } + fontAwesomeIcon(icon = icon) + + className = ClassName("mt-3 mb-4") + +title + } + } +} + +/** + * @param selectedTab + * @param tabsList + * @param setSelectedTab + * @param navClassName + */ +fun ChildrenBuilder.tab( + selectedTab: String, + tabsList: List<String>, + navClassName: String = "nav nav-tabs mb-4", + setSelectedTab: (String) -> Unit +) { + ReactHTML.div { + className = ClassName("row justify-content-center") + + ReactHTML.nav { + className = ClassName(navClassName) + tabsList.forEachIndexed { i, value -> + ReactHTML.li { + key = i.toString() + className = ClassName("nav-item") + val classVal = + if (selectedTab == value) { + " active font-weight-bold" + } else { + "" + } + ReactHTML.p { + className = ClassName("nav-link $classVal text-gray-800") + onClick = { + if (selectedTab != value) { + setSelectedTab(value) + } + } + style = jso { + cursor = "pointer".unsafeCast<Cursor>() + } + + +value + } + } + } + } + } +} diff --git a/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/WindowOpenness.kt b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/WindowOpenness.kt new file mode 100644 index 0000000000..7327350a5d --- /dev/null +++ b/save-frontend-common/src/main/kotlin/com/saveourtool/save/frontend/common/utils/WindowOpenness.kt @@ -0,0 +1,55 @@ +package com.saveourtool.save.frontend.common.utils + +import react.StateInstance +import react.useState + +/** + * Util class which stores state about window openness + * + * @param isOpenState [StateInstance] with [Boolean] value + */ +class WindowOpenness( + private val isOpenState: StateInstance<Boolean> +) { + /** + * @return current state of window + */ + fun isOpen(): Boolean { + val (isOpen, _) = isOpenState + return isOpen + } + + /** + * Open window + */ + fun openWindow() { + setIsOpen(true) + } + + /** + * @return action to open window + */ + fun openWindowAction(): () -> Unit = { openWindow() } + + /** + * Close window + */ + fun closeWindow() { + setIsOpen(false) + } + + /** + * @return action to close window + */ + fun closeWindowAction(): () -> Unit = { closeWindow() } + + private fun setIsOpen(value: Boolean) { + val (_, setIsOpenState) = isOpenState + setIsOpenState(value) + } +} + +/** + * @return [WindowOpenness] with closed state by default + */ +fun useWindowOpenness() = WindowOpenness(useState(false)) diff --git a/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/components/FooterTest.kt b/save-frontend-common/src/test/kotlin/components/FooterTest.kt similarity index 76% rename from save-frontend/src/test/kotlin/com/saveourtool/save/frontend/components/FooterTest.kt rename to save-frontend-common/src/test/kotlin/components/FooterTest.kt index 2caaa47b1e..455bb15070 100644 --- a/save-frontend/src/test/kotlin/com/saveourtool/save/frontend/components/FooterTest.kt +++ b/save-frontend-common/src/test/kotlin/components/FooterTest.kt @@ -1,7 +1,8 @@ -package com.saveourtool.save.frontend.components +package components -import com.saveourtool.save.frontend.externals.render -import com.saveourtool.save.frontend.externals.screen +import com.saveourtool.save.frontend.common.components.footer +import externals.render +import externals.screen import generated.SAVE_CLOUD_VERSION diff --git a/save-frontend-common/src/test/kotlin/externals/ReactTestingLibrary.kt b/save-frontend-common/src/test/kotlin/externals/ReactTestingLibrary.kt new file mode 100644 index 0000000000..ce3db77cc9 --- /dev/null +++ b/save-frontend-common/src/test/kotlin/externals/ReactTestingLibrary.kt @@ -0,0 +1,53 @@ +/** + * Declarations from https://github.com/testing-library/dom-testing-library + * https://github.com/testing-library/react-testing-library/blob/main/types/index.d.ts + */ + +@file:JsModule("@testing-library/react") +@file:JsNonModule +@file:Suppress( + "USE_DATA_CLASS", + "MISSING_KDOC_ON_FUNCTION", + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_TOP_LEVEL", + "KDOC_NO_EMPTY_TAGS", + "KDOC_WITHOUT_PARAM_TAG", + "KDOC_WITHOUT_RETURN_TAG", +) + +package externals + +import web.html.HTMLElement +import react.Props +import react.ReactElement +import kotlin.js.Promise + +/** + * https://testing-library.com/docs/queries/about/#screen + * https://github.com/testing-library/dom-testing-library/blob/main/types/screen.d.ts + */ +external val screen: BoundFunctions + +external class RenderResult { + var container: dynamic +} + +/** + * https://github.com/testing-library/dom-testing-library/blob/main/types/queries.d.ts + */ +external class BoundFunctions { + fun getByText(text: String, options: dynamic = definedExternally): HTMLElement + + /** + * https://testing-library.com/docs/queries/byrole + */ + fun getByRole(vararg args: dynamic): HTMLElement + + fun findByText(text: String, options: dynamic = definedExternally, waitForOptions: dynamic = definedExternally): Promise<HTMLElement> + + fun queryByText(text: String, options: dynamic = definedExternally): HTMLElement? +} + +external fun <P : Props> render(ui: ReactElement<P>, options: dynamic = definedExternally): RenderResult + +external fun <T> waitForElementToBeRemoved(elem: T, options: dynamic = definedExternally): Promise<Unit> diff --git a/save-frontend/build.gradle.kts b/save-frontend/build.gradle.kts index fc883d1531..3504e95034 100644 --- a/save-frontend/build.gradle.kts +++ b/save-frontend/build.gradle.kts @@ -18,6 +18,7 @@ rootProject.plugins.withType<NodeJsRootPlugin> { dependencies { implementation(projects.saveCloudCommon) + implementation(projects.saveFrontendCommon) implementation(enforcedPlatform(libs.kotlin.wrappers.bom)) implementation("org.jetbrains.kotlin-wrappers:kotlin-react") @@ -224,14 +225,6 @@ tasks.named<KotlinJsTest>("browserTest").configure { inputs.file(mswScriptTargetFile) } -kotlin.sourceSets.getByName("main") { - kotlin.srcDir( - tasks.named("generateSaveCloudVersionFile").map { - it.outputs.files.singleFile - } - ) -} - tasks.withType<org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack> { // Since we inject timestamp into HTML file, we would like this task to always be re-run. inputs.property("Build timestamp", System.currentTimeMillis()) diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt index d3e4b15466..9007c55096 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt @@ -4,6 +4,7 @@ package com.saveourtool.save.frontend +import com.saveourtool.save.frontend.common.components.footer import com.saveourtool.save.frontend.components.* import com.saveourtool.save.frontend.components.basic.cookieBanner import com.saveourtool.save.frontend.components.basic.scrollToTopButton diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/ErrorBoundary.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/ErrorBoundary.kt index 3cc2a20488..04e71fe811 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/ErrorBoundary.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/ErrorBoundary.kt @@ -4,6 +4,7 @@ package com.saveourtool.save.frontend.components +import com.saveourtool.save.frontend.common.components.footer import com.saveourtool.save.frontend.components.topbar.topBarComponent import com.saveourtool.save.frontend.components.views.FallbackView diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/HasErrorModal.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/HasErrorModal.kt index e896799fc7..b4de95aff1 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/HasErrorModal.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/HasErrorModal.kt @@ -2,6 +2,7 @@ package com.saveourtool.save.frontend.components +import com.saveourtool.save.frontend.common.components.footer import com.saveourtool.save.frontend.components.modal.loaderModalStyle import com.saveourtool.save.frontend.components.modal.modal import com.saveourtool.save.frontend.components.topbar.topBarComponent 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 21eb4c7325..654b532457 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,13 +7,13 @@ 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.components.RequestStatusContext import com.saveourtool.save.frontend.components.basic.* import com.saveourtool.save.frontend.components.basic.organizations.* import com.saveourtool.save.frontend.components.modal.displayModal import com.saveourtool.save.frontend.components.modal.smallTransparentModalStyle import com.saveourtool.save.frontend.components.requestStatusContext -import com.saveourtool.save.frontend.components.views.vuln.vulnerabilityTableComponent import com.saveourtool.save.frontend.externals.fontawesome.* import com.saveourtool.save.frontend.http.getOrganization import com.saveourtool.save.frontend.http.postImageUpload diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/userprofile/UserProfileView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/userprofile/UserProfileView.kt index b07c1bf4ff..8113df584d 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/userprofile/UserProfileView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/userprofile/UserProfileView.kt @@ -6,12 +6,12 @@ package com.saveourtool.save.frontend.components.views.userprofile import com.saveourtool.save.entities.OrganizationDto import com.saveourtool.save.frontend.TabMenuBar +import com.saveourtool.save.frontend.common.components.views.vuln.vulnerabilityTableComponent import com.saveourtool.save.frontend.components.basic.renderAvatar import com.saveourtool.save.frontend.components.inputform.InputTypes import com.saveourtool.save.frontend.components.modal.displayModal import com.saveourtool.save.frontend.components.modal.mediumTransparentModalStyle import com.saveourtool.save.frontend.components.views.contests.tab -import com.saveourtool.save.frontend.components.views.vuln.vulnerabilityTableComponent import com.saveourtool.save.frontend.externals.fontawesome.* import com.saveourtool.save.frontend.utils.* import com.saveourtool.save.info.UserInfo 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 d25e115318..a859245197 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 @@ -20,12 +20,9 @@ import com.saveourtool.save.frontend.components.views.demo.demoCollectionView import com.saveourtool.save.frontend.components.views.demo.demoView import com.saveourtool.save.frontend.components.views.index.indexView import com.saveourtool.save.frontend.components.views.projectcollection.CollectionView -import com.saveourtool.save.frontend.components.views.toprating.topRatingView import com.saveourtool.save.frontend.components.views.userprofile.userProfileView import com.saveourtool.save.frontend.components.views.usersettings.* -import com.saveourtool.save.frontend.components.views.vuln.* import com.saveourtool.save.frontend.components.views.welcome.saveWelcomeView -import com.saveourtool.save.frontend.components.views.welcome.vulnWelcomeView import com.saveourtool.save.frontend.utils.* import com.saveourtool.save.frontend.utils.isSuperAdmin import com.saveourtool.save.validation.FrontendRoutes.* @@ -134,20 +131,6 @@ val basicRouting: FC<UserInfoAwareMutablePropsWithChildren> = FC { props -> } } - val vulnerabilityCollectionView = withRouter { location, _ -> - vulnerabilityCollectionView { - currentUserInfo = props.userInfo - filter = URLSearchParams(location.search).toVulnerabilitiesFilter() - } - } - - val vulnerabilityView = withRouter { _, params -> - vulnerabilityView { - identifier = requireNotNull(params["identifier"]) - currentUserInfo = props.userInfo - } - } - val createProjectProblemView = withRouter { _, params -> createProjectProblem { organizationName = requireNotNull(params["owner"]) @@ -167,7 +150,6 @@ val basicRouting: FC<UserInfoAwareMutablePropsWithChildren> = FC { props -> listOf( indexView.create { userInfo = props.userInfo } to "/", saveWelcomeView.create { userInfo = props.userInfo } to SAVE, - vulnWelcomeView.create { userInfo = props.userInfo } to VULN, sandboxView.create() to SANDBOX, AboutUsView::class.react.create() to ABOUT_US, createOrganizationView.create() to CREATE_ORGANIZATION, @@ -200,17 +182,11 @@ val basicRouting: FC<UserInfoAwareMutablePropsWithChildren> = FC { props -> demoView.create() to "$DEMO/:organizationName/:projectName", cpgView.create() to "$DEMO/cpg", testExecutionDetailsView.create() to "/:organization/:project/history/execution/:executionId/test/:testId", - vulnerabilityCollectionView.create() to "$VULN/list/:params?", - createVulnerabilityView.create() to VULN_CREATE, - uploadVulnerabilityView.create() to VULN_UPLOAD, - vulnerabilityView.create() to "$VULNERABILITY_SINGLE/:identifier", demoCollectionView.create() to DEMO, userProfileView.create() to "$VULN_PROFILE/:name", - topRatingView.create() to VULN_TOP_RATING, termsOfUsageView.create() to TERMS_OF_USE, cookieTermsOfUse.create() to COOKIE, thanksForRegistrationView.create() to THANKS_FOR_REGISTRATION, - cosvSchemaView.create() to VULN_COSV_SCHEMA, userSettingsView.create { this.userInfoSetter = props.userInfoSetter diff --git a/settings.gradle.kts b/settings.gradle.kts index 12b40171a1..42b7a9b361 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -53,6 +53,8 @@ include("save-demo-cpg") include("test-analysis-core") include("save-demo-agent") include("save-cosv") +include("save-cosv-frontend") +include("save-frontend-common") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")